"""Asynchronous delivery of the transactional outbox to audit-core.""" from __future__ import annotations import json import threading from pathlib import Path from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from .store import Engine class AuditDeliveryError(RuntimeError): """Bounded delivery failure; details deliberately exclude response bodies.""" def audit_envelope(payload: dict[str, Any]) -> dict[str, Any]: details = dict(payload.get("details") or {}) return { "id": payload["event_id"], "type": payload["action"], "source": payload["source"], "subject": payload["resource"], "tenant": payload["tenant"], "correlation_id": details.get("approval_id") or payload["event_id"], "occurred_at": payload["observed_at"], "data": { "schema_version": payload["schema_version"], "scope": payload["scope"], "actor": payload.get("actor"), "resource": payload["resource"], "outcome": payload["outcome"], "reason": payload.get("reason"), "details": details, }, } class AuditCoreSink: def __init__( self, base_url: str, token_file: str | Path, *, timeout_seconds: float = 5, opener: Callable[..., Any] = urlopen, ) -> None: self.url = base_url.rstrip("/") + "/v1/events" self.token_file = Path(token_file) self.timeout_seconds = timeout_seconds self.opener = opener def __call__(self, payload: dict[str, Any]) -> None: token = self.token_file.read_text(encoding="utf-8").strip() if not token or any(ch.isspace() for ch in token): raise AuditDeliveryError("audit credential is unavailable") body = json.dumps(audit_envelope(payload), sort_keys=True).encode("utf-8") request = Request( self.url, data=body, method="POST", headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "Idempotency-Key": payload["event_id"], }, ) try: response = self.opener(request, timeout=self.timeout_seconds) status = int(response.getcode()) response.close() except (HTTPError, URLError, OSError) as exc: raise AuditDeliveryError(type(exc).__name__) from exc if status not in {200, 202}: raise AuditDeliveryError(f"audit ingest returned status {status}") class OutboxWorker: def __init__( self, engine: Engine, sink: Callable[[dict[str, Any]], None], *, heartbeat_interval_seconds: int = 300, ) -> None: self.engine = engine self.sink = sink self.heartbeat_interval_seconds = heartbeat_interval_seconds def run_once(self) -> dict[str, int]: if self.engine.heartbeat_due(self.heartbeat_interval_seconds): self.engine.emit_heartbeat() return self.engine.drain(self.sink) def run_forever(self, stop: threading.Event, poll_seconds: float = 5) -> None: while not stop.is_set(): self.run_once() stop.wait(poll_seconds)