Implement P05 checked services and safe selected delivery recovery
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 19s
Account journey acceptance / journeys (push) Successful in 5s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 22:11:49 +02:00
parent 99618488e0
commit ac0eb14b75
15 changed files with 735 additions and 88 deletions

View file

@ -3,7 +3,20 @@
from __future__ import annotations
import json
from urllib.request import Request, urlopen
from urllib.request import Request, urlopen, build_opener, HTTPRedirectHandler
from urllib.error import HTTPError
class DeliveryError(RuntimeError):
def __init__(self, code):
self.code = code
super().__init__(code)
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
from user_engine.domain import OutboxEvent
@ -39,6 +52,8 @@ class HTTPOutboxDeliveryAdapter:
"occurred_at": event.occurred_at.isoformat(),
"data": dict(event.payload),
}
if event.event_type in _MAIL_EVENTS and not self.mail_url:
raise DeliveryError("mail_unconfigured")
if self.mail_url and event.event_type in _MAIL_EVENTS:
if not self.mail_bearer_token:
raise RuntimeError("mail delivery token is required")
@ -51,21 +66,78 @@ class HTTPOutboxDeliveryAdapter:
event_envelope["data"] = event_data
self._post(self.event_url, event_envelope, self.event_bearer_token)
def _post(
self, url: str, envelope: dict[str, object], bearer_token: str
) -> None:
with urlopen(
Request(
url,
data=json.dumps(envelope).encode(),
headers={
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",
"Idempotency-Key": str(envelope["id"]),
},
method="POST",
),
timeout=self.timeout_seconds,
def _post(self, url: str, envelope: dict[str, object], bearer_token: str) -> None:
request = Request(
url,
data=json.dumps(envelope).encode(),
headers={
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",
"Idempotency-Key": str(envelope["id"]),
},
method="POST",
)
try:
response = build_opener(NoRedirect()).open(
request, timeout=self.timeout_seconds
)
except HTTPError as exc:
code = "provider_unavailable"
try:
body = json.loads(exc.read(8192))
candidate = body.get("error")
if candidate in {
"delivery_outcome_unknown",
"temporary_deferral",
"permanent_rejection",
"recipient_suppressed",
}:
code = candidate
except Exception:
pass
raise DeliveryError(code) from None
except Exception:
raise DeliveryError("provider_unavailable") from None
with response:
if not 200 <= response.status < 300:
raise DeliveryError("provider_unavailable")
def mail_status(self):
if not self.mail_url or not self.mail_bearer_token:
return {"checks": {"smtp_authentication": "unconfigured"}}
from urllib.parse import urlsplit, urlunsplit
parsed = urlsplit(self.mail_url)
url = urlunsplit((parsed.scheme, parsed.netloc, "/v1/status", "", ""))
req = Request(
url,
data=b"{}",
headers={
"Authorization": "Bearer " + self.mail_bearer_token,
"Content-Type": "application/json",
},
method="POST",
)
with build_opener(NoRedirect()).open(req, timeout=7) as response:
return json.loads(response.read(8192))
def mail_delivery_status(self, event_id):
if not self.mail_url or not self.mail_bearer_token:
return {"state": "unconfigured"}
from urllib.parse import urlsplit, urlunsplit
parsed = urlsplit(self.mail_url)
url = urlunsplit((parsed.scheme, parsed.netloc, "/v1/delivery-status", "", ""))
request = Request(
url,
data=json.dumps({"event_id": event_id}).encode(),
headers={
"Authorization": "Bearer " + self.mail_bearer_token,
"Content-Type": "application/json",
},
method="POST",
)
with build_opener(NoRedirect()).open(
request, timeout=self.timeout_seconds
) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"delivery rejected with HTTP {response.status}")
return json.loads(response.read(8192))

View file

@ -87,6 +87,12 @@ class InMemoryUserEngineStore:
with self._lifecycle_lock:
yield
@contextmanager
def outbox_delivery_guard(self, event_id: str):
# Separate namespace; reuse the existing cross-connection lock primitive.
with self.tenant_lifecycle_guard("outbox-event:" + event_id):
yield
def outbox_history(self) -> tuple[OutboxEvent, ...]:
return tuple(self.outbox_events)

View file

@ -103,6 +103,12 @@ class PostgresUserEngineStore:
with self._cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(hashtextextended(%s, 0))", (key,))
@contextmanager
def outbox_delivery_guard(self, event_id: str):
# Separate namespace; reuse the existing cross-connection lock primitive.
with self.tenant_lifecycle_guard("outbox-event:" + event_id):
yield
def outbox_history(self) -> tuple[OutboxEvent, ...]:
with self._cursor() as cursor:
cursor.execute("SELECT payload FROM user_engine_outbox_events ORDER BY occurred_at, event_id")