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,185 +1,91 @@
import asyncio """Kafka publications complete only on broker delivery callbacks."""
import logging import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, Optional from concurrent.futures import Future
from app.common.config import config from app.common.config import config
from app.common.logging import setup_logger
logger = setup_logger("ingestion-api")
class MessageProducer(ABC): class MessageProducer(ABC):
@abstractmethod @abstractmethod
def produce(self, topic: str, key: Optional[str], value: bytes) -> bool: def produce(self, topic: str, key: str | None, value: bytes) -> Future:
"""Produce a message synchronously or enqueue in broker buffer.""" """Return a future resolved by the destination acknowledgement."""
pass
@abstractmethod @abstractmethod
def is_healthy(self) -> bool: def close(self, timeout: float = 5.0) -> None:
"""Check if producer is healthy and connected to broker."""
pass
@abstractmethod
def flush(self, timeout: float = 5.0) -> int:
"""Flush any pending messages."""
pass
@abstractmethod
def close(self) -> None:
"""Close producer connection."""
pass pass
class ConfluentKafkaProducer(MessageProducer): class ConfluentKafkaProducer(MessageProducer):
""" def __init__(self, bootstrap_servers=None):
Production Kafka producer implementation wrapping confluent-kafka. from confluent_kafka import Producer
Uses 'raw_id' as partition key and LZ4 compression. self._producer = Producer({
""" "bootstrap.servers": bootstrap_servers or config.kafka.bootstrap_servers,
def __init__(self, bootstrap_servers: Optional[str] = None):
try:
import confluent_kafka
self._ck = confluent_kafka
except ImportError:
raise ImportError(
"confluent-kafka library is not installed. Run 'pip install confluent-kafka'."
)
self.bootstrap_servers = bootstrap_servers or config.kafka.bootstrap_servers
self._producer_config = {
"bootstrap.servers": self.bootstrap_servers,
"compression.type": "lz4", "compression.type": "lz4",
"queue.buffering.max.messages": 100000, "queue.buffering.max.messages": config.api.queue_max_size,
"queue.buffering.max.ms": 5, "linger.ms": 5,
"acks": "all", "acks": "all",
"retries": 3, "enable.idempotence": True,
} "delivery.timeout.ms": int(config.kafka.delivery_timeout_seconds * 1000),
self._producer = self._ck.Producer(self._producer_config) })
self._healthy = True self._stop = threading.Event()
self._closed = False
self._thread = threading.Thread(target=self._poll, name="KafkaDelivery", daemon=True)
self._thread.start()
def _delivery_report(self, err: Any, msg: Any) -> None: def _poll(self):
if err is not None: while not self._stop.is_set():
self._healthy = False self._producer.poll(0.01)
raw_id = msg.key().decode("utf-8") if msg and msg.key() else None
logger.error( def produce(self, topic, key, value):
f"Kafka delivery failed: {err}", future = Future()
extra={"raw_id": raw_id, "event": "kafka_produce_failed"}, def delivered(error, message):
) if error is not None:
future.set_exception(RuntimeError(str(error)))
else: else:
self._healthy = True future.set_result(message)
def produce(self, topic: str, key: Optional[str], value: bytes) -> bool:
try: try:
key_bytes = key.encode("utf-8") if key else None if self._closed:
self._producer.produce( raise RuntimeError("Producer is closed")
topic=topic, self._producer.produce(topic, key=key, value=value, on_delivery=delivered)
key=key_bytes, except Exception as exc:
value=value, future.set_exception(exc)
on_delivery=self._delivery_report, return future
)
# Serve delivery callback queue non-blockingly
self._producer.poll(0)
return True
except BufferError:
logger.warning(
"Kafka local producer buffer full",
extra={"raw_id": key, "event": "kafka_buffer_full"},
)
self._producer.poll(0.1)
return False
except Exception as e:
self._healthy = False
logger.error(
f"Kafka produce error: {e}",
extra={"raw_id": key, "event": "kafka_error"},
)
return False
def is_healthy(self) -> bool: def flush(self, timeout=5.0):
return self._healthy
def flush(self, timeout: float = 5.0) -> int:
return self._producer.flush(timeout) return self._producer.flush(timeout)
def close(self) -> None: def close(self, timeout=5.0):
self.flush() self._closed = True
remaining = self.flush(timeout)
self._stop.set()
self._thread.join(timeout=1)
if remaining:
raise TimeoutError(f"{remaining} Kafka deliveries remain unconfirmed")
class MockMessageProducer(MessageProducer): class MockMessageProducer(MessageProducer):
"""
In-memory mock producer for offline testing and verification.
"""
def __init__(self): def __init__(self):
self.messages = [] self.messages = []
self._healthy = True self._healthy = True
def produce(self, topic: str, key: Optional[str], value: bytes) -> bool: def produce(self, topic, key, value):
if not self._healthy: future = Future()
return False if self._healthy:
self.messages.append({"topic": topic, "key": key, "value": value}) self.messages.append({"topic": topic, "key": key, "value": value})
return True future.set_result(None)
else:
future.set_exception(ConnectionError("Broker unavailable"))
return future
def is_healthy(self) -> bool: def is_healthy(self):
return self._healthy return self._healthy
def set_healthy(self, healthy: bool) -> None: def set_healthy(self, healthy):
self._healthy = healthy self._healthy = healthy
def flush(self, timeout: float = 5.0) -> int: def flush(self, timeout=5.0):
return 0 return 0
def close(self) -> None: def close(self, timeout=5.0):
pass pass
class AsyncProducerWorker:
"""
Background worker consuming from the bounded async queue and writing to the Kafka producer.
"""
def __init__(self, queue: asyncio.Queue, producer: MessageProducer, topic: str):
self.queue = queue
self.producer = producer
self.topic = topic
self._running = False
self._task: Optional[asyncio.Task] = None
async def start(self) -> None:
self._running = True
self._task = asyncio.create_task(self._run_loop())
async def _run_loop(self) -> None:
while self._running:
try:
item = await self.queue.get()
if item is None: # Sentinel to stop
self.queue.task_done()
break
key, value = item
success = self.producer.produce(self.topic, key, value)
if not success:
logger.error(
"Failed to publish message from async queue to Kafka",
extra={"raw_id": key, "event": "queue_dispatch_failed"},
)
self.queue.task_done()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(
f"Unexpected error in async producer worker: {e}",
extra={"event": "worker_unexpected_error"},
)
async def stop(self) -> None:
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self.producer.flush()

View File

@@ -1,285 +1,194 @@
import asyncio import asyncio
import json import json
import logging import signal
from typing import Optional, Tuple
from app.api.metrics import api_metrics from app.api.metrics import api_metrics
from app.api.producer import ( from app.api.producer import ConfluentKafkaProducer
AsyncProducerWorker,
ConfluentKafkaProducer,
MessageProducer,
MockMessageProducer,
)
from app.api.stats import get_aggregated_pipeline_stats from app.api.stats import get_aggregated_pipeline_stats
from app.common.config import config from app.common.config import config
from app.common.logging import setup_logger from app.common.logging import setup_logger
from app.common.validation import PayloadError, parse_payload, validate_structure
logger = setup_logger("ingestion-api") 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: class IngestionAPIServer:
""" def __init__(self, host=None, port=None, queue_max_size=None, producer=None,
Framework-less HTTP/1.1 Ingestion API server built on standard library asyncio. worker_stats_url=None, max_body_bytes=None, read_timeout=None,
Enforces bounded async queue backpressure and dispatches to Kafka. 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
def __init__( self.queue_max_size = queue_max_size if queue_max_size is not None else config.api.queue_max_size
self, self.max_body_bytes = max_body_bytes if max_body_bytes is not None else config.api.max_body_bytes
host: Optional[str] = None, self.read_timeout = read_timeout if read_timeout is not None else config.api.read_timeout_seconds
port: Optional[int] = None, self.delivery_timeout = delivery_timeout if delivery_timeout is not None else config.kafka.delivery_timeout_seconds
queue_max_size: Optional[int] = None, if min(self.queue_max_size, self.max_body_bytes, self.read_timeout, self.delivery_timeout) <= 0:
producer: Optional[MessageProducer] = None, raise ValueError("API capacities and timeouts must be positive")
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
self.worker_stats_url = worker_stats_url or config.api.worker_stats_url 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) async def start(self):
self.producer = producer or ConfluentKafkaProducer() self._server = await asyncio.start_server(self._handle_client, self.host, self.port)
self.dispatcher = AsyncProducerWorker( self.port = self._server.sockets[0].getsockname()[1]
queue=self.queue, logger.info(f"Listening on {self.host}:{self.port}", extra={"event": "server_started"})
producer=self.producer,
topic=config.kafka.raw_topic,
)
self._server: Optional[asyncio.Server] = None async def stop(self):
self._running = False self._stopping = True
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
if self._server: if self._server:
self._server.close() self._server.close()
await self._server.wait_closed() await self._server.wait_closed()
await self.dispatcher.stop() deadline = asyncio.get_running_loop().time() + config.api.shutdown_timeout_seconds
self.producer.close() outstanding = self._pending | self._clients
logger.info("Ingestion API stopped cleanly", extra={"event": "server_stopped"}) 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( async def _read_request(self, reader):
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter line = await reader.readline()
) -> None: if not line:
return None
try: try:
# Read request line method, path, version = line.decode("ascii").strip().split()
request_line = await reader.readline() except (ValueError, UnicodeError) as exc:
if not request_line: raise HTTPError(400, "bad_request", "Invalid request line") from exc
writer.close() if version not in ("HTTP/1.0", "HTTP/1.1"):
await writer.wait_closed() raise HTTPError(400, "bad_request", "Unsupported HTTP version")
return
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 = {} headers = {}
header_bytes = len(line)
while True: while True:
header_line = await reader.readline() line = await reader.readline()
if not header_line or header_line == b"\r\n" or header_line == b"\n": 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 break
header_str = header_line.decode("utf-8", errors="replace").strip() if not line or b":" not in line:
if ":" in header_str: raise HTTPError(400, "bad_request", "Invalid header framing")
key, val = header_str.split(":", 1) key, value = line.decode("latin-1").split(":", 1)
headers[key.strip().lower()] = val.strip() 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))
# Read body based on Content-Length async def _handle_client(self, reader, writer):
content_length = int(headers.get("content-length", 0)) task = asyncio.current_task()
body = b"" self._clients.add(task)
if content_length > 0:
body = await reader.readexactly(content_length)
# Route dispatch
await self._dispatch_route(writer, method, path, headers, body)
except asyncio.IncompleteReadError:
pass
except Exception as e:
logger.error(
f"Error handling HTTP request: {e}",
extra={"event": "request_handler_error"},
)
try: try:
err_body = json.dumps( async with asyncio.timeout(self.read_timeout):
{"error": "internal_error", "detail": str(e)} request = await self._read_request(reader)
).encode("utf-8") if request:
await self._send_response(writer, 500, "application/json", err_body) await self._dispatch_route(writer, *request)
except Exception: 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 pass
except Exception:
logger.exception("HTTP handler failure", extra={"event": "request_handler_error"})
await self._error(writer, 500, "internal_error", "Request failed")
finally: finally:
try: self._clients.discard(task)
writer.close() writer.close()
try:
await writer.wait_closed() await writer.wait_closed()
except Exception: except ConnectionError:
pass pass
async def _dispatch_route( async def _dispatch_route(self, writer, method, path, headers, body):
self,
writer: asyncio.StreamWriter,
method: str,
path: str,
headers: dict,
body: bytes,
) -> None:
# 1. Health check
if method == "GET" and path in ("/health", "/healthz", "/live"): if method == "GET" and path in ("/health", "/healthz", "/live"):
res = json.dumps( await self._json(writer, 200, {"status": "ok", "in_flight": len(self._pending)})
{"status": "ok", "queue_size": self.queue.qsize()} elif method == "GET" and path == "/metrics":
).encode("utf-8") await self._send_response(writer, 200, "text/plain; version=0.0.4; charset=utf-8", api_metrics.get_prometheus_metrics().encode())
await self._send_response(writer, 200, "application/json", res) elif method == "GET" and path == "/stats":
return await self._json(writer, 200, await get_aggregated_pipeline_stats(self.worker_stats_url))
elif method == "POST" and path == "/api/v1/cdr/raw":
# 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._handle_raw_cdr_ingestion(writer, body) await self._handle_raw_cdr_ingestion(writer, body)
return else:
await self._error(writer, 404, "not_found", f"Cannot {method} {path}")
# 5. Not found async def _handle_raw_cdr_ingestion(self, writer, body):
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
try: try:
body_text = body.decode("utf-8") payload = parse_payload(body)
payload = json.loads(body_text) validate_structure(payload)
except json.JSONDecodeError as e: except PayloadError as exc:
err = json.dumps( await self._error(writer, 400, exc.reason, str(exc))
{
"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)
return return
if self._stopping:
if not isinstance(payload, dict): await self._error(writer, 503, "shutting_down", "API is draining")
err = json.dumps( return
{"error": "invalid_payload", "detail": "Expected a JSON object"} if len(self._pending) >= self.queue_max_size:
).encode("utf-8") await self._error(writer, 429, "queue_full", "Outstanding publication capacity exhausted")
await self._send_response(writer, 400, "application/json", err)
return return
raw_id = payload.get("raw_id")
# Put in bounded queue without blocking
try: try:
self.queue.put_nowait((raw_id, body)) delivery = asyncio.wrap_future(self.producer.produce(config.kafka.raw_topic, payload["raw_id"], body))
except asyncio.QueueFull: self._pending.add(delivery)
err = json.dumps( def completed(future):
{ self._pending.discard(future)
"error": "queue_full", if not future.cancelled():
"detail": "Internal async buffer is saturated (backpressure)", future.exception() # Consume failures after an HTTP timeout/disconnect.
} delivery.add_done_callback(completed)
).encode("utf-8") await asyncio.wait_for(asyncio.shield(delivery), self.delivery_timeout)
await self._send_response(writer, 429, "application/json", err) 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 return
# Increment metric
api_metrics.increment_received(1) api_metrics.increment_received(1)
await self._json(writer, 202, {"status": "accepted", "raw_id": payload["raw_id"], "queued": True})
# 202 Accepted async def _error(self, writer, status, error, detail):
res = json.dumps( await self._json(writer, status, {"error": error, "detail": detail})
{"status": "accepted", "raw_id": raw_id, "queued": True}
).encode("utf-8")
await self._send_response(writer, 202, "application/json", res)
async def _send_response( async def _json(self, writer, status, payload):
self, await self._send_response(writer, status, "application/json", json.dumps(payload).encode())
writer: asyncio.StreamWriter,
status_code: int, async def _send_response(self, writer, status_code, content_type, body):
content_type: str, from http import HTTPStatus
body: bytes, headers = (f"HTTP/1.1 {status_code} {HTTPStatus(status_code).phrase}\r\n"
) -> None: f"Content-Type: {content_type}\r\nContent-Length: {len(body)}\r\n"
status_reasons = { "Connection: close\r\n\r\n")
200: "OK", writer.write(headers.encode() + body)
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)
await writer.drain() await writer.drain()
async def main(): async def main():
server = IngestionAPIServer() 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() await server.start()
try: try:
# Keep running until cancelled await stopping.wait()
while True: finally:
await asyncio.sleep(3600)
except (asyncio.CancelledError, KeyboardInterrupt):
await server.stop() await server.stop()

View File

@@ -25,6 +25,9 @@ class APIConfig:
host: str = get_env_str("API_HOST", "0.0.0.0") host: str = get_env_str("API_HOST", "0.0.0.0")
port: int = get_env_int("API_PORT", 8000) port: int = get_env_int("API_PORT", 8000)
queue_max_size: int = get_env_int("API_QUEUE_MAX_SIZE", 10000) queue_max_size: int = get_env_int("API_QUEUE_MAX_SIZE", 10000)
max_body_bytes: int = get_env_int("API_MAX_BODY_BYTES", 65536)
read_timeout_seconds: float = get_env_float("API_READ_TIMEOUT_SECONDS", 10)
shutdown_timeout_seconds: float = get_env_float("SHUTDOWN_TIMEOUT_SECONDS", 45)
worker_stats_url: str = get_env_str( worker_stats_url: str = get_env_str(
"WORKER_STATS_URL", "http://worker:8001/internal/stats" "WORKER_STATS_URL", "http://worker:8001/internal/stats"
) )
@@ -44,6 +47,7 @@ class KafkaConfig:
dlq_topic: str = get_env_str("KAFKA_DLQ_TOPIC", "cdr.raw.dlq") dlq_topic: str = get_env_str("KAFKA_DLQ_TOPIC", "cdr.raw.dlq")
consumer_group: str = get_env_str("KAFKA_CONSUMER_GROUP", "cdr-processing-group") consumer_group: str = get_env_str("KAFKA_CONSUMER_GROUP", "cdr-processing-group")
auto_offset_reset: str = get_env_str("KAFKA_AUTO_OFFSET_RESET", "earliest") auto_offset_reset: str = get_env_str("KAFKA_AUTO_OFFSET_RESET", "earliest")
delivery_timeout_seconds: float = get_env_float("KAFKA_DELIVERY_TIMEOUT_SECONDS", 10)
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -28,6 +28,8 @@ def build_dlq_envelope(
""" """
Constructs a standardized DLQ envelope according to specification. Constructs a standardized DLQ envelope according to specification.
""" """
if failure_reason not in ALLOWED_FAILURE_REASONS:
raise ValueError(f"Unsupported DLQ reason: {failure_reason}")
return { return {
"original_payload": original_payload, "original_payload": original_payload,
"failure_reason": failure_reason, "failure_reason": failure_reason,
@@ -56,11 +58,13 @@ def route_to_dlq(
) )
envelope_bytes = json.dumps(envelope, default=str).encode("utf-8") envelope_bytes = json.dumps(envelope, default=str).encode("utf-8")
success = producer.produce(topic=topic, key=raw_id, value=envelope_bytes) producer.produce(topic=topic, key=raw_id, value=envelope_bytes).result(
timeout=config.kafka.delivery_timeout_seconds + 1
)
worker_metrics.record_dlq(failure_reason, 1) worker_metrics.record_dlq(failure_reason, 1)
logger.warning( logger.warning(
f"Record routed to DLQ: reason={failure_reason}, field={failed_field}", f"Record routed to DLQ: reason={failure_reason}, field={failed_field}",
extra={"raw_id": raw_id, "event": "routed_to_dlq"}, extra={"raw_id": raw_id, "event": "routed_to_dlq"},
) )
return success return True

View File

@@ -0,0 +1,72 @@
import asyncio
import json
import unittest
from concurrent.futures import Future
from app.api.producer import MockMessageProducer
from app.api.server import IngestionAPIServer
def payload():
return dict(raw_id="delivery-test", device_id="123", caller_num="+19875551234",
svc="4G_DATA", rat=6, lac="1", ci="2", up_bytes=10,
down_bytes=20, epoch_start=1726070000, epoch_end=1726070300)
class PendingProducer(MockMessageProducer):
def __init__(self):
super().__init__()
self.pending = []
def produce(self, topic, key, value):
future = Future()
self.pending.append(future)
return future
class Writer:
def __init__(self):
self.data = b""
def write(self, value):
self.data += value
async def drain(self):
pass
class TestAcknowledgedAPI(unittest.IsolatedAsyncioTestCase):
async def test_response_waits_for_delivery_and_bounds_outstanding(self):
producer = PendingProducer()
server = IngestionAPIServer(producer=producer, queue_max_size=1)
writer = Writer()
task = asyncio.create_task(server._handle_raw_cdr_ingestion(writer, json.dumps(payload()).encode()))
await asyncio.sleep(0.02)
self.assertEqual(writer.data, b"", "202 must await the broker")
other = Writer()
await server._handle_raw_cdr_ingestion(other, json.dumps(payload()).encode())
self.assertIn(b"429", other.data)
producer.pending[0].set_result(None)
await task
self.assertIn(b"202", writer.data)
async def test_missing_fields_wrong_types_and_utf8_return_400(self):
server = IngestionAPIServer(producer=MockMessageProducer())
for body in (b"{}", b"\xff", json.dumps(payload() | {"raw_id": 42}).encode(),
json.dumps(payload() | {"rat": True}).encode()):
with self.subTest(body=body):
writer = Writer()
await server._handle_raw_cdr_ingestion(writer, body)
self.assertIn(b"400", writer.data)
async def test_failed_delivery_returns_503_and_next_request_recovers(self):
producer = MockMessageProducer()
server = IngestionAPIServer(producer=producer)
producer.set_healthy(False)
writer = Writer()
await server._handle_raw_cdr_ingestion(writer, json.dumps(payload()).encode())
self.assertIn(b"503", writer.data)
producer.set_healthy(True)
writer = Writer()
await server._handle_raw_cdr_ingestion(writer, json.dumps(payload()).encode())
self.assertIn(b"202", writer.data)

View File

@@ -129,28 +129,22 @@ class TestIngestionAPIServer(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data["error"], "invalid_payload") self.assertEqual(data["error"], "invalid_payload")
async def test_backpressure_returns_429_when_queue_full(self): async def test_backpressure_returns_429_when_queue_full(self):
# Cancel dispatcher task so it won't consume from queue from concurrent.futures import Future
self.server.dispatcher._task.cancel() from tests.test_api_delivery import payload
futures = [asyncio.get_running_loop().create_future() for _ in range(2)]
# Fill the queue (maxsize=2) self.server._pending.update(futures)
await self.server.queue.put(("k1", b"v1")) body = json.dumps(payload()).encode()
await self.server.queue.put(("k2", b"v2")) status, _, resp_body = await self._http_request("POST", "/api/v1/cdr/raw", body=body)
# Third request must be rejected with 429
body = json.dumps({"raw_id": "k3"}).encode("utf-8")
status, _, resp_body = await self._http_request(
"POST",
"/api/v1/cdr/raw",
body=body,
headers={"Content-Type": "application/json"},
)
self.assertEqual(status, 429) self.assertEqual(status, 429)
data = json.loads(resp_body.decode("utf-8")) self.assertEqual(json.loads(resp_body)["error"], "queue_full")
self.assertEqual(data["error"], "queue_full") for future in futures:
future.set_result(None)
self.server._pending.clear()
async def test_broker_unavailable_returns_503(self): async def test_broker_unavailable_returns_503(self):
self.mock_producer.set_healthy(False) self.mock_producer.set_healthy(False)
body = json.dumps({"raw_id": "test"}).encode("utf-8") from tests.test_api_delivery import payload
body = json.dumps(payload()).encode("utf-8")
status, _, resp_body = await self._http_request( status, _, resp_body = await self._http_request(
"POST", "POST",
"/api/v1/cdr/raw", "/api/v1/cdr/raw",

View File

@@ -0,0 +1,152 @@
"""Exercise HTTP framing and broker acknowledgement through real TCP sockets."""
import asyncio
import json
import unittest
from concurrent.futures import Future
from app.api.server import IngestionAPIServer
class PendingProducer:
def __init__(self):
self.deliveries = []
self.produced = asyncio.Event()
self.closed = False
def produce(self, topic, key, value):
future = Future()
self.deliveries.append(future)
self.produced.set()
return future
def is_healthy(self):
return True
def flush(self, timeout=5):
return sum(not future.done() for future in self.deliveries)
def close(self, timeout=5):
self.closed = True
class HTTPBoundaryTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.producer = PendingProducer()
self.server = None
self.writers = []
self.tasks = []
async def asyncTearDown(self):
for future in self.producer.deliveries:
if not future.done():
future.set_result(None)
for writer in self.writers:
writer.close()
await writer.wait_closed()
if self.server is not None:
await asyncio.wait_for(self.server.stop(), timeout=2)
for task in self.tasks:
if not task.done():
task.cancel()
await asyncio.gather(*self.tasks, return_exceptions=True)
async def start_server(self, **options):
self.server = IngestionAPIServer(host="127.0.0.1", port=0,
producer=self.producer, **options)
await self.server.start()
self.port = self.server._server.sockets[0].getsockname()[1]
async def send(self, request):
reader, writer = await asyncio.open_connection("127.0.0.1", self.port)
self.writers.append(writer)
writer.write(request)
await writer.drain()
task = asyncio.create_task(reader.read())
self.tasks.append(task)
return task
def valid_request(self):
body = json.dumps(dict(raw_id="http-boundary", device_id="123",
caller_num="0019875551234", svc="4G_DATA", rat=6,
up_bytes=100, down_bytes=200, lac="1", ci="2",
epoch_start=1726070000, epoch_end=1726070300)).encode()
return (b"POST /api/v1/cdr/raw HTTP/1.1\r\nHost: localhost\r\n"
+ f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
async def assert_response(self, task, status):
response = await asyncio.wait_for(asyncio.shield(task), timeout=2)
headers, body = response.split(b"\r\n\r\n", 1)
self.assertEqual(int(headers.split(b" ")[1]), status, response)
decoded = json.loads(body)
if status >= 400:
self.assertIn("error", decoded)
return decoded
async def test_malformed_framing_returns_structured_400(self):
await self.start_server()
headers = [b"Content-Length: invalid", b"Content-Length: -1",
b"Content-Length: 2\r\nContent-Length: 3",
b"Transfer-Encoding: chunked",
b"Transfer-Encoding: chunked\r\nContent-Length: 2"]
for header in headers:
with self.subTest(header=header):
task = await self.send(b"POST /api/v1/cdr/raw HTTP/1.1\r\n" + header + b"\r\n\r\n{}")
await self.assert_response(task, 400)
self.assertEqual(self.producer.deliveries, [])
async def test_oversized_declared_body_is_rejected_before_reading(self):
await self.start_server(max_body_bytes=32)
task = await self.send(b"POST /api/v1/cdr/raw HTTP/1.1\r\nContent-Length: 33\r\n\r\n")
await self.assert_response(task, 413)
self.assertEqual(self.producer.deliveries, [])
async def test_incomplete_body_times_out_with_structured_408(self):
await self.start_server(read_timeout=0.03)
task = await self.send(b"POST /api/v1/cdr/raw HTTP/1.1\r\nContent-Length: 20\r\n\r\n{")
await self.assert_response(task, 408)
self.assertEqual(self.producer.deliveries, [])
async def test_response_waits_for_broker_acknowledgement(self):
await self.start_server(delivery_timeout=1)
task = await self.send(self.valid_request())
await asyncio.wait_for(self.producer.produced.wait(), timeout=1)
await asyncio.sleep(0.03)
self.assertFalse(task.done(), "HTTP accepted before broker acknowledgement")
self.producer.deliveries[0].set_result(None)
await self.assert_response(task, 202)
async def test_timeout_holds_capacity_until_delivery_finishes(self):
await self.start_server(queue_max_size=1, delivery_timeout=0.03)
first = await self.send(self.valid_request())
await self.assert_response(first, 503)
self.assertEqual(len(self.producer.deliveries), 1)
self.assertFalse(self.producer.deliveries[0].cancelled())
await self.assert_response(await self.send(self.valid_request()), 429)
self.producer.deliveries[0].set_result(None)
await asyncio.sleep(0.03)
self.server.delivery_timeout = 1.0
self.producer.produced.clear()
third = await self.send(self.valid_request())
await asyncio.wait_for(self.producer.produced.wait(), timeout=1)
self.assertEqual(len(self.producer.deliveries), 2)
self.producer.deliveries[1].set_result(None)
await self.assert_response(third, 202)
async def test_shutdown_waits_for_outstanding_acknowledgement(self):
await self.start_server(delivery_timeout=1)
request = await self.send(self.valid_request())
await asyncio.wait_for(self.producer.produced.wait(), timeout=1)
stop = asyncio.create_task(self.server.stop())
self.tasks.append(stop)
await asyncio.sleep(0.03)
self.assertFalse(stop.done(), "Shutdown discarded an outstanding publication")
self.assertFalse(self.producer.closed)
self.producer.deliveries[0].set_result(None)
await asyncio.wait_for(stop, timeout=2)
await self.assert_response(request, 202)
self.assertTrue(self.producer.closed)
self.server = None
if __name__ == "__main__":
unittest.main()