fix(api): await Kafka acknowledgements and bound HTTP requests

This commit is contained in:
2026-09-19 21:30:27 +03:30
parent 178741590e
commit 33593609e3
7 changed files with 447 additions and 406 deletions

View File

@@ -1,285 +1,194 @@
import asyncio
import json
import logging
from typing import Optional, Tuple
import signal
from app.api.metrics import api_metrics
from app.api.producer import (
AsyncProducerWorker,
ConfluentKafkaProducer,
MessageProducer,
MockMessageProducer,
)
from app.api.producer import ConfluentKafkaProducer
from app.api.stats import get_aggregated_pipeline_stats
from app.common.config import config
from app.common.logging import setup_logger
from app.common.validation import PayloadError, parse_payload, validate_structure
logger = setup_logger("ingestion-api")
class HTTPError(ValueError):
def __init__(self, status, reason, detail):
super().__init__(detail)
self.status, self.reason = status, reason
class IngestionAPIServer:
"""
Framework-less HTTP/1.1 Ingestion API server built on standard library asyncio.
Enforces bounded async queue backpressure and dispatches to Kafka.
"""
def __init__(
self,
host: Optional[str] = None,
port: Optional[int] = None,
queue_max_size: Optional[int] = None,
producer: Optional[MessageProducer] = None,
worker_stats_url: Optional[str] = None,
):
self.host = host or config.api.host
self.port = port or config.api.port
self.queue_max_size = queue_max_size or config.api.queue_max_size
def __init__(self, host=None, port=None, queue_max_size=None, producer=None,
worker_stats_url=None, max_body_bytes=None, read_timeout=None,
delivery_timeout=None):
self.host = host if host is not None else config.api.host
self.port = port if port is not None else config.api.port
self.queue_max_size = queue_max_size if queue_max_size is not None else config.api.queue_max_size
self.max_body_bytes = max_body_bytes if max_body_bytes is not None else config.api.max_body_bytes
self.read_timeout = read_timeout if read_timeout is not None else config.api.read_timeout_seconds
self.delivery_timeout = delivery_timeout if delivery_timeout is not None else config.kafka.delivery_timeout_seconds
if min(self.queue_max_size, self.max_body_bytes, self.read_timeout, self.delivery_timeout) <= 0:
raise ValueError("API capacities and timeouts must be positive")
self.worker_stats_url = worker_stats_url or config.api.worker_stats_url
self.producer = producer if producer is not None else ConfluentKafkaProducer()
self._pending = set()
self._clients = set()
self._server = None
self._stopping = False
self.queue: asyncio.Queue = asyncio.Queue(maxsize=self.queue_max_size)
self.producer = producer or ConfluentKafkaProducer()
self.dispatcher = AsyncProducerWorker(
queue=self.queue,
producer=self.producer,
topic=config.kafka.raw_topic,
)
async def start(self):
self._server = await asyncio.start_server(self._handle_client, self.host, self.port)
self.port = self._server.sockets[0].getsockname()[1]
logger.info(f"Listening on {self.host}:{self.port}", extra={"event": "server_started"})
self._server: Optional[asyncio.Server] = None
self._running = False
async def start(self) -> None:
"""Start the async producer dispatcher and HTTP server."""
await self.dispatcher.start()
self._server = await asyncio.start_server(
self._handle_client, self.host, self.port
)
self._running = True
logger.info(
f"Ingestion API listening on http://{self.host}:{self.port} (queue capacity: {self.queue_max_size})",
extra={"event": "server_started"},
)
async def stop(self) -> None:
"""Stop the HTTP server and flush dispatcher queue."""
self._running = False
async def stop(self):
self._stopping = True
if self._server:
self._server.close()
await self._server.wait_closed()
await self.dispatcher.stop()
self.producer.close()
logger.info("Ingestion API stopped cleanly", extra={"event": "server_stopped"})
deadline = asyncio.get_running_loop().time() + config.api.shutdown_timeout_seconds
outstanding = self._pending | self._clients
if outstanding:
_, remaining = await asyncio.wait(outstanding, timeout=max(0, deadline-asyncio.get_running_loop().time()))
for task in remaining:
if isinstance(task, asyncio.Task):
task.cancel()
await asyncio.gather(*(t for t in remaining if isinstance(t, asyncio.Task)), return_exceptions=True)
await asyncio.to_thread(self.producer.close, max(0, deadline-asyncio.get_running_loop().time()))
logger.info("API drained and stopped", extra={"event": "server_stopped"})
async def _handle_client(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
async def _read_request(self, reader):
line = await reader.readline()
if not line:
return None
try:
# Read request line
request_line = await reader.readline()
if not request_line:
writer.close()
await writer.wait_closed()
return
method, path, version = line.decode("ascii").strip().split()
except (ValueError, UnicodeError) as exc:
raise HTTPError(400, "bad_request", "Invalid request line") from exc
if version not in ("HTTP/1.0", "HTTP/1.1"):
raise HTTPError(400, "bad_request", "Unsupported HTTP version")
headers = {}
header_bytes = len(line)
while True:
line = await reader.readline()
header_bytes += len(line)
if header_bytes > 16384:
raise HTTPError(400, "bad_request", "Headers exceed 16384 bytes")
if line in (b"\r\n", b"\n"):
break
if not line or b":" not in line:
raise HTTPError(400, "bad_request", "Invalid header framing")
key, value = line.decode("latin-1").split(":", 1)
key, value = key.strip().lower(), value.strip()
if key in headers and key in ("content-length", "transfer-encoding"):
raise HTTPError(400, "bad_request", "Duplicate framing header")
headers[key] = value
if "transfer-encoding" in headers:
raise HTTPError(400, "bad_request", "Transfer-Encoding is unsupported; use Content-Length")
length = headers.get("content-length", "0")
if not length.isascii() or not length.isdecimal():
raise HTTPError(400, "bad_request", "Invalid Content-Length")
if len(length) > 10 or int(length) > self.max_body_bytes:
raise HTTPError(413, "payload_too_large", "Request body exceeds configured limit")
return method.upper(), path, headers, await reader.readexactly(int(length))
line_str = request_line.decode("utf-8", errors="replace").strip()
parts = line_str.split(" ")
if len(parts) < 2:
await self._send_response(
writer, 400, "application/json", b'{"error": "bad_request"}'
)
return
method = parts[0].upper()
path = parts[1]
# Read headers
headers = {}
while True:
header_line = await reader.readline()
if not header_line or header_line == b"\r\n" or header_line == b"\n":
break
header_str = header_line.decode("utf-8", errors="replace").strip()
if ":" in header_str:
key, val = header_str.split(":", 1)
headers[key.strip().lower()] = val.strip()
# Read body based on Content-Length
content_length = int(headers.get("content-length", 0))
body = b""
if content_length > 0:
body = await reader.readexactly(content_length)
# Route dispatch
await self._dispatch_route(writer, method, path, headers, body)
except asyncio.IncompleteReadError:
async def _handle_client(self, reader, writer):
task = asyncio.current_task()
self._clients.add(task)
try:
async with asyncio.timeout(self.read_timeout):
request = await self._read_request(reader)
if request:
await self._dispatch_route(writer, *request)
except TimeoutError:
await self._error(writer, 408, "request_timeout", "Request read deadline exceeded")
except HTTPError as exc:
await self._error(writer, exc.status, exc.reason, str(exc))
except (asyncio.IncompleteReadError, ValueError) as exc:
await self._error(writer, 400, "bad_request", str(exc))
except (ConnectionError, BrokenPipeError):
pass
except Exception as e:
logger.error(
f"Error handling HTTP request: {e}",
extra={"event": "request_handler_error"},
)
try:
err_body = json.dumps(
{"error": "internal_error", "detail": str(e)}
).encode("utf-8")
await self._send_response(writer, 500, "application/json", err_body)
except Exception:
pass
except Exception:
logger.exception("HTTP handler failure", extra={"event": "request_handler_error"})
await self._error(writer, 500, "internal_error", "Request failed")
finally:
self._clients.discard(task)
writer.close()
try:
writer.close()
await writer.wait_closed()
except Exception:
except ConnectionError:
pass
async def _dispatch_route(
self,
writer: asyncio.StreamWriter,
method: str,
path: str,
headers: dict,
body: bytes,
) -> None:
# 1. Health check
async def _dispatch_route(self, writer, method, path, headers, body):
if method == "GET" and path in ("/health", "/healthz", "/live"):
res = json.dumps(
{"status": "ok", "queue_size": self.queue.qsize()}
).encode("utf-8")
await self._send_response(writer, 200, "application/json", res)
return
# 2. Prometheus metrics
if method == "GET" and path == "/metrics":
res = api_metrics.get_prometheus_metrics().encode("utf-8")
await self._send_response(
writer, 200, "text/plain; version=0.0.4; charset=utf-8", res
)
return
# 3. Public Stats summary
if method == "GET" and path == "/stats":
stats = await get_aggregated_pipeline_stats(self.worker_stats_url)
res = json.dumps(stats, indent=2).encode("utf-8")
await self._send_response(writer, 200, "application/json", res)
return
# 4. Raw CDR Ingestion endpoint
if method == "POST" and path == "/api/v1/cdr/raw":
await self._json(writer, 200, {"status": "ok", "in_flight": len(self._pending)})
elif method == "GET" and path == "/metrics":
await self._send_response(writer, 200, "text/plain; version=0.0.4; charset=utf-8", api_metrics.get_prometheus_metrics().encode())
elif method == "GET" and path == "/stats":
await self._json(writer, 200, await get_aggregated_pipeline_stats(self.worker_stats_url))
elif method == "POST" and path == "/api/v1/cdr/raw":
await self._handle_raw_cdr_ingestion(writer, body)
return
else:
await self._error(writer, 404, "not_found", f"Cannot {method} {path}")
# 5. Not found
err = json.dumps(
{"error": "not_found", "detail": f"Cannot {method} {path}"}
).encode("utf-8")
await self._send_response(writer, 404, "application/json", err)
async def _handle_raw_cdr_ingestion(
self, writer: asyncio.StreamWriter, body: bytes
) -> None:
# Check broker reachability
if not self.producer.is_healthy():
err = json.dumps(
{
"error": "broker_unavailable",
"detail": "Kafka broker is unreachable",
}
).encode("utf-8")
await self._send_response(writer, 503, "application/json", err)
return
# Check queue capacity (Backpressure)
if self.queue.full():
err = json.dumps(
{
"error": "queue_full",
"detail": "Internal async buffer is saturated (backpressure)",
}
).encode("utf-8")
await self._send_response(writer, 429, "application/json", err)
return
# Validate JSON format
async def _handle_raw_cdr_ingestion(self, writer, body):
try:
body_text = body.decode("utf-8")
payload = json.loads(body_text)
except json.JSONDecodeError as e:
err = json.dumps(
{
"error": "invalid_json",
"detail": f"Unexpected token at position {e.pos}: {e.msg}",
}
).encode("utf-8")
await self._send_response(writer, 400, "application/json", err)
payload = parse_payload(body)
validate_structure(payload)
except PayloadError as exc:
await self._error(writer, 400, exc.reason, str(exc))
return
if not isinstance(payload, dict):
err = json.dumps(
{"error": "invalid_payload", "detail": "Expected a JSON object"}
).encode("utf-8")
await self._send_response(writer, 400, "application/json", err)
if self._stopping:
await self._error(writer, 503, "shutting_down", "API is draining")
return
if len(self._pending) >= self.queue_max_size:
await self._error(writer, 429, "queue_full", "Outstanding publication capacity exhausted")
return
raw_id = payload.get("raw_id")
# Put in bounded queue without blocking
try:
self.queue.put_nowait((raw_id, body))
except asyncio.QueueFull:
err = json.dumps(
{
"error": "queue_full",
"detail": "Internal async buffer is saturated (backpressure)",
}
).encode("utf-8")
await self._send_response(writer, 429, "application/json", err)
delivery = asyncio.wrap_future(self.producer.produce(config.kafka.raw_topic, payload["raw_id"], body))
self._pending.add(delivery)
def completed(future):
self._pending.discard(future)
if not future.cancelled():
future.exception() # Consume failures after an HTTP timeout/disconnect.
delivery.add_done_callback(completed)
await asyncio.wait_for(asyncio.shield(delivery), self.delivery_timeout)
except Exception as exc:
logger.warning(str(exc), extra={"raw_id": payload["raw_id"], "event": "kafka_delivery_failed"})
await self._error(writer, 503, "broker_unavailable", "Delivery unconfirmed; retry with the same raw_id and payload")
return
# Increment metric
api_metrics.increment_received(1)
await self._json(writer, 202, {"status": "accepted", "raw_id": payload["raw_id"], "queued": True})
# 202 Accepted
res = json.dumps(
{"status": "accepted", "raw_id": raw_id, "queued": True}
).encode("utf-8")
await self._send_response(writer, 202, "application/json", res)
async def _error(self, writer, status, error, detail):
await self._json(writer, status, {"error": error, "detail": detail})
async def _send_response(
self,
writer: asyncio.StreamWriter,
status_code: int,
content_type: str,
body: bytes,
) -> None:
status_reasons = {
200: "OK",
202: "Accepted",
400: "Bad Request",
404: "Not Found",
429: "Too Many Requests",
500: "Internal Server Error",
503: "Service Unavailable",
}
reason = status_reasons.get(status_code, "Unknown")
headers = (
f"HTTP/1.1 {status_code} {reason}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n"
f"\r\n"
)
writer.write(headers.encode("utf-8") + body)
async def _json(self, writer, status, payload):
await self._send_response(writer, status, "application/json", json.dumps(payload).encode())
async def _send_response(self, writer, status_code, content_type, body):
from http import HTTPStatus
headers = (f"HTTP/1.1 {status_code} {HTTPStatus(status_code).phrase}\r\n"
f"Content-Type: {content_type}\r\nContent-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n")
writer.write(headers.encode() + body)
await writer.drain()
async def main():
server = IngestionAPIServer()
stopping = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, stopping.set)
except NotImplementedError:
signal.signal(sig, lambda *_: loop.call_soon_threadsafe(stopping.set))
await server.start()
try:
# Keep running until cancelled
while True:
await asyncio.sleep(3600)
except (asyncio.CancelledError, KeyboardInterrupt):
await stopping.wait()
finally:
await server.stop()