Skip to content

Commit f95a2ce

Browse files
committed
Handle nested AI retry errors
1 parent a5862a5 commit f95a2ce

6 files changed

Lines changed: 356 additions & 138 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,

packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import * as analytics from '@codebuff/common/analytics'
22
import { TEST_USER_ID } from '@codebuff/common/old-constants'
33
import { createTestAgentRuntimeParams } from '@codebuff/common/testing/fixtures/agent-runtime'
4-
import {
5-
clearMockedModules,
6-
} from '@codebuff/common/testing/mock-modules'
4+
import { clearMockedModules } from '@codebuff/common/testing/mock-modules'
75
import { setupDbSpies } from '@codebuff/common/testing/mocks/database'
86
import { getInitialSessionState } from '@codebuff/common/types/session-state'
97
import { AbortError, promptSuccess } from '@codebuff/common/util/error'
@@ -20,7 +18,7 @@ import {
2018
mock,
2119
spyOn,
2220
} from 'bun:test'
23-
import { APICallError } from 'ai'
21+
import { APICallError, RetryError } from 'ai'
2422
import { z } from 'zod/v4'
2523

2624
import { loopAgentSteps } from '../run-agent-step'
@@ -661,13 +659,15 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
661659
// Mock promptAiSdk to capture the n parameter
662660
loopAgentStepsBaseParams.promptAiSdk = async (params: any) => {
663661
agentStepN = params.n
664-
return promptSuccess(JSON.stringify([
665-
'Response 1',
666-
'Response 2',
667-
'Response 3',
668-
'Response 4',
669-
'Response 5',
670-
]))
662+
return promptSuccess(
663+
JSON.stringify([
664+
'Response 1',
665+
'Response 2',
666+
'Response 3',
667+
'Response 4',
668+
'Response 5',
669+
]),
670+
)
671671
}
672672

673673
await loopAgentSteps({
@@ -972,7 +972,9 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
972972
expect(result.output.type).toBe('error')
973973
if (result.output.type === 'error') {
974974
// Should use the server's message, NOT the generic "Forbidden"
975-
expect(result.output.message).toBe('Free mode is not available in your country.')
975+
expect(result.output.message).toBe(
976+
'Free mode is not available in your country.',
977+
)
976978
// Should NOT have the 'Agent run error: ' prefix since message came from responseBody
977979
expect(result.output.message).not.toContain('Agent run error:')
978980
// Should propagate the error code so the CLI can match on it
@@ -1022,5 +1024,53 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
10221024
expect(result.output.error).toBeUndefined()
10231025
}
10241026
})
1027+
1028+
it('should unwrap retry errors to propagate underlying 409 gate errors', async () => {
1029+
const llmOnlyTemplate = {
1030+
...mockTemplate,
1031+
handleSteps: undefined,
1032+
}
1033+
1034+
const localAgentTemplates = {
1035+
'test-agent': llmOnlyTemplate,
1036+
}
1037+
1038+
const apiError = new APICallError({
1039+
statusCode: 409,
1040+
message: 'Conflict',
1041+
url: 'https://api.codebuff.com/v1/chat/completions',
1042+
requestBodyValues: {},
1043+
responseBody: JSON.stringify({
1044+
error: 'session_superseded',
1045+
message:
1046+
'Another instance of freebuff has taken over this session. Only one instance per account is allowed.',
1047+
}),
1048+
isRetryable: true,
1049+
})
1050+
1051+
loopAgentStepsBaseParams.promptAiSdkStream = async function* () {
1052+
throw new RetryError({
1053+
message: 'Failed after 4 attempts. Last error: Conflict',
1054+
reason: 'maxRetriesExceeded',
1055+
errors: [apiError],
1056+
})
1057+
}
1058+
1059+
const result = await loopAgentSteps({
1060+
...loopAgentStepsBaseParams,
1061+
agentType: 'test-agent',
1062+
localAgentTemplates,
1063+
})
1064+
1065+
expect(result.output.type).toBe('error')
1066+
if (result.output.type === 'error') {
1067+
expect(result.output.message).toBe(
1068+
'Another instance of freebuff has taken over this session. Only one instance per account is allowed.',
1069+
)
1070+
expect(result.output.message).not.toContain('Agent run error:')
1071+
expect(result.output.error).toBe('session_superseded')
1072+
expect(result.output.statusCode).toBe(409)
1073+
}
1074+
})
10251075
})
10261076
})

0 commit comments

Comments
 (0)