Implement P05 checked services and safe selected delivery recovery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
99618488e0
commit
ac0eb14b75
15 changed files with 735 additions and 88 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue