"""Tests for the shared native-forwarder POST delivery classifier.""" from __future__ import annotations import json from pathlib import Path import httpx import pytest from omnigent._native_post_delivery import ( _DEAD_LETTER_BACKUP_FILE, _DEAD_LETTER_FILE, _DEAD_LETTER_MAX_BYTES, RepostResult, _dead_letter_record_replayable, append_dead_letter, post_external_session_status, post_may_have_been_delivered, replay_dead_letters, ) # Status codes a forwarder treats as transient/retryable, used by the replay # classifier to tell a recoverable exhausted-503 from a permanent 4xx. _RETRYABLE = frozenset({429, 500, 503}) def _dead_letter_record( *, session_id: str = "conv_abc123", event_type: str = "external_conversation_item", payload: dict[str, object] | None = None, reason: str = "proven-undelivered transport failure after retries", delivered_ambiguous: bool = False, http_status: int | None = None, transport_error: str | None = None, ) -> dict[str, object]: """Build a classified dead-letter record for replay fixtures.""" return { "ts": 1.0, "session_id": session_id, "event_type": event_type, "reason": reason, "delivered_ambiguous": delivered_ambiguous, "http_status": http_status, "transport_error": transport_error, "payload": payload if payload is not None else {"item_type": "message"}, } def _write_records(path: Path, records: list[dict[str, object]]) -> None: """Write records as one JSON line each, mirroring the dead-letter format.""" path.write_text( "".join(json.dumps(record) + "\n" for record in records), encoding="utf-8", ) def _read_records(path: Path) -> list[dict[str, object]]: """Parse a dead-letter file back into records.""" return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] @pytest.mark.parametrize( "exc,may_have_been_delivered", [ # Server responded with a status — the events route returns 2xx # only after the append + consume publish, so any non-2xx means # the item was NOT committed. Safe to retry. ( httpx.HTTPStatusError( "boom", request=httpx.Request("POST", "http://test"), response=httpx.Response(503), ), False, ), # Connection never established / pool never acquired — no request # bytes were sent, so the item was not delivered. Safe to retry. (httpx.ConnectError("refused", request=httpx.Request("POST", "http://test")), False), ( httpx.ConnectTimeout("slow connect", request=httpx.Request("POST", "http://test")), False, ), (httpx.PoolTimeout("no slot", request=httpx.Request("POST", "http://test")), False), # Request was sent and no response was seen — the server may have # committed it. Ambiguous: a retry could duplicate. (httpx.ReadTimeout("no response", request=httpx.Request("POST", "http://test")), True), (httpx.WriteError("write failed", request=httpx.Request("POST", "http://test")), True), ( httpx.RemoteProtocolError("peer closed", request=httpx.Request("POST", "http://test")), True, ), ], ) def test_post_may_have_been_delivered_classification( exc: httpx.HTTPError, may_have_been_delivered: bool ) -> None: """ Classify which POST failures may have reached + committed the server. A forwarder must not retry a POST that may already be committed, because external conversation items are not deduped server-side, so a retry would surface as a duplicate bubble in the web UI. A wrong classification means either duplicates (ambiguous error marked safe-to-retry) or lost messages (provably-undelivered error marked ambiguous and dropped). :param exc: HTTP exception raised while posting an AP event. :param may_have_been_delivered: Whether the request may have been committed despite the error. """ assert post_may_have_been_delivered(exc) is may_have_been_delivered def test_append_dead_letter_writes_parseable_line(tmp_path: Path) -> None: """ A dropped forward payload is appended as one parseable JSON line. :param tmp_path: Pytest temp dir standing in for a bridge dir. """ append_dead_letter( tmp_path, session_id="conv_abc123", event_type="external_conversation_item", payload={"item_type": "message", "item_data": {"role": "assistant"}}, reason="permanent HTTP failure after retries", ) dl_path = tmp_path / _DEAD_LETTER_FILE lines = dl_path.read_text(encoding="utf-8").splitlines() assert len(lines) == 1 record = json.loads(lines[0]) assert record["session_id"] == "conv_abc123" assert record["event_type"] == "external_conversation_item" assert record["reason"] == "permanent HTTP failure after retries" assert record["payload"] == { "item_type": "message", "item_data": {"role": "assistant"}, } assert isinstance(record["ts"], (int, float)) def test_append_dead_letter_rotates_at_cap_keeping_newest(tmp_path: Path) -> None: """ At the cap the file rotates to a ``.1`` backup and keeps the newest item. A sustained outage must retain the most recent drops, not stop at the oldest: the full file moves to ``dead_letter.jsonl.1`` and the new record lands in a fresh ``dead_letter.jsonl``. :param tmp_path: Pytest temp dir standing in for a bridge dir. """ dl_path = tmp_path / _DEAD_LETTER_FILE backup_path = tmp_path / _DEAD_LETTER_BACKUP_FILE # Use a sparse file (truncate) to reach the cap without writing 50 MB. with dl_path.open("wb") as fh: fh.truncate(_DEAD_LETTER_MAX_BYTES + 1) capped_size = dl_path.stat().st_size append_dead_letter( tmp_path, session_id="conv_abc123", event_type="external_session_usage", payload={"context_tokens": 1}, reason="post failed", ) # Old content rotated out to the backup; the newest record is in the # fresh active file as the sole line. assert backup_path.stat().st_size == capped_size lines = dl_path.read_text(encoding="utf-8").splitlines() assert len(lines) == 1 record = json.loads(lines[0]) assert record["event_type"] == "external_session_usage" assert record["payload"] == {"context_tokens": 1} def test_append_dead_letter_never_raises_on_unwritable_dir() -> None: """A bogus / unwritable bridge dir is swallowed, not raised.""" bogus = Path("/this/path/does/not/exist/and/cannot/be/made\x00") # Must return without raising despite the invalid path. append_dead_letter( bogus, session_id="conv_abc123", event_type="external_conversation_item", payload={"item_type": "message"}, reason="post failed", ) def test_append_dead_letter_writes_classification_fields(tmp_path: Path) -> None: """ The structured classification replay reads is persisted on each record (#1579). :param tmp_path: Pytest temp dir standing in for a bridge dir. """ append_dead_letter( tmp_path, session_id="conv_abc123", event_type="external_conversation_item", payload={"item_type": "message"}, reason="ambiguous transport failure (may already be committed)", delivered_ambiguous=True, http_status=None, transport_error="ReadTimeout", ) record = _read_records(tmp_path / _DEAD_LETTER_FILE)[0] assert record["delivered_ambiguous"] is True assert record["http_status"] is None assert record["transport_error"] == "ReadTimeout" def test_append_dead_letter_classification_defaults(tmp_path: Path) -> None: """A record written without classification args defaults to non-ambiguous.""" append_dead_letter( tmp_path, session_id="conv_abc123", event_type="external_session_usage", payload={"context_tokens": 1}, reason="post failed", ) record = _read_records(tmp_path / _DEAD_LETTER_FILE)[0] assert record["delivered_ambiguous"] is False assert record["http_status"] is None assert record["transport_error"] is None @pytest.mark.parametrize( "record,replayable", [ # Proven-undelivered transport failure (no response, not ambiguous). (_dead_letter_record(), True), # Retryable status exhausted after the forwarder's bounded retries. (_dead_letter_record(http_status=503), True), # Ambiguous: the server may have committed it — never replay. (_dead_letter_record(delivered_ambiguous=True), False), # Permanent 4xx: the server rejected it; a replay just re-rejects. (_dead_letter_record(http_status=400), False), # Non-retryable 5xx that is not in the retry set is also not recoverable. (_dead_letter_record(http_status=501), False), # Missing routing fields — cannot be re-POSTed. (_dead_letter_record(session_id=""), False), # A non-dict (malformed line) is never replayable. ("not-a-record", False), # Legacy record written before classification existed (#1579): no # ``delivered_ambiguous`` field, so treated as unsafe (forensic only). ( { "ts": 1.0, "session_id": "conv_abc123", "event_type": "external_conversation_item", "reason": "post failed", "payload": {"item_type": "message"}, }, False, ), ], ) def test_dead_letter_record_replayable_classification(record: object, replayable: bool) -> None: """ Only proven-undelivered records are replayable; everything else is forensic. A wrong classification either duplicates a committed item (ambiguous or permanent record wrongly replayed) or re-rejects forever. :param record: A parsed dead-letter record (or non-dict) to classify. :param replayable: Whether the record is safe to re-POST. """ assert _dead_letter_record_replayable(record, retryable_status_codes=_RETRYABLE) is replayable async def test_replay_drains_backup_then_current_in_order(tmp_path: Path) -> None: """ Replay re-POSTs the ``.1`` backup first, then the current file, in order (#1579). :param tmp_path: Pytest temp dir standing in for a bridge dir. """ backup = tmp_path / _DEAD_LETTER_BACKUP_FILE current = tmp_path / _DEAD_LETTER_FILE _write_records( backup, [_dead_letter_record(payload={"n": 0}), _dead_letter_record(payload={"n": 1})] ) _write_records( current, [_dead_letter_record(payload={"n": 2}), _dead_letter_record(payload={"n": 3})] ) seen: list[int] = [] async def repost(record: dict[str, object]) -> RepostResult: seen.append(record["payload"]["n"]) # type: ignore[index] return RepostResult(delivered=True) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE ) assert replayed == 4 # .1 backup (0, 1) precedes the current file (2, 3), each in append order. assert seen == [0, 1, 2, 3] # All delivered, so both files are removed. assert not backup.exists() assert not current.exists() async def test_replay_skips_ambiguous_and_permanent_4xx(tmp_path: Path) -> None: """ Ambiguous and permanent-4xx records are never re-POSTed; they stay forensic. :param tmp_path: Pytest temp dir standing in for a bridge dir. """ current = tmp_path / _DEAD_LETTER_FILE _write_records( current, [ _dead_letter_record(payload={"n": 0}, delivered_ambiguous=True), _dead_letter_record(payload={"n": 1}, http_status=400), _dead_letter_record(payload={"n": 2}), _dead_letter_record(payload={"n": 3}, http_status=503), ], ) seen: list[int] = [] async def repost(record: dict[str, object]) -> RepostResult: seen.append(record["payload"]["n"]) # type: ignore[index] return RepostResult(delivered=True) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE ) assert replayed == 2 # Only the proven-undelivered transport (2) and exhausted-503 (3) replayed. assert seen == [2, 3] # Ambiguous (0) and permanent-4xx (1) retained, untouched, in order. retained = _read_records(current) assert [record["payload"]["n"] for record in retained] == [0, 1] async def test_replay_success_removes_and_failure_retains(tmp_path: Path) -> None: """ A delivered record is removed; one that still fails is retained (#1579). :param tmp_path: Pytest temp dir standing in for a bridge dir. """ current = tmp_path / _DEAD_LETTER_FILE _write_records( current, [_dead_letter_record(payload={"n": 0}), _dead_letter_record(payload={"n": 1})], ) async def repost(record: dict[str, object]) -> RepostResult: if record["payload"]["n"] == 0: # type: ignore[index] return RepostResult(delivered=True) # Still proven-undelivered (transport failed again). return RepostResult(delivered=False, http_status=None) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE ) assert replayed == 1 retained = _read_records(current) assert [record["payload"]["n"] for record in retained] == [1] # The retained record stays proven-undelivered (replayable on a later run). assert retained[0]["delivered_ambiguous"] is False assert retained[0]["http_status"] is None async def test_replay_reclassifies_record_that_now_fails_ambiguously(tmp_path: Path) -> None: """ A replay attempt that fails *ambiguously* reclassifies the record so it is never auto-replayed again — guarding against a duplicate (#1579). :param tmp_path: Pytest temp dir standing in for a bridge dir. """ current = tmp_path / _DEAD_LETTER_FILE _write_records(current, [_dead_letter_record(payload={"n": 0})]) async def repost_ambiguous(record: dict[str, object]) -> RepostResult: return RepostResult(delivered=False, delivered_ambiguous=True) replayed = await replay_dead_letters( tmp_path, repost=repost_ambiguous, retryable_status_codes=_RETRYABLE ) assert replayed == 0 retained = _read_records(current) assert len(retained) == 1 assert retained[0]["delivered_ambiguous"] is True # A second startup must NOT re-POST the now-ambiguous record. calls: list[object] = [] async def repost_record(record: dict[str, object]) -> RepostResult: calls.append(record) return RepostResult(delivered=True) replayed_again = await replay_dead_letters( tmp_path, repost=repost_record, retryable_status_codes=_RETRYABLE ) assert replayed_again == 0 assert calls == [] async def test_replay_no_replayable_records_leaves_files_untouched(tmp_path: Path) -> None: """ With nothing recoverable, replay never calls ``repost`` or rewrites the file. :param tmp_path: Pytest temp dir standing in for a bridge dir. """ current = tmp_path / _DEAD_LETTER_FILE _write_records( current, [ _dead_letter_record(payload={"n": 0}, delivered_ambiguous=True), _dead_letter_record(payload={"n": 1}, http_status=404), ], ) before = current.read_bytes() called = False async def repost(record: dict[str, object]) -> RepostResult: nonlocal called called = True return RepostResult(delivered=True) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE ) assert replayed == 0 assert called is False # File is byte-identical: forensic records are left exactly as written. assert current.read_bytes() == before async def test_replay_with_no_dead_letter_files_is_a_noop(tmp_path: Path) -> None: """Replay on a bridge dir with no dead-letter file does nothing.""" called = False async def repost(record: dict[str, object]) -> RepostResult: nonlocal called called = True return RepostResult(delivered=True) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE ) assert replayed == 0 assert called is False async def test_replay_respects_max_records(tmp_path: Path) -> None: """ At most ``max_records`` records are re-POSTed; the rest are deferred (#1579). Bounds startup latency even against a healthy server with a huge file. The deferred records are retained unchanged for a later startup, not dropped. :param tmp_path: Pytest temp dir standing in for a bridge dir. """ current = tmp_path / _DEAD_LETTER_FILE _write_records(current, [_dead_letter_record(payload={"n": i}) for i in range(3)]) seen: list[int] = [] async def repost(record: dict[str, object]) -> RepostResult: seen.append(record["payload"]["n"]) # type: ignore[index] return RepostResult(delivered=True) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE, max_records=2 ) assert replayed == 2 # Only the first two were attempted, in order. assert seen == [0, 1] # The third was deferred and kept for next startup. retained = _read_records(current) assert [record["payload"]["n"] for record in retained] == [2] async def test_replay_respects_deadline(tmp_path: Path) -> None: """ Once the deadline is exceeded, replay defers everything (no re-POST) (#1579). A zero budget defers before the first attempt, leaving the file untouched. :param tmp_path: Pytest temp dir standing in for a bridge dir. """ current = tmp_path / _DEAD_LETTER_FILE _write_records( current, [_dead_letter_record(payload={"n": 0}), _dead_letter_record(payload={"n": 1})], ) before = current.read_bytes() called = False async def repost(record: dict[str, object]) -> RepostResult: nonlocal called called = True return RepostResult(delivered=True) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE, deadline_seconds=0.0 ) assert replayed == 0 assert called is False # Nothing changed, so the file is left byte-identical for the next startup. assert current.read_bytes() == before async def test_replay_preserves_malformed_lines(tmp_path: Path) -> None: """ A malformed (unparseable) line is retained verbatim across a replay rewrite. :param tmp_path: Pytest temp dir standing in for a bridge dir. """ current = tmp_path / _DEAD_LETTER_FILE current.write_text( "not-json garbage line\n" + json.dumps(_dead_letter_record(payload={"n": 0})) + "\n", encoding="utf-8", ) async def repost(record: dict[str, object]) -> RepostResult: return RepostResult(delivered=True) replayed = await replay_dead_letters( tmp_path, repost=repost, retryable_status_codes=_RETRYABLE ) assert replayed == 1 # The parseable record was delivered and removed; the garbage line survives. remaining = current.read_text(encoding="utf-8").splitlines() assert remaining == ["not-json garbage line"] async def test_retry_loop_records_exhausted_connectivity_failure_for_watchdog() -> None: """An exhausted-retry connectivity failure is recorded for the idle watchdog. Writer half of issue #1119: when every POST attempt raises a connection error (e.g. ``No route to host``), the shared retry loop must record the failure in ``_native_forwarder_health`` so the harness idle-turn watchdog can name the real cause. Fails before the recording call was added (the health slot stays empty); passes after. """ from omnigent import _native_forwarder_health as health from omnigent._native_post_delivery import post_session_event_with_retry class _AlwaysConnectError: """Stub client whose every POST fails to connect.""" async def post(self, url: str, *, json: object) -> httpx.Response: """Raise a connect error mimicking an unreachable server.""" del json raise httpx.ConnectError("No route to host", request=httpx.Request("POST", url)) async def _no_sleep(_: float) -> None: """No-op sleep so retries don't add real delay.""" health.clear() try: result = await post_session_event_with_retry( client=_AlwaysConnectError(), # type: ignore[arg-type] url="/v1/sessions/conv_x/events", payload={"type": "external_session_status", "data": {}}, event_type="external_session_status", max_attempts=2, retry_status_codes=frozenset(), sleep=_no_sleep, retry_delay=lambda _attempt: 0.0, logger_name="test.native_post_delivery", ) assert result is None detail = health.recent_post_failure(60.0) assert detail is not None assert "external_session_status" in detail assert "No route to host" in detail finally: health.clear() async def test_retry_loop_success_clears_a_prior_connectivity_failure() -> None: """A successful POST clears a previously recorded connectivity failure. Misattribution guard for issue #1119: once the server is reachable again, the retry loop must empty the failure slot so the idle watchdog can't blame a long-resolved outage for a later, unrelated stall. """ from omnigent import _native_forwarder_health as health from omnigent._native_post_delivery import post_session_event_with_retry class _Ok: """Stub client whose POST always succeeds with 200.""" async def post(self, url: str, *, json: object) -> httpx.Response: """Return a 200 response.""" del json return httpx.Response(200, request=httpx.Request("POST", url)) async def _no_sleep(_: float) -> None: """No-op sleep.""" health.clear() try: # Simulate an earlier outage still on record. health.record_post_failure( "external_session_status", httpx.ConnectError("No route to host") ) assert health.recent_post_failure(60.0) is not None response = await post_session_event_with_retry( client=_Ok(), # type: ignore[arg-type] url="/v1/sessions/conv_x/events", payload={"type": "external_session_status", "data": {}}, event_type="external_session_status", max_attempts=2, retry_status_codes=frozenset(), sleep=_no_sleep, retry_delay=lambda _attempt: 0.0, logger_name="test.native_post_delivery", ) assert response is not None assert response.status_code == 200 # The successful round-trip must have cleared the stale failure. assert health.recent_post_failure(60.0) is None finally: health.clear() @pytest.mark.asyncio async def test_post_external_session_status_attaches_failure_reason() -> None: """A failed status carries its reason as ``output``; idle omits it. The server surfaces a failed edge's ``output`` as the session's failure detail, so threading a drop reason there renders it instead of a bare "failed"; a normal idle edge sends a bare status with no output field. """ captured: list[dict[str, object]] = [] def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/v1/sessions/conv_x/events": captured.append(json.loads(request.content)) return httpx.Response(204) return httpx.Response(404) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://ap") as client: await post_external_session_status( client, session_id="conv_x", status="failed", output="transcript item item-1 rejected", ) # No reason → no output field (e.g. a normal idle edge). await post_external_session_status(client, session_id="conv_x", status="idle") assert captured[0]["type"] == "external_session_status" assert captured[0]["data"] == { "status": "failed", "output": "transcript item item-1 rejected", } assert captured[1]["data"] == {"status": "idle"}