From f8684136ef9300dd1a9e6dbed6f05efac90d117f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 26 Oct 2026 00:02:48 +0000 Subject: [PATCH] fix(reporting): classify pending status and owed history as transient, unresolved references as REFERENCE_NOT_FOUND installed_reporting_call projected STATUS_PROJECTION_UNAVAILABLE, HISTORY_UNAVAILABLE and LOOKUP_UNAVAILABLE as terminal. A pending status lifecycle and retained history the seller still owes resolve on the seller side, so they are transient. A reporting reference that does not resolve for the caller answers the standard, nondisclosing REFERENCE_NOT_FOUND (correctable) with a generic message. Fixes #1618 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reliable-reporting-service.md | 10 ++++- src/adcp/reporting/_errors.py | 12 ++++++ tests/test_reporting_installed_errors.py | 51 +++++++++++++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/reliable-reporting-service.md b/docs/reliable-reporting-service.md index dffee6fd7..df9ec9fd4 100644 --- a/docs/reliable-reporting-service.md +++ b/docs/reliable-reporting-service.md @@ -394,8 +394,14 @@ returns `AUTH_REQUIRED`; explicit buyer configuration validation can raise Composition errors retain `INTERNAL_ERROR` and terminal recovery, with internal configuration details omitted from buyer responses. Ledger codes are preserved and receive the SDK's recovery classification for that code. Reporting cursor/ -input extensions are correctable, storage-unavailability extensions are transient, -and unknown extension or integrity failures default to terminal. Disabled optional methods and aggregate delivery +input extensions are correctable. Storage-unavailability extensions are transient, +as are a pending status lifecycle (`STATUS_PROJECTION_UNAVAILABLE`) and retained +history the seller still owes but has not yet materialized or repaired +(`HISTORY_UNAVAILABLE`): the request is valid, and the same retry succeeds once +the seller catches up. A reporting reference that does not resolve for the caller +(`LOOKUP_UNAVAILABLE`) answers the standard, nondisclosing `REFERENCE_NOT_FOUND` +with correctable recovery. Unknown extension or integrity failures default to +terminal. Disabled optional methods and aggregate delivery continue to delegate to the application. ## Source identity and automatic read timing diff --git a/src/adcp/reporting/_errors.py b/src/adcp/reporting/_errors.py index eb5105aa3..dd5c6e017 100644 --- a/src/adcp/reporting/_errors.py +++ b/src/adcp/reporting/_errors.py @@ -16,6 +16,10 @@ # Reporting extensions have local meanings. Normative SDK codes always take # precedence; unlisted extension/invariant failures retain terminal recovery. +# Transient covers seller-side state the seller still owes the buyer: a pending +# status lifecycle, and retained history that is not yet materialized or awaits +# repair. The buyer's request is valid and an identical retry succeeds once the +# seller catches up, as for unreadable revision rows (SERVICE_UNAVAILABLE). _REPORTING_CORRECTABLE = frozenset( { "CURSOR_REVISION_MISMATCH", @@ -49,6 +53,8 @@ "REPORTING_CONTENT_UNAVAILABLE", "REPORTING_FEED_STORAGE_UNAVAILABLE", "RECEIPT_STORAGE_UNAVAILABLE", + "STATUS_PROJECTION_UNAVAILABLE", + "HISTORY_UNAVAILABLE", } ) @@ -77,6 +83,12 @@ def reporting_error(code: str, message: str) -> Error: # nondisclosing answer is the standard reference error. code = "REFERENCE_NOT_FOUND" message = "Reporting revision not found" + elif code == "LOOKUP_UNAVAILABLE": + # A buyer-supplied reporting reference that does not resolve for this + # caller. Typed parameters without a dedicated code use the standard, + # nondisclosing reference error with a generic message. + code = "REFERENCE_NOT_FOUND" + message = "Reporting reference not found" recovery = None if code not in STANDARD_ERROR_CODES: if code in _REPORTING_CORRECTABLE: diff --git a/tests/test_reporting_installed_errors.py b/tests/test_reporting_installed_errors.py index 93214f50f..5d4bbc762 100644 --- a/tests/test_reporting_installed_errors.py +++ b/tests/test_reporting_installed_errors.py @@ -97,7 +97,8 @@ async def get_media_buy_delivery(self, params, context=None): ("CURSOR_REVISION_MISMATCH", "correctable"), ("INVALID_PAGE_SIZE", "correctable"), ("CONFIGURATION_GENERATION_IMMUTABLE", "correctable"), - ("HISTORY_UNAVAILABLE", "terminal"), + ("HISTORY_UNAVAILABLE", "transient"), + ("STATUS_PROJECTION_UNAVAILABLE", "transient"), ("REVISION_CONTENT_MISMATCH", "terminal"), ("REPORTING_STATUS_UNAVAILABLE", "transient"), ("RECEIPT_STORAGE_UNAVAILABLE", "transient"), @@ -112,3 +113,51 @@ async def test_reporting_extension_codes_retain_their_meaning(code, recovery): assert caught.value.code == code assert caught.value.recovery == recovery await service.close() + + +@pytest.mark.parametrize("decisioning", [False, True]) +@pytest.mark.parametrize( + "ledger_code,message,code,wire_message,recovery", + [ + ( + "STATUS_PROJECTION_UNAVAILABLE", + "status lifecycle is pending", + "STATUS_PROJECTION_UNAVAILABLE", + "status lifecycle is pending", + "transient", + ), + ( + "HISTORY_UNAVAILABLE", + "the revision history requires repair", + "HISTORY_UNAVAILABLE", + "the revision history requires repair", + "transient", + ), + ( + "LOOKUP_UNAVAILABLE", + "reporting_obligation_id obl_secret does not resolve for this caller and account", + "REFERENCE_NOT_FOUND", + "Reporting reference not found", + "correctable", + ), + ], +) +async def test_owed_state_retries_and_unresolved_references_are_correctable( + decisioning, ledger_code, message, code, wire_message, recovery +): + from adcp.reporting._errors import installed_reporting_call + + async def call(): + raise LedgerConflictError(ledger_code, message) + + with pytest.raises(AdcpError if decisioning else ADCPTaskError) as caught: + await installed_reporting_call("sync_reporting_status", call, decisioning=decisioning) + error = caught.value + if decisioning: + assert (error.code, error.recovery) == (code, recovery) + assert wire_message in str(error) + else: + wire = error.error_info[0] + assert (wire.code, wire.message, wire.recovery) == (code, wire_message, recovery) + assert "obl_secret" not in str(error) + assert STANDARD_ERROR_CODES.get(code, {}).get("recovery", recovery) == recovery