Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/reliable-reporting-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/adcp/reporting/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -49,6 +53,8 @@
"REPORTING_CONTENT_UNAVAILABLE",
"REPORTING_FEED_STORAGE_UNAVAILABLE",
"RECEIPT_STORAGE_UNAVAILABLE",
"STATUS_PROJECTION_UNAVAILABLE",
"HISTORY_UNAVAILABLE",
}
)

Expand Down Expand Up @@ -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:
Expand Down
51 changes: 50 additions & 1 deletion tests/test_reporting_installed_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Loading