feat: expose buyer_reason on the AdcpErrorInfo + wire projection - #2856
Conversation
Adopts the AdCP 3.2 `error.buyer_reason` sub-object on the
client-facing error surfaces. Generated Zod schemas already carried
it; the hand-written client shape did not.
Reader side:
- `AdcpErrorInfo.buyer_reason` and `ExtractedAdcpError.buyer_reason`
now carry the buyer-safe `{code, message}` when the producer
populated it.
- `buildExtracted`, `extractAdcpErrorInfo`,
`extractAdcpErrorFromMcp`, and `extractAdcpErrorFromTransport` all
forward it. Partial payloads (missing `code` or `message`, empty
strings, non-object values) are dropped rather than surfaced as
half-typed values a caller might render to a buyer.
Producer / seller side:
- `AdcpStructuredError.buyer_reason` and `AdcpError` constructor
option added so adopters can throw a coarse top-level code with a
specific buyer-actionable classification. `toStructuredError()`
round-trips it through the async task-completion path.
- `adcpError()` (via `AdcpErrorOptions`) accepts and emits
`buyer_reason`; `AdcpErrorPayload` declares it.
- Framework sync-throw projection (`projectThrownAdcpError`) spreads
`err.buyer_reason` to the envelope builder.
- Two-layer dispatcher path preserves `buyer_reason` in both
directions: `sanitizePayloadError`'s standard-code projection
includes the key, and `PAYLOAD_ERROR_FIELDS` (envelope ↔ payload
mirror) allowlists it so the two wire layers stay in lockstep.
- `NormalizedError` / `normalizeError()` (public API used by
adopters projecting per-row batch errors — `sync_creatives`,
`sync_audiences`, `sync_accounts`, `report_usage`,
`acquire_rights`) now carries `buyer_reason` too, with the same
strict validation as the reader extractor.
Retry policy:
- `BuyerRetryPolicy` overrides receive `error.buyer_reason` on the
`error` argument and can key retry decisions on
`error.buyer_reason?.code`. Default policy is unchanged — routing
on `buyer_reason` is opt-in via override. Docstring calls out the
"overrides map is keyed by outer `error.code`" constraint.
Constraints preserved:
- `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_IN_FLIGHT` envelope
allowlists intentionally strip `buyer_reason` — those
wire-shape-restricted codes never carry a buyer-actionable
classification.
Tests:
- New `test/normalize-errors.test.js` (7 cases) covering the row
normalizer's forward + drop paths.
- Extended `test/error-extraction.test.js` with buyer_reason
preservation across the L3 / L2 / transport paths, malformed
drops, and full round-trips through `adcpError()`,
`AdcpError.toStructuredError()`, and the sync-throw framework
path via a new `__unstable__projectThrownAdcpError` export used
only by tests.
- Extended `test/lib/buyer-retry-policy.test.js` with override
visibility of `buyer_reason.code` and a backward-compat pin for
the default policy.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — additive AdCP 3.2 error.buyer_reason wiring.
Checked:
- Wire-shape fidelity: the
buyer_reasonfield wiring across the reader/extractor, producer/builder, row normalizer, two-layer dispatcher, and retry-policy surfaces matches the generated types and Zod ErrorSchema. Fail-closed, symmetric validation between the reader extractor and row normalizer; no fabrication or re-shaping at a seam (witness-not-translator preserved). - Restricted-code allowlists correctly strip the field.
- Changeset-vs-wire-impact: the
minorchangeset accurately reflects the non-breaking additive impact of touchingsrc/lib/**; changeset present and correctly typed.
No Critical/High/Medium findings. One Low observation (the two-layer projection test re-extracts rather than exercising PAYLOAD_ERROR_FIELDS directly) noted as a non-blocking follow-up.
Decision-table trace: no critical/high (row 1 n/a); gated_paths false (row 2 n/a); high_risk false (rows 3/5 n/a); no medium findings (rows 4/8 n/a); no prior escalation (row 6 n/a); no no-auto-approve team match (row 7 n/a). Falls through to row 9 — approve. Note: review_decision is REVIEW_REQUIRED, but since gated_paths is false, that does not block approval.
Formatting-only fix for the "Check code formatting" CI step — `npx prettier --write` on the two test files added in the previous commit. No behavior change.
66cddf6
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Delta since the prior approve (head a8960a5) is pure Prettier reformatting of two test files — collapsed multiline literals and arrow-param paren style. No behavior change, no new code paths, and no library or wire surface touched. Changeset present (.changeset/expose-buyer-reason-on-adcp-error-info.md) covering the src/lib/** changes.
Decision-table walk (top-down):
- No no-auto-approve team match.
- gated_paths: false — row 2 does not apply despite review_decision REVIEW_REQUIRED.
- high_risk: false — rows 3/5 do not apply.
- No critical/high/medium findings — rows 1, 4, 8 do not apply.
- Prior decision was
approve, so sticky-escalation row 6 does not apply. - Falls through to row 9 → approve.
No blocking findings. No medium findings.
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 Zod schemas already carry
buyer_reason. The hand-written client surface — the reader (AdcpErrorInfo), the throw shape (AdcpError/AdcpStructuredError), the builder (adcpError()), the sync-throw projection, the two-layer dispatcher, the per-row wire normalizer (normalizeError()), and the retry policy — did not. This PR wires it through every layer so an adopter that populatesbuyer_reasonat the seam actually reaches the buyer with it, and a buyer that readsAdcpErrorInfosees a typed, buyer-safe classification instead ofundefined.What Changed
Reader:
AdcpErrorInfo.buyer_reasonandExtractedAdcpError.buyer_reasontyped.buildExtracted/extractAdcpErrorInfo/extractAdcpErrorFromMcp/extractAdcpErrorFromTransportforward it. Partial payloads (missingcodeormessage, empty strings, non-object) are dropped rather than surfaced as half-typed values.Producer / seller:
AdcpStructuredError.buyer_reasonandAdcpErrorconstructor option typed;toStructuredError()round-trips it (async task-completion path).adcpError()(viaAdcpErrorOptions) accepts and emits it;AdcpErrorPayloaddeclares it.projectThrownAdcpError(framework sync-throw projection) spreadserr.buyer_reason.sanitizePayloadError's standard-code projection includes the key, andPAYLOAD_ERROR_FIELDS(envelope ↔ payload mirror) allowlists it so the two wire layers stay in lockstep.NormalizedError/normalizeError()(public API used by adopters projecting per-row batch errors) carries it with the same strict validation.Retry policy:
BuyerRetryPolicyoverrides receiveerror.buyer_reasonand can key retry decisions onerror.buyer_reason?.code. Default policy unchanged — routing onbuyer_reasonis opt-in via override.Constraints preserved:
IDEMPOTENCY_CONFLICT/IDEMPOTENCY_IN_FLIGHTenvelope allowlists intentionally stripbuyer_reason— those wire-shape-restricted codes never carry a buyer-actionable classification. Negative test locks this in.Test plan
adcpError()→extractAdcpErrorFromMcppreservesbuyer_reason.new AdcpError('X', { buyer_reason })→__unstable__projectThrownAdcpError→extractAdcpErrorFromMcppreserves it (locks in the load-bearing spread insideprojectThrownAdcpError).AdcpError.toStructuredError()carries the field.buyer_reasononCREATIVE_REJECTEDsurvives extract.IDEMPOTENCY_CONFLICTallowlist stripsbuyer_reason.normalizeError/normalizeErrors): forward from plain object and fromAdcpErrorinstance; drop missing-message, empty strings, non-object; absent-when-input-doesn't-carry-it; array coverage.error.buyer_reason.codeand can route on it; default policy is unaffected by the field's presence.npm run test:node:fastgreen (123 / 123).npm run typecheckclean.npm run build:libclean.