1"""Durable at-most-once outbox with retry, backoff, and destination caps.
5- oc_connect.audit (AuditStore, content_hash, timestamp - audits every queue and delivery outcome)
6- oc_connect.workspace (comms_root - locates the outbox in the comms bus outbound lane)
7- oc_diagnostics (_dbg structured logger)
8- option_c_connector_contracts (DeliveryReceipt, LaneId, MessageDirection, RiskClass - receipts and lane fields)
9- pydantic (BaseModel, ConfigDict - the frozen strict OutboxItem model)
11from __future__
import annotations
15import secrets
as _secrets
17from collections.abc
import Callable
18from datetime
import datetime, timedelta, timezone
19from pathlib
import Path
20from typing
import Literal
22from oc_diagnostics
import _dbg
23from option_c_connector_contracts
import (
29from pydantic
import BaseModel, ConfigDict
31from .audit
import AuditRecord, AuditStore, content_hash, timestamp, utc_now
32from .workspace
import comms_root
34OutboxState = Literal[
"queued",
"sent",
"failed"]
38 """Return the durable outbox inside the comms bus outbound lane (§3)."""
39 return Path(comms_root(root)) /
"outbound" /
"outbox.json"
43 """One durable message; bodies live here, never in the audit file.
45 See :class:`~oc_connect.audit.AuditRecord` for why ``lane`` and
46 ``direction`` default to the single plan-219 lane while ``risk_class``
47 defaults to ``None``: for a migrated record the lane is a fact and the risk
48 class was never recorded.
50 ``transport_ref`` holds the connector's own message identifier for a
51 delivered item. It is what an inbound reply's ``reply_to`` metadata
52 resolves against, so correlation needs no second index file (§4).
55 model_config = ConfigDict(frozen=
True, extra=
"forbid", strict=
True)
65 dispatch_claimed: bool
68 next_attempt_at_utc: str |
None =
None
69 sent_at_utc: str |
None =
None
70 error_code: str |
None =
None
71 lane: LaneId =
"outbound-telegram"
72 direction: MessageDirection =
"outbound"
73 risk_class: RiskClass |
None =
None
74 transport_ref: str |
None =
None
78 """The outbox file is unreadable or malformed."""
82 """Persist messages and drive bounded delivery through an injected transport."""
86 path: str | Path |
None =
None,
88 audit: AuditStore |
None =
None,
89 now: Callable[[], datetime] = utc_now,
90 sleep: Callable[[float],
None] = time.sleep,
91 max_attempts: int = 3,
92 backoff_seconds: tuple[float, ...] = (0.25, 0.5),
93 rate_limit_count: int = 10,
94 rate_limit_window_seconds: float = 60.0,
95 root: str | Path |
None =
None,
98 raise ValueError(
"max_attempts must be at least one")
99 if len(backoff_seconds) < max_attempts - 1:
100 raise ValueError(
"backoff_seconds must cover every retry")
101 if rate_limit_count < 1
or rate_limit_window_seconds <= 0:
102 raise ValueError(
"rate limit values must be positive")
112 def _load(self) -> dict[str, OutboxItem]:
113 if not self.
path.is_file():
116 payload = json.loads(self.
path.read_text(encoding=
"utf-8"))
118 item.idempotency_key: item
120 OutboxItem.model_validate(raw)
121 for raw
in payload.get(
"items", [])
124 except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError)
as exc:
126 f
"could not parse connector outbox at {self.path} "
127 f
"({type(exc).__name__})"
131 def _write(self, items: dict[str, OutboxItem]) ->
None:
132 self.
path.parent.mkdir(parents=
True, exist_ok=
True)
133 temporary = self.
path.with_name(
134 f
".tmp-{self.path.name}-{_secrets.token_hex(8)}"
138 "items": [items[key].model_dump(mode=
"json")
for key
in sorted(items)],
141 with temporary.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
142 json.dump(payload, handle, indent=2, sort_keys=
True)
145 os.fsync(handle.fileno())
146 os.replace(temporary, self.
path)
148 if temporary.exists():
161 idempotency_key: str,
162 risk_class: RiskClass |
None =
None,
163 lane: LaneId =
"outbound-telegram",
164 direction: MessageDirection =
"outbound",
166 """Persist one queued item, deduplicating by its caller-owned key."""
167 fields = (connector, grant_id, destination, content, idempotency_key)
168 if not all(value.strip()
for value
in fields):
169 raise ValueError(
"outbox fields must not be blank")
171 digest = content_hash(content)
172 existing = items.get(idempotency_key)
173 if existing
is not None:
175 existing.connector == connector
176 and existing.grant_id == grant_id
177 and existing.destination == destination
178 and existing.content_hash == digest
182 f
"idempotency key {idempotency_key!r} already names different content or scope"
184 stamped = timestamp(self.
_now())
188 destination=destination,
191 idempotency_key=idempotency_key,
194 dispatch_claimed=
False,
195 created_at_utc=stamped,
196 updated_at_utc=stamped,
199 risk_class=risk_class,
201 items[idempotency_key] = item
206 destination=destination,
209 idempotency_key=idempotency_key,
213 risk_class=risk_class,
217 def get(self, idempotency_key: str) -> OutboxItem:
218 """Return one item or raise its public key."""
220 return self.
_load()[idempotency_key]
222 raise KeyError(f
"outbox item {idempotency_key!r} does not exist")
from None
225 """Return items in deterministic idempotency-key order."""
227 return tuple(items[key]
for key
in sorted(items))
231 return datetime.fromisoformat(value.replace(
"Z",
"+00:00")).astimezone(timezone.utc)
233 def _rate_limited(self, item: OutboxItem, items: dict[str, OutboxItem]) -> bool:
234 cutoff = self.
_now().astimezone(timezone.utc) - timedelta(
239 for other
in items.values()
240 if other.destination == item.destination
241 and other.state ==
"sent"
242 and other.sent_at_utc
is not None
248 def _receipt(item: OutboxItem, record: AuditRecord) -> DeliveryReceipt:
249 return DeliveryReceipt(
250 connector=item.connector,
251 destination=item.destination,
252 idempotency_key=item.idempotency_key,
253 outcome=record.outcome,
254 audit_id=record.audit_id,
255 attempts=item.attempts,
261 outcome: Literal[
"sent",
"failed",
"deferred"],
263 error_code: str |
None =
None,
264 ) -> DeliveryReceipt:
265 record = self.
audit.append(
266 connector=item.connector,
267 grant_id=item.grant_id,
268 destination=item.destination,
270 digest=item.content_hash,
271 idempotency_key=item.idempotency_key,
272 attempts=item.attempts,
273 error_code=error_code,
275 direction=item.direction,
276 risk_class=item.risk_class,
281 """Return the delivered item a connector message id belongs to.
283 This is the correlation seam (§4): an inbound reply carries the
284 ``message_id`` of the outbound message it answers, and that id was
285 recorded here at delivery. Resolution therefore needs no side index
286 that could drift from the outbox.
288 if not transport_ref.strip():
291 for key
in sorted(items):
292 candidate = items[key]
293 if candidate.transport_ref == transport_ref:
299 idempotency_key: str,
300 send: Callable[[OutboxItem], str |
None],
301 ) -> DeliveryReceipt:
302 """Process one item through a durable at-most-once dispatch claim.
304 *send* may return the connector's message identifier; when it does, the
305 reference is persisted on the item so inbound replies can correlate
310 item = items[idempotency_key]
312 raise KeyError(f
"outbox item {idempotency_key!r} does not exist")
from None
314 if item.state
in (
"sent",
"failed"):
315 existing = self.
audit.latest(idempotency_key)
316 if existing
is None or existing.outcome
not in (
"sent",
"failed"):
317 return self.
_record(item, item.state)
318 return self.
_receipt(item, existing)
320 if item.dispatch_claimed:
321 item = item.model_copy(
324 "updated_at_utc": timestamp(self.
_now()),
325 "error_code":
"ambiguous_delivery_after_restart",
328 items[idempotency_key] = item
333 error_code=
"ambiguous_delivery_after_restart",
337 item = item.model_copy(
339 "next_attempt_at_utc": timestamp(
342 "updated_at_utc": timestamp(self.
_now()),
345 items[idempotency_key] = item
347 return self.
_record(item,
"deferred", error_code=
"destination_rate_cap")
349 for attempt
in range(item.attempts + 1, self.
max_attempts + 1):
350 item = item.model_copy(
353 "dispatch_claimed":
True,
354 "updated_at_utc": timestamp(self.
_now()),
355 "next_attempt_at_utc":
None,
359 items[idempotency_key] = item
362 transport_ref = send(item)
363 except Exception
as exc:
364 error_code = type(exc).__name__
366 item = item.model_copy(
368 "state":
"failed" if terminal
else "queued",
369 "dispatch_claimed":
False,
370 "updated_at_utc": timestamp(self.
_now()),
371 "error_code": error_code,
374 items[idempotency_key] = item
379 f
"transport attempt={attempt} connector={item.connector} "
380 f
"destination={item.destination} error={error_code}",
384 return self.
_record(item,
"failed", error_code=error_code)
388 item = item.model_copy(
391 "dispatch_claimed":
False,
392 "updated_at_utc": timestamp(self.
_now()),
393 "sent_at_utc": timestamp(self.
_now()),
396 str(transport_ref)
if transport_ref
is not None else None
400 items[idempotency_key] = item
402 return self.
_record(item,
"sent")
404 raise RuntimeError(
"outbox retry loop exhausted without a terminal receipt")
412 "default_outbox_path",
None __init__(self, str|Path|None path=None, *, AuditStore|None audit=None, Callable[[], datetime] now=utc_now, Callable[[float], None] sleep=time.sleep, int max_attempts=3, tuple[float,...] backoff_seconds=(0.25, 0.5), int rate_limit_count=10, float rate_limit_window_seconds=60.0, str|Path|None root=None)
bool _rate_limited(self, OutboxItem item, dict[str, OutboxItem] items)
DeliveryReceipt _record(self, OutboxItem item, Literal["sent", "failed", "deferred"] outcome, *, str|None error_code=None)
OutboxItem enqueue(self, *, str connector, str grant_id, str destination, str content, str idempotency_key, RiskClass|None risk_class=None, LaneId lane="outbound-telegram", MessageDirection direction="outbound")
None _write(self, dict[str, OutboxItem] items)
OutboxItem|None find_by_transport_ref(self, str transport_ref)
DeliveryReceipt process(self, str idempotency_key, Callable[[OutboxItem], str|None] send)
dict[str, OutboxItem] _load(self)
datetime _parse_time(str value)
tuple[OutboxItem,...] list_items(self)
OutboxItem get(self, str idempotency_key)
DeliveryReceipt _receipt(OutboxItem item, AuditRecord record)
rate_limit_window_seconds
Path default_outbox_path(str|Path|None root=None)