Skip to content

feat(exceptions): expose buyer_reason + recovery on ADCPTaskError - #1138

Merged
nastassiafulconis merged 2 commits into
mainfrom
ohalushchak/expose-buyer-reason-on-adcp-task-error
Sep 8, 2026
Merged

feat(exceptions): expose buyer_reason + recovery on ADCPTaskError#1138
nastassiafulconis merged 2 commits into
mainfrom
ohalushchak/expose-buyer-reason-on-adcp-task-error

Conversation

@ohalushchak-exadel

Copy link
Copy Markdown
Collaborator

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 whenever buyer_reason is present.
  • error.buyer_reason: { code, message } — a buyer-safe classification of the failure. code is drawn from the standard enums/error-code.json vocabulary; message MUST 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 read buyer_reason or drive retry decisions from the wire signal.

What Changed

New public API:

  • BuyerReasonInfo: typed extraction of error.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 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.

Retry semantics — ADCPTaskError.is_retryable:

Every entry gets an effective recovery: wire recovery when present (authoritative per AdCP 3.1+), else the code-table classification, else transient (the forward-compat rule for unknown codes at core/error.json on error.code). A batch is retryable only when no entry resolves to terminal OR correctable.

Behavior change to name explicitly: 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. A producer that ships a new code before this SDK learns about it stays retry-eligible.

Extraction hardening:

  • retry_after rejects bool (a subclass of intretry_after: true 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; anything else is non-conforming.
  • Malformed buyer_reason (missing code or message) → drops the entire object rather than surfacing a half-typed value.

Test plan

  • Extraction from pydantic Error, plain dict, duck-typed object.
  • buyer_reason preserved end-to-end through ADCPTaskError.first_buyer_reason (both dict and pydantic paths).
  • is_retryable four-quadrant: wire-only (terminal blocks, transient enables, correctable blocks), mixed wire + no-wire (per-entry independence), no-wire only (code-table fallback), empty errors.
  • Unknown-code forward-compat default (transient) with and without a terminal in the same batch.
  • Hardening: retry_after: true and retry_after: inf reject; non-dict details normalizes to None; unknown recovery string normalizes to None.
  • cached_property cache-hit identity on error_info.
  • Backward compat: original test_retryable_with_transient_code, test_not_retryable_with_terminal_code, test_basic_task_error, test_multiple_errors still pass.
  • Public-API snapshot regenerated (three new symbols only).
  • Lint + mypy clean.

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`.
Comment thread tests/test_error_info_extraction.py Fixed
Comment thread src/adcp/exceptions.py
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Sep 7, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_retryable batch-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.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_after extraction (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.0 constraint 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_risk is true only because src/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.

@nastassiafulconis
nastassiafulconis merged commit 77760fd into main Sep 8, 2026
79 checks passed
@nastassiafulconis
nastassiafulconis deleted the ohalushchak/expose-buyer-reason-on-adcp-task-error branch September 8, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants