Skip to content

Commit 67de107

Browse files
authored
Handle nested AI retry errors (#665)
1 parent b5e8086 commit 67de107

7 files changed

Lines changed: 409 additions & 167 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { extractApiErrorDetails } from '../error'
4+
5+
describe('extractApiErrorDetails', () => {
6+
it('extracts structured details from nested retry errors', () => {
7+
const apiError = new Error('Conflict') as Error & {
8+
statusCode: number
9+
responseBody: string
10+
}
11+
apiError.statusCode = 409
12+
apiError.responseBody = JSON.stringify({
13+
error: 'session_superseded',
14+
message:
15+
'Another instance of freebuff has taken over this session. Only one instance per account is allowed.',
16+
})
17+
18+
const retryError = new Error(
19+
'Failed after 4 attempts. Last error: Conflict',
20+
) as Error & {
21+
lastError: unknown
22+
errors: unknown[]
23+
}
24+
retryError.name = 'AI_RetryError'
25+
retryError.lastError = apiError
26+
retryError.errors = [apiError]
27+
28+
expect(extractApiErrorDetails(retryError)).toEqual({
29+
statusCode: 409,
30+
errorCode: 'session_superseded',
31+
message:
32+
'Another instance of freebuff has taken over this session. Only one instance per account is allowed.',
33+
})
34+
})
35+
})

common/src/util/error.ts

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,93 @@ export function parseApiErrorResponseBody(responseBody: unknown): {
254254
}
255255
}
256256

257+
export type ApiErrorDetails = ReturnType<typeof parseApiErrorResponseBody> & {
258+
statusCode?: number
259+
}
260+
261+
function getApiErrorCandidates(
262+
error: unknown,
263+
seen = new Set<object>(),
264+
): unknown[] {
265+
if (!error || typeof error !== 'object') return [error]
266+
if (seen.has(error)) return []
267+
seen.add(error)
268+
269+
const candidates: unknown[] = [error]
270+
const errorWithNested = error as {
271+
lastError?: unknown
272+
errors?: unknown[]
273+
cause?: unknown
274+
}
275+
276+
candidates.push(...getApiErrorCandidates(errorWithNested.lastError, seen))
277+
278+
if (Array.isArray(errorWithNested.errors)) {
279+
for (const nestedError of [...errorWithNested.errors].reverse()) {
280+
candidates.push(...getApiErrorCandidates(nestedError, seen))
281+
}
282+
}
283+
284+
candidates.push(...getApiErrorCandidates(errorWithNested.cause, seen))
285+
286+
return candidates
287+
}
288+
289+
function getApiErrorStatusCode(error: unknown): number | undefined {
290+
if (!error || typeof error !== 'object') return undefined
291+
292+
if ('statusCode' in error) {
293+
const statusCode = (error as { statusCode: unknown }).statusCode
294+
if (typeof statusCode === 'number') return statusCode
295+
}
296+
297+
if ('status' in error) {
298+
const status = (error as { status: unknown }).status
299+
if (typeof status === 'number') return status
300+
}
301+
302+
return undefined
303+
}
304+
305+
function getApiErrorResponseBody(error: unknown): unknown {
306+
if (!error || typeof error !== 'object') return undefined
307+
if (!('responseBody' in error)) return undefined
308+
return (error as { responseBody: unknown }).responseBody
309+
}
310+
311+
function hasParsedApiErrorDetails(
312+
details: ReturnType<typeof parseApiErrorResponseBody>,
313+
): boolean {
314+
return (
315+
details.errorCode !== undefined ||
316+
details.message !== undefined ||
317+
details.countryCode !== undefined ||
318+
details.countryBlockReason !== undefined ||
319+
details.ipPrivacySignals !== undefined
320+
)
321+
}
322+
323+
/**
324+
* Extracts HTTP status and structured server error fields from API errors,
325+
* including AI SDK RetryError wrappers whose useful APICallError is nested in
326+
* `lastError` / `errors`.
327+
*/
328+
export function extractApiErrorDetails(error: unknown): ApiErrorDetails {
329+
for (const candidate of getApiErrorCandidates(error)) {
330+
const statusCode = getApiErrorStatusCode(candidate)
331+
const parsed = parseApiErrorResponseBody(getApiErrorResponseBody(candidate))
332+
333+
if (statusCode !== undefined || hasParsedApiErrorDetails(parsed)) {
334+
return {
335+
...parsed,
336+
...(statusCode !== undefined && { statusCode }),
337+
}
338+
}
339+
}
340+
341+
return {}
342+
}
343+
257344
// Extended error properties that various libraries add to Error objects
258345
interface ExtendedErrorProperties {
259346
status?: number
@@ -330,9 +417,7 @@ export function getErrorObject(
330417
? extError.statusCode
331418
: undefined,
332419
code: typeof extError.code === 'string' ? extError.code : undefined,
333-
rawError: options.includeRawError
334-
? safeStringify(error)
335-
: undefined,
420+
rawError: options.includeRawError ? safeStringify(error) : undefined,
336421
// API error fields
337422
responseBody,
338423
url: typeof extError.url === 'string' ? extError.url : undefined,

docs/error-schema.md

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ The server returns JSON error responses with an HTTP status code. There are two
1616

1717
Used for:
1818

19-
| Status | Example message |
20-
|--------|----------------|
21-
| 400 | `"Invalid JSON in request body"` |
22-
| 400 | `"No runId found in request body"` |
23-
| 401 | `"Unauthorized"` |
24-
| 401 | `"Invalid Codebuff API key"` |
25-
| 402 | `"Out of credits. Please add credits at https://codebuff.com/usage. Your free credits reset in 3 hours."` |
19+
| Status | Example message |
20+
| ------ | --------------------------------------------------------------------------------------------------------- |
21+
| 400 | `"Invalid JSON in request body"` |
22+
| 400 | `"No runId found in request body"` |
23+
| 401 | `"Unauthorized"` |
24+
| 401 | `"Invalid Codebuff API key"` |
25+
| 402 | `"Out of credits. Please add credits at https://codebuff.com/usage. Your free credits reset in 3 hours."` |
2626

2727
### Typed errors (error code + message)
2828

@@ -32,11 +32,13 @@ Used for:
3232

3333
Used for errors that the client needs to identify programmatically:
3434

35-
| Status | `error` code | Example `message` |
36-
|--------|-------------|-------------------|
37-
| 403 | `account_suspended` | `"Your account has been suspended. Please contact support@codebuff.com if you did not expect this."` |
38-
| 403 | `free_mode_unavailable` | `"Free mode is not available in your country."` (Freebuff: `"Freebuff is not available in your country."`) |
39-
| 429 | `rate_limit_exceeded` | `"Subscription weekly limit reached. Your limit resets in 2 hours. Enable 'Continue with credits' in the CLI to use a-la-carte credits."` |
35+
| Status | `error` code | Example `message` |
36+
| ------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
37+
| 403 | `account_suspended` | `"Your account has been suspended. Please contact support@codebuff.com if you did not expect this."` |
38+
| 403 | `free_mode_unavailable` | `"Free mode is not available in your country."` (Freebuff: `"Freebuff is not available in your country."`) |
39+
| 409 | `session_superseded` | `"Another instance of freebuff has taken over this session. Only one instance per account is allowed."` |
40+
| 409 | `session_model_mismatch` | `"This session is bound to <model>; restart freebuff to switch models."` |
41+
| 429 | `rate_limit_exceeded` | `"Subscription weekly limit reached. Your limit resets in 2 hours. Enable 'Continue with credits' in the CLI to use a-la-carte credits."` |
4042

4143
### Catch-all server error
4244

@@ -65,20 +67,38 @@ AI SDK creates: APICallError {
6567
}
6668
```
6769

68-
The server's human-readable `message` and machine-readable `error` code are buried inside `responseBody` as a JSON string. The `APICallError.message` is just the HTTP status text ("Forbidden", "Payment Required", etc.).
70+
The server's human-readable `message` and machine-readable `error` code are buried inside `responseBody` as a JSON string. The `APICallError.message` is often just the HTTP status text ("Forbidden", "Payment Required", "Conflict", etc.).
71+
72+
Some statuses that the AI SDK considers retryable, including HTTP 409, can be retried and then wrapped in an `AI_RetryError`:
73+
74+
```
75+
AI_RetryError {
76+
message: "Failed after 4 attempts. Last error: Conflict",
77+
lastError: APICallError { statusCode: 409, responseBody: "{\"error\":\"session_superseded\",...}" },
78+
errors: [APICallError, ...]
79+
}
80+
```
81+
82+
In this case the structured server response is no longer on the top-level error. It must be recovered from `lastError` or `errors`.
6983

7084
## Client-Side Error Recovery
7185

72-
To recover the server's structured error details, we use `parseApiErrorResponseBody()` from `common/src/util/error.ts`:
86+
To recover the server's structured error details, callers use `extractApiErrorDetails()` from `common/src/util/error.ts`:
7387

7488
```typescript
75-
export function parseApiErrorResponseBody(responseBody: unknown): {
89+
export function extractApiErrorDetails(error: unknown): {
90+
statusCode?: number
7691
errorCode?: string
7792
message?: string
93+
countryCode?: string
94+
countryBlockReason?: string
95+
ipPrivacySignals?: string[]
7896
}
7997
```
8098

81-
This is called in two places:
99+
`extractApiErrorDetails()` checks the top-level error and nested retry wrapper fields (`lastError`, `errors`, and `cause`). For each candidate it extracts `statusCode`/`status` and parses any API `responseBody` with `parseApiErrorResponseBody()`.
100+
101+
This helper is called in two places:
82102

83103
### 1. Agent Runtime catch block
84104

@@ -88,18 +108,17 @@ This is the **primary** error handler. Most API errors are caught here because t
88108

89109
```typescript
90110
catch (error) {
91-
if (error instanceof APICallError) {
92-
const parsed = parseApiErrorResponseBody(error.responseBody)
93-
// parsed.errorCode = 'free_mode_unavailable'
94-
// parsed.message = 'Free mode is not available in your country.'
95-
}
111+
const apiErrorDetails = extractApiErrorDetails(error)
112+
// apiErrorDetails.errorCode = 'free_mode_unavailable'
113+
// apiErrorDetails.message = 'Free mode is not available in your country.'
114+
// apiErrorDetails.statusCode = 403
96115
// ...
97116
return {
98117
output: {
99118
type: 'error',
100119
message: hasServerMessage ? errorMessage : 'Agent run error: ' + errorMessage,
101-
statusCode,
102-
error: errorCode, // ← machine-readable code for client matching
120+
statusCode: apiErrorDetails.statusCode,
121+
error: apiErrorDetails.errorCode, // ← machine-readable code for client matching
103122
},
104123
}
105124
}
@@ -111,6 +130,8 @@ catch (error) {
111130

112131
This is a **fallback** handler for errors that escape the agent runtime (e.g., errors during setup before the agent loop starts).
113132

133+
It also calls `extractApiErrorDetails()` so retry-wrapped setup errors preserve the same `statusCode`, `error`, and `message` fields as agent-loop errors.
134+
114135
## Error Output Schema
115136

116137
**File:** `common/src/types/session-state.ts`
@@ -122,7 +143,7 @@ z.object({
122143
type: z.literal('error'),
123144
message: z.string(),
124145
statusCode: z.number().optional(),
125-
error: z.string().optional(), // machine-readable error code
146+
error: z.string().optional(), // machine-readable error code
126147
})
127148
```
128149

@@ -152,12 +173,13 @@ For all other errors, the raw `output.message` is displayed in the `UserErrorBan
152173
│ HTTP 403 │ │ │ │
153174
│ { error, message } │ │ │ │
154175
│────────────────────────▶│ │ │ │
155-
│ │ APICallError │ │ │
156-
│ │ .message="Forbidden" │ │ │
176+
│ │ APICallError or │ │ │
177+
│ │ AI_RetryError │ │ │
157178
│ │ .responseBody="{...}" │ │ │
179+
│ │ or .lastError │ │ │
158180
│ │────────────────────────▶│ │ │
159-
│ │ │ catch (APICallError) │ │
160-
│ │ │ parseResponseBody() │ │
181+
│ │ │ catch (error) │ │
182+
│ │ │ extractApiError...() │ │
161183
│ │ │ extract error code │ │
162184
│ │ │ extract message │ │
163185
│ │ │─────────────────────▶ │ │
@@ -177,6 +199,7 @@ For all other errors, the raw `output.message` is displayed in the `UserErrorBan
177199
To add a new error type that the CLI can identify and handle specially:
178200

179201
1. **Server** (`web/src/app/api/v1/chat/completions/_post.ts`): Return a typed error:
202+
180203
```typescript
181204
return NextResponse.json(
182205
{ error: 'your_error_code', message: 'User-friendly message.' },
@@ -185,6 +208,7 @@ To add a new error type that the CLI can identify and handle specially:
185208
```
186209

187210
2. **CLI error detection** (`cli/src/utils/error-handling.ts`): Add a checker:
211+
188212
```typescript
189213
export const isYourError = (error: unknown): boolean => {
190214
if (
@@ -210,4 +234,4 @@ To add a new error type that the CLI can identify and handle specially:
210234
}
211235
```
212236

213-
No changes needed in the agent runtime or SDK — `parseApiErrorResponseBody` automatically extracts any `error` and `message` fields from the server's response body.
237+
No changes needed in the agent runtime or SDK — `extractApiErrorDetails()` automatically extracts any `error` and `message` fields from the server's response body, including when the API error is nested inside an AI SDK retry wrapper.

0 commit comments

Comments
 (0)