feat(exceptions): expose buyer_reason + recovery on ADCPTaskError - #1138
Conversation
Adopts the AdCP 3.2 `error.buyer_reason` sub-object and normative `error.recovery` field on the client-facing surface. The generated pydantic types already carried both; the hand-written exception hierarchy did not. Public API additions: - `BuyerReasonInfo`: typed extraction of `error.buyer_reason` (buyer-safe by spec — no vendor identifiers, no internal IDs). - `AdcpErrorInfo`: typed extraction of a single `error` entry with the new fields alongside `code`/`message`/`field`/`suggestion`/`retry_after`/ `details`. - `extract_adcp_error_info(err)`: normalizes a pydantic `Error`, plain dict, or duck-typed object into `AdcpErrorInfo`. - `ADCPTaskError.error_info`, `.buyer_reasons`, `.first_buyer_reason`, `.wire_recoveries`: typed accessors over the raw `errors` list. `ADCPTaskError.is_retryable` now computes an effective recovery per entry — wire `recovery` when present (authoritative per AdCP 3.1+), else the code-table classification, else `transient` (the AdCP forward-compat rule for unknown codes with no wire recovery). A batch is retryable only when every entry resolves to `transient`; a `terminal` OR `correctable` entry blocks (correctable requires the caller to fix the request — retrying as-is would re-trigger it). Behavior change: an unknown code with no wire `recovery` previously returned `is_retryable=False` (not in `TRANSIENT_CODES`) and now returns `True`, matching the spec's forward-compat rule at `core/error.json` on `error.code`. A producer that ships a new code before the SDK learns about it stays retry-eligible. Extraction hardening: `retry_after` rejects `bool` (a subclass of `int` that would otherwise coerce to `1.0` and schedule a real retry) and non-finite floats. Unknown `recovery` values normalize to `None` — the spec closes the enum to three values, so anything else is non-conforming. Tests: full four-quadrant coverage on `is_retryable` (wire-only, mixed wire + no-wire, no-wire only, empty), extraction from pydantic model / dict / duck-typed object, malformed payloads, and cache-hit identity on `error_info`.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean public-surface change for AdCP 3.2 (buyer_reason + recovery on ADCPTaskError).
Checked:
- Public exports are snapshot-registered (tests/fixtures/public_api_snapshot.json updated) with thorough new tests (tests/test_error_info_extraction.py).
- Import layering intact; no generated-code hand-edits, no CI-gate weakening.
is_retryablebatch-aggregation behavior change is documented and correctness-positive; not a mandatory semver break.
Medium findings
- src/adcp/exceptions.py:107 — retry_after extraction hardens against bool/non-finite but leaves the schema's ge=1/le=3600 range unenforced on the raw-dict path.
Decision rationale: No critical/high findings. gated_paths is false. high_risk is true only via a (modified) entry, but with no medium finding attached to that flag combination and just a single medium finding overall, rows 1–8 do not fire (only 1 medium; row 8 requires ≥3). No no-auto-approve team match. Falls through to row 9 → approve.
- Extractor now rejects `retry_after` values outside the AdCP spec's `[1, 3600]` range at `core/error.json`. Generated pydantic `Error` validates the range; the extractor also serves raw-dict / duck-typed inputs that bypass that validation, so we enforce here too — a negative value would schedule an immediate retry, and a very large one would stall the loop for hours. - Split the `err.error_info is err.error_info` cache-hit assertion into two named locals so the intent is explicit (was flagged as a comparison of identical expressions by static analysis). New tests: negative + over-3600 rejected, [1, 3600] boundaries accepted.
d2e3a7b
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — subsequent pass on PR #1138 with no findings.
What I checked
- Delta since prior approve (head f528713) is a single bounds check on
retry_afterextraction (src/adcp/exceptions.py:174,and 1 <= retry_after_raw <= 3600) plus three tests. - The bound mirrors the generated wire model's
ge=1.0/le=3600.0constraint and is load-bearing for the raw-dict and duck-typed paths that bypass pydantic; guard ordering is safe (finiteness confirmed first) and tests cover negative, over-max, and both boundaries. high_riskis true only becausesrc/adcp/__init__.py(modified) matches the glob; reviewer surfaced no medium-or-higher concern, so the modification is presumed safe (heuristic signal, not an escalation trigger).- No gated paths, no author no-auto-approve team match.
Decision path: No critical/high findings (row 1 n/a). gated_paths false (row 2 n/a). No (deleted) reason (row 3 n/a). No medium findings (rows 4/5/8 n/a). Prior decision was approve, so sticky row 6 n/a. No team gate (row 7 n/a). Falls through to row 9 → approve.
Why
AdCP 3.2 RC.1 adds two normative signals on the error envelope:
error.recovery(transient | correctable | terminal) — the wire-authoritative retry classification, required wheneverbuyer_reasonis present.error.buyer_reason: { code, message }— a buyer-safe classification of the failure.codeis drawn from the standardenums/error-code.jsonvocabulary;messageMUST omit vendor identifiers, ad-server type names, internal object names, internal IDs, and stack traces.The generated pydantic types already carry both. The hand-written surface (
ADCPTaskError,is_retryable, no extraction helper) did not — a buyer-side caller had no ergonomic way to readbuyer_reasonor drive retry decisions from the wire signal.What Changed
New public API:
BuyerReasonInfo: typed extraction oferror.buyer_reason.AdcpErrorInfo: typed extraction of a single error entry (code / message / recovery / buyer_reason / field / suggestion / retry_after / details).extract_adcp_error_info(err): normalizes a pydanticError, plain dict, or duck-typed object intoAdcpErrorInfo.ADCPTaskError.error_info,.buyer_reasons,.first_buyer_reason,.wire_recoveries: typed accessors over the rawerrorslist.Retry semantics —
ADCPTaskError.is_retryable:Every entry gets an effective recovery: wire
recoverywhen present (authoritative per AdCP 3.1+), else the code-table classification, elsetransient(the forward-compat rule for unknown codes atcore/error.jsononerror.code). A batch is retryable only when no entry resolves toterminalORcorrectable.Behavior change to name explicitly: an unknown code with no wire
recoverypreviously returnedis_retryable=False(not inTRANSIENT_CODES) and now returnsTrue, matching the spec's forward-compat rule. A producer that ships a new code before this SDK learns about it stays retry-eligible.Extraction hardening:
retry_afterrejectsbool(a subclass ofint—retry_after: truewould otherwise coerce to1.0and schedule a real retry) and non-finite floats.recoveryvalues normalize toNone. The spec closes the enum to three values; anything else is non-conforming.buyer_reason(missingcodeormessage) → drops the entire object rather than surfacing a half-typed value.Test plan
Error, plain dict, duck-typed object.buyer_reasonpreserved end-to-end throughADCPTaskError.first_buyer_reason(both dict and pydantic paths).is_retryablefour-quadrant: wire-only (terminalblocks,transientenables,correctableblocks), mixed wire + no-wire (per-entry independence), no-wire only (code-table fallback), empty errors.retry_after: trueandretry_after: infreject; non-dictdetailsnormalizes to None; unknownrecoverystring normalizes to None.cached_propertycache-hit identity onerror_info.test_retryable_with_transient_code,test_not_retryable_with_terminal_code,test_basic_task_error,test_multiple_errorsstill pass.