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: 10 additions & 0 deletions .changeset/expose-buyer-reason-on-adcp-error-info.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@adcp/sdk': minor
---

Expose the AdCP 3.2 `error.buyer_reason` sub-object on the client-facing error surfaces.

- `AdcpErrorInfo.buyer_reason` and `ExtractedAdcpError.buyer_reason` now carry the buyer-safe `{ code, message }` when the producer populated it. `buildExtracted`, `extractAdcpErrorInfo`, and `extractAdcpErrorFromMcp` / `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.
- `AdcpStructuredError.buyer_reason` and `AdcpError` constructor option added so seller-side adopters can throw a coarse top-level code with a specific buyer-actionable classification. `adcpError()` (via `AdcpErrorOptions`) now accepts and emits `buyer_reason`; the framework's sync-throw projection (`projectThrownAdcpError`) and the two-layer error dispatcher (`sanitizePayloadError`, `PAYLOAD_ERROR_FIELDS`) both carry the field through to the wire. `BuyerRetryPolicy` overrides receive the field on the `error` argument and can key retry decisions on `error.buyer_reason?.code`; the default policy is unchanged — routing on `buyer_reason` is opt-in via override.
- `NormalizedError` and `normalizeError()` (`@adcp/sdk/server`) now carry `buyer_reason` too, so adopters projecting per-row batch errors through `normalizeErrors()` (`sync_creatives`, `sync_audiences`, `sync_accounts`, `report_usage`, `acquire_rights`) don't silently lose the field between their row objects and the wire.
- The `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_IN_FLIGHT` envelope allowlists still strip `buyer_reason` — those wire-shape-restricted codes intentionally never carry a buyer-actionable classification.
11 changes: 11 additions & 0 deletions src/lib/core/ConversationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,17 @@ export interface AdcpErrorInfo {
* the field, `'enum'` → pick a valid value, `'type'` → fix the value type.
*/
issues?: AdcpValidationIssue[];
/**
* Buyer-actionable classification of the failure. Present when the enclosing
* `code`/`message` is too coarse or carries producer-internal context that
* must not cross the buyer trust boundary. `code` reuses the standard error
* vocabulary; `message` is buyer-safe by spec — no vendor identifiers,
* ad-server type names, internal object names, internal IDs, or stack traces
* — so it may be rendered directly to a buyer UI. When present, the
* enclosing `recovery` classifies the buyer-actionable reason. See
* `core/error.json`'s `buyer_reason` field for the normative contract.
*/
buyer_reason?: { code: string; message: string };
/** True when the SDK inferred this error from unstructured text (L1 compliance) */
synthetic?: boolean;
}
Expand Down
16 changes: 15 additions & 1 deletion src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2547,6 +2547,18 @@ function shouldCacheIdempotencyResponse(response: McpToolResponse): boolean {
* `isThrownAdcpError` unwrap, but for the in-process class throw rather
* than the already-projected envelope throw.
*/
/**
* @internal Exposed for regression tests only — not part of the public API.
*
* Serializes a caught `AdcpError` into an MCP envelope by delegating to
* `adcpError()`. The `buyer_reason` spread here is load-bearing: a refactor
* that drops it silently loses the field on every sync-throw code path
* (`test/error-extraction.test.js` pins this).
*/
export function __unstable__projectThrownAdcpError(err: AdcpError): McpToolResponse {
return projectThrownAdcpError(err);
}

function projectThrownAdcpError(err: AdcpError): McpToolResponse {
return adcpError(err.code, {
recovery: err.recovery,
Expand All @@ -2555,6 +2567,7 @@ function projectThrownAdcpError(err: AdcpError): McpToolResponse {
...(err.suggestion !== undefined && { suggestion: err.suggestion }),
...(err.retry_after !== undefined && { retry_after: err.retry_after }),
...(err.details !== undefined && { details: err.details }),
...(err.buyer_reason !== undefined && { buyer_reason: err.buyer_reason }),
});
}

Expand Down Expand Up @@ -3646,7 +3659,7 @@ function sanitizePayloadError(value: unknown): unknown {
message: typeof safe.message === 'string' ? safe.message : 'operation failed',
recovery: STANDARD_ERROR_CODES[code].recovery,
};
for (const key of ['field', 'suggestion', 'retry_after', 'issues']) {
for (const key of ['field', 'suggestion', 'retry_after', 'issues', 'buyer_reason']) {
if (safe[key] !== undefined) projected[key] = safe[key];
}
if (safe.details !== undefined) {
Expand Down Expand Up @@ -3763,6 +3776,7 @@ const PAYLOAD_ERROR_FIELDS: ReadonlySet<string> = new Set([
'retry_after',
'issues',
'details',
'buyer_reason',
]);

function projectEnvelopeToPayloadError(envelope: Record<string, unknown>): Record<string, unknown> {
Expand Down
23 changes: 23 additions & 0 deletions src/lib/server/decisioning/async-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ export interface AdcpStructuredError {
*/
retry_after?: number;
details?: Record<string, unknown>;
/**
* Buyer-actionable classification of the failure. Use when the enclosing
* `code`/`message` is too coarse or carries producer-internal context that
* must not cross the buyer trust boundary. `code` reuses the standard
* error vocabulary; `message` MUST be buyer-safe (no vendor identifiers,
* ad-server type names, internal object names, internal IDs, or stack
* traces). When present, `recovery` classifies the buyer-actionable
* reason. See `core/error.json`'s `buyer_reason` field for the normative
* contract.
*/
buyer_reason?: { code: string; message: string };
}

/**
Expand Down Expand Up @@ -114,6 +125,7 @@ export class AdcpError extends Error {
readonly suggestion?: string;
readonly retry_after?: number;
readonly details?: Record<string, unknown>;
readonly buyer_reason?: { code: string; message: string };

constructor(
code: ErrorCode | (string & {}),
Expand All @@ -130,6 +142,15 @@ export class AdcpError extends Error {
suggestion?: string;
retry_after?: number;
details?: Record<string, unknown>;
/**
* Buyer-actionable classification. When the enclosing `code`/`message`
* is too coarse or carries producer-internal context, populate this
* with a buyer-safe (`code`, `message`) pair drawn from the standard
* error vocabulary. Per spec, `message` MUST NOT contain vendor
* identifiers, ad-server type names, internal object names, internal
* IDs, or stack traces.
*/
buyer_reason?: { code: string; message: string };
}
) {
super(options.message);
Expand All @@ -144,6 +165,7 @@ export class AdcpError extends Error {
if (options.suggestion !== undefined) this.suggestion = options.suggestion;
if (options.retry_after !== undefined) this.retry_after = options.retry_after;
if (options.details !== undefined) this.details = options.details;
if (options.buyer_reason !== undefined) this.buyer_reason = options.buyer_reason;
}

/** Coerce to the structured envelope shape the framework projects to the wire. */
Expand All @@ -156,6 +178,7 @@ export class AdcpError extends Error {
...(this.suggestion !== undefined && { suggestion: this.suggestion }),
...(this.retry_after !== undefined && { retry_after: this.retry_after }),
...(this.details !== undefined && { details: this.details }),
...(this.buyer_reason !== undefined && { buyer_reason: this.buyer_reason }),
};
}

Expand Down
18 changes: 18 additions & 0 deletions src/lib/server/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ export interface AdcpErrorOptions {
* convention.
*/
issues?: Array<Omit<ValidationIssue, 'schemaPath'> & { schemaPath?: string }>;
/**
* Buyer-actionable classification. Populate when the top-level `code`
* is coarse or producer-internal and a buyer-safe `{code, message}` pair
* (drawn from the standard error vocabulary) would give the buyer's
* agent something to act on. Per AdCP 3.2 `core/error.json`, `message`
* MUST be safe to show to the buyer — no vendor identifiers, ad-server
* type names, internal object names, internal IDs, or stack traces. The
* envelope allowlist for `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_IN_FLIGHT`
* drops this key — those codes are wire-shape-restricted and never carry
* a buyer-actionable classification.
*/
buyer_reason?: { code: string; message: string };
}

export interface AdcpErrorPayload {
Expand All @@ -90,6 +102,11 @@ export interface AdcpErrorPayload {
* compatibility.
*/
issues?: Array<Omit<ValidationIssue, 'schemaPath'> & { schemaPath?: string }>;
/**
* Buyer-actionable classification of the failure. See
* {@link AdcpErrorOptions.buyer_reason} for the buyer-safety contract.
*/
buyer_reason?: { code: string; message: string };
}

export interface AdcpErrorResponse {
Expand Down Expand Up @@ -186,6 +203,7 @@ export function adcpError(code: StandardErrorCode | (string & {}), options: Adcp
...(options.retry_after != null && { retry_after: options.retry_after }),
...(options.issues != null && { issues: options.issues }),
...(options.details != null && { details: options.details }),
...(options.buyer_reason != null && { buyer_reason: options.buyer_reason }),
};

const filtered = applyAdcpErrorAllowlist(code, adcp_error as unknown as Record<string, unknown>);
Expand Down
30 changes: 27 additions & 3 deletions src/lib/server/normalize-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface NormalizedError {
retry_after?: number;
details?: Record<string, unknown>;
recovery?: 'transient' | 'correctable' | 'terminal';
/**
* Buyer-actionable classification of the failure. See
* `core/error.json`'s `buyer_reason` field. `message` MUST be
* buyer-safe — no vendor identifiers, ad-server type names, internal
* object names, internal IDs, or stack traces.
*/
buyer_reason?: { code: string; message: string };
}

/**
Expand All @@ -47,10 +54,12 @@ export interface NormalizedError {
* - `string` → `{ code: 'GENERIC_ERROR', message: <input>, recovery: 'terminal' }`
* - `Error` instance → `{ code: 'GENERIC_ERROR', message: err.message, recovery: 'terminal' }`
* - `AdcpError` instance → projected to wire shape using its `code` /
* `recovery` / `field` / `suggestion` / `retry_after` / `details`
* `recovery` / `field` / `suggestion` / `retry_after` / `details` /
* `buyer_reason`
* - Plain object with `code` + `message` → fields whitelisted to the
* wire shape; vendor-specific fields dropped (use `details` for
* vendor extensions)
* wire shape (including `buyer_reason` when both `code` and `message`
* are non-empty strings); vendor-specific fields dropped (use `details`
* for vendor extensions)
* - Any other object → `{ code: 'GENERIC_ERROR', message: <safeStringify>, recovery: 'terminal' }`
* - `null` / `undefined` → `{ code: 'GENERIC_ERROR', message: 'Unknown error', recovery: 'terminal' }`
*
Expand Down Expand Up @@ -92,6 +101,21 @@ export function normalizeError(input: unknown): NormalizedError {
// via pickSafeDetails before reaching here.
out.details = { ...(obj.details as Record<string, unknown>) };
}
// `buyer_reason` per AdCP 3.2 core/error.json. Mirror the strictness of
// the reader-side extractor (`mapBuyerReason` in `error-extraction.ts`):
// require non-empty `code` AND non-empty `message` strings; drop a
// half-formed payload rather than forward it to the wire.
if (typeof obj.buyer_reason === 'object' && obj.buyer_reason !== null) {
const br = obj.buyer_reason as Record<string, unknown>;
if (
typeof br.code === 'string' &&
br.code.length > 0 &&
typeof br.message === 'string' &&
br.message.length > 0
) {
out.buyer_reason = { code: br.code, message: br.message };
}
}
return out;
}
// Native Error (no `code` field, has `message`).
Expand Down
11 changes: 11 additions & 0 deletions src/lib/utils/buyer-retry-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ export interface RetryContext {
* Override hook for adopters with vertical-specific policy needs.
* Receives the error + context; returns a `RetryDecision` or `null` to fall
* through to the default policy.
*
* The `error` argument is the full `AdcpStructuredError`, so an override
* registered under a coarse outer `code` (e.g. `INVALID_REQUEST`) can still
* key on `error.buyer_reason?.code` for a more specific action — useful when
* the seller emits a producer-internal top-level code but populates
* `buyer_reason` with a standard buyer-actionable classification like
* `BUDGET_TOO_LOW` or `CREATIVE_REJECTED`. Note that the overrides map is
* keyed by outer `error.code` only — an override that inspects
* `error.buyer_reason.code` MUST be registered under every outer code that
* might carry that buyer-actionable reason (or under the wildcard patterns
* an adopter's platform emits).
*/
export type RetryDecisionOverride = (error: AdcpStructuredError, ctx: RetryContext) => RetryDecision | null;

Expand Down
40 changes: 38 additions & 2 deletions src/lib/utils/error-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface ExtractedAdcpError {
retry_after?: number;
details?: Record<string, unknown>;
issues?: AdcpValidationIssue[];
/**
* Buyer-actionable classification. Mirrors `AdcpErrorInfo.buyer_reason`;
* `message` is buyer-safe by spec (no vendor identifiers, no internal IDs).
*/
buyer_reason?: { code: string; message: string };
/** Where the error was found in the response */
source: 'structuredContent' | 'text_json' | 'text_pattern';
/** The compliance level this delivery achieves */
Expand Down Expand Up @@ -177,6 +182,30 @@ function mapValidationIssues(raw: unknown): AdcpValidationIssue[] | undefined {
return mapped.length > 0 ? mapped : undefined;
}

/**
* Extract a well-formed `buyer_reason` sub-object.
*
* Returns `undefined` when the input is absent or its shape does not meet the
* spec's minimum contract (non-empty `code` string AND non-empty `message`
* string at `core/error.json`'s `buyer_reason`). A partial payload — e.g. a
* producer that sent `code` but no `message` — is dropped rather than
* surfacing a half-typed value that callers might render to a buyer.
*
* The wire schema keeps `buyer_reason` open for forward compatibility; the
* extractor deliberately narrows to `{code, message}` and drops any extra
* keys, so a future SDK bump is required before consumers can rely on
* additional fields.
*/
function mapBuyerReason(raw: unknown): { code: string; message: string } | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const obj = raw as Record<string, unknown>;
const code = obj.code;
const message = obj.message;
if (typeof code !== 'string' || code.length === 0) return undefined;
if (typeof message !== 'string' || message.length === 0) return undefined;
return { code, message };
}

function buildExtracted(
obj: any,
source: ExtractedAdcpError['source'],
Expand All @@ -196,6 +225,8 @@ function buildExtracted(
if (obj.details != null) result.details = obj.details;
const mappedIssues = mapValidationIssues(obj.issues);
if (mappedIssues) result.issues = mappedIssues;
const buyerReason = mapBuyerReason(obj.buyer_reason);
if (buyerReason) result.buyer_reason = buyerReason;
return result;
}

Expand Down Expand Up @@ -261,19 +292,24 @@ export function extractAdcpErrorInfo(data: any): AdcpErrorInfo | undefined {
if (ae.details != null) info.details = ae.details;
const mappedIssues = mapValidationIssues(ae.issues);
if (mappedIssues) info.issues = mappedIssues;
const buyerReason = mapBuyerReason(ae.buyer_reason);
if (buyerReason) info.buyer_reason = buyerReason;
if (ae.synthetic) info.synthetic = true;
return info;
}

// Legacy `{ errors: [...] }` envelope — L1 compat only. Extracts code/message/recovery
// from errors[0]; does not forward issues[], field, suggestion, or details since this
// path is only reached for non-standard envelopes that are unlikely to carry issues[].
// (and `buyer_reason` when the seller populated it on the first entry) from errors[0];
// does not forward issues[], field, suggestion, or details since this path is only
// reached for non-standard envelopes that are unlikely to carry them.
if (Array.isArray(data.errors) && data.errors.length > 0) {
const first = data.errors[0];
if (typeof first.code === 'string') {
const info: AdcpErrorInfo = { code: first.code, message: first.message || '' };
const recovery = resolveRecovery(first);
if (recovery) info.recovery = recovery;
const buyerReason = mapBuyerReason(first.buyer_reason);
if (buyerReason) info.buyer_reason = buyerReason;
return info;
}
}
Expand Down
Loading
Loading