Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .changeset/run-error-numeric-status.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 24 additions & 4 deletions packages/ai/src/activities/error-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) {
Expand Down
61 changes: 61 additions & 0 deletions packages/ai/tests/error-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down