diff --git a/.changeset/run-error-numeric-status.md b/.changeset/run-error-numeric-status.md new file mode 100644 index 000000000..5c057ba89 --- /dev/null +++ b/.changeset/run-error-numeric-status.md @@ -0,0 +1,12 @@ +--- +'@tanstack/ai': patch +--- + +`toRunErrorPayload` now falls back to a numeric `status` field when a thrown +error carries no `code`. Some SDK error classes report the HTTP status only as +`status: number` and no `code` (for example Google's `@google/genai` +`ApiError`), so their status was previously dropped and the resulting +`RUN_ERROR` event surfaced `code: undefined` — indistinguishable from an +unknown failure. A string `status` (an HTTP reason phrase such as `"Forbidden"` +or a symbolic status such as `"PERMISSION_DENIED"`) is intentionally ignored so +only the numeric HTTP code is forwarded; an explicit `code` still wins. diff --git a/packages/ai/src/activities/error-payload.ts b/packages/ai/src/activities/error-payload.ts index 42b322b01..6cb1d0617 100644 --- a/packages/ai/src/activities/error-payload.ts +++ b/packages/ai/src/activities/error-payload.ts @@ -29,6 +29,28 @@ function normalizeCode(codeField: unknown): string | undefined { return undefined } +// SDK error classes disagree on where they carry the HTTP status. Most expose a +// `code` (OpenAI/Anthropic error bodies), but some report it only as a numeric +// `status` — Google's `@google/genai` `ApiError` sets `status: number` and no +// `code` at all. Without this fallback such errors reach downstream consumers +// with `code: undefined`, so a 401/403/404/429 is indistinguishable from an +// unknown failure and cannot be classified. +// +// Only a *numeric* `status` is used: a string `status` is commonly an HTTP +// reason phrase ("Forbidden") or a symbolic status ("PERMISSION_DENIED"), not +// the numeric code consumers key on, so forwarding it would be misleading. +function extractCode(source: { + code?: unknown + status?: unknown +}): string | undefined { + const fromCode = normalizeCode(source.code) + if (fromCode !== undefined) return fromCode + if (typeof source.status === 'number' && Number.isFinite(source.status)) { + return String(source.status) + } + return undefined +} + export function toRunErrorPayload( error: unknown, fallbackMessage = 'Unknown error occurred', @@ -40,21 +62,19 @@ export function toRunErrorPayload( } } if (error instanceof Error) { - const codeField = (error as Error & { code?: unknown }).code return { message: error.message || fallbackMessage, - code: normalizeCode(codeField), + code: extractCode(error as Error & { code?: unknown; status?: unknown }), } } if (typeof error === 'object' && error !== null) { const messageField = (error as { message?: unknown }).message - const codeField = (error as { code?: unknown }).code return { message: typeof messageField === 'string' && messageField.length > 0 ? messageField : fallbackMessage, - code: normalizeCode(codeField), + code: extractCode(error as { code?: unknown; status?: unknown }), } } if (typeof error === 'string' && error.length > 0) { diff --git a/packages/ai/tests/error-payload.test.ts b/packages/ai/tests/error-payload.test.ts index fb5655fdc..6b1eb1f75 100644 --- a/packages/ai/tests/error-payload.test.ts +++ b/packages/ai/tests/error-payload.test.ts @@ -63,6 +63,67 @@ describe('toRunErrorPayload', () => { }) }) + it('falls back to a numeric `status` when there is no `code`', () => { + // Google's `@google/genai` `ApiError` reports the HTTP status on + // `status: number` and carries no `code`; without the fallback the status + // is lost and the failure cannot be classified downstream. + const err = Object.assign(new Error('http 403'), { status: 403 }) + expect(toRunErrorPayload(err)).toEqual({ + message: 'http 403', + code: '403', + }) + expect(toRunErrorPayload({ message: 'http 429', status: 429 })).toEqual({ + message: 'http 429', + code: '429', + }) + }) + + it('prefers an explicit `code` over `status`', () => { + const err = Object.assign(new Error('conflict'), { + code: 'rate_limit_exceeded', + status: 429, + }) + expect(toRunErrorPayload(err)).toEqual({ + message: 'conflict', + code: 'rate_limit_exceeded', + }) + }) + + it('ignores a non-numeric `status` (reason phrase, not an HTTP code)', () => { + // A string `status` is typically an HTTP reason phrase ("Forbidden") or a + // symbolic status ("PERMISSION_DENIED"), not the numeric code consumers key + // on, so it must not be forwarded as `code`. + expect( + toRunErrorPayload({ message: 'denied', status: 'Forbidden' }), + ).toEqual({ + message: 'denied', + code: undefined, + }) + expect( + toRunErrorPayload({ message: 'denied', status: 'PERMISSION_DENIED' }), + ).toEqual({ + message: 'denied', + code: undefined, + }) + }) + + it('reproduces the @google/genai ApiError shape (status-only, JSON message)', () => { + // Mirrors `class ApiError extends Error { status: number }` from + // `@google/genai`, whose message is the stringified provider body. + const body = JSON.stringify({ + error: { + code: 403, + message: 'Your project has been denied access. Please contact support.', + status: 'PERMISSION_DENIED', + }, + }) + const err = Object.assign(new Error(body), { name: 'ApiError', status: 403 }) + expect(toRunErrorPayload(err)).toEqual({ + message: body, + code: '403', + }) + }) + it('accepts a bare string as a thrown value', () => { expect(toRunErrorPayload('plain string error')).toEqual({ message: 'plain string error',