From f95a2ce28d21b9751dfa069b87ca64805a7cd570 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 12 May 2026 17:14:28 -0700 Subject: [PATCH 1/2] Handle nested AI retry errors --- .../util/__tests__/error-api-details.test.ts | 35 +++ common/src/util/error.ts | 91 ++++++- .../src/__tests__/loop-agent-steps.test.ts | 74 +++++- packages/agent-runtime/src/run-agent-step.ts | 224 +++++++++--------- sdk/src/__tests__/run-cancellation.test.ts | 56 +++++ sdk/src/run.ts | 14 +- 6 files changed, 356 insertions(+), 138 deletions(-) create mode 100644 common/src/util/__tests__/error-api-details.test.ts diff --git a/common/src/util/__tests__/error-api-details.test.ts b/common/src/util/__tests__/error-api-details.test.ts new file mode 100644 index 0000000000..0e0312275b --- /dev/null +++ b/common/src/util/__tests__/error-api-details.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test' + +import { extractApiErrorDetails } from '../error' + +describe('extractApiErrorDetails', () => { + it('extracts structured details from nested retry errors', () => { + const apiError = new Error('Conflict') as Error & { + statusCode: number + responseBody: string + } + apiError.statusCode = 409 + apiError.responseBody = JSON.stringify({ + error: 'session_superseded', + message: + 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', + }) + + const retryError = new Error( + 'Failed after 4 attempts. Last error: Conflict', + ) as Error & { + lastError: unknown + errors: unknown[] + } + retryError.name = 'AI_RetryError' + retryError.lastError = apiError + retryError.errors = [apiError] + + expect(extractApiErrorDetails(retryError)).toEqual({ + statusCode: 409, + errorCode: 'session_superseded', + message: + 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', + }) + }) +}) diff --git a/common/src/util/error.ts b/common/src/util/error.ts index 610ff3208b..0e96665fe2 100644 --- a/common/src/util/error.ts +++ b/common/src/util/error.ts @@ -254,6 +254,93 @@ export function parseApiErrorResponseBody(responseBody: unknown): { } } +export type ApiErrorDetails = ReturnType & { + statusCode?: number +} + +function getApiErrorCandidates( + error: unknown, + seen = new Set(), +): unknown[] { + if (!error || typeof error !== 'object') return [error] + if (seen.has(error)) return [] + seen.add(error) + + const candidates: unknown[] = [error] + const errorWithNested = error as { + lastError?: unknown + errors?: unknown[] + cause?: unknown + } + + candidates.push(...getApiErrorCandidates(errorWithNested.lastError, seen)) + + if (Array.isArray(errorWithNested.errors)) { + for (const nestedError of [...errorWithNested.errors].reverse()) { + candidates.push(...getApiErrorCandidates(nestedError, seen)) + } + } + + candidates.push(...getApiErrorCandidates(errorWithNested.cause, seen)) + + return candidates +} + +function getApiErrorStatusCode(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined + + if ('statusCode' in error) { + const statusCode = (error as { statusCode: unknown }).statusCode + if (typeof statusCode === 'number') return statusCode + } + + if ('status' in error) { + const status = (error as { status: unknown }).status + if (typeof status === 'number') return status + } + + return undefined +} + +function getApiErrorResponseBody(error: unknown): unknown { + if (!error || typeof error !== 'object') return undefined + if (!('responseBody' in error)) return undefined + return (error as { responseBody: unknown }).responseBody +} + +function hasParsedApiErrorDetails( + details: ReturnType, +): boolean { + return ( + details.errorCode !== undefined || + details.message !== undefined || + details.countryCode !== undefined || + details.countryBlockReason !== undefined || + details.ipPrivacySignals !== undefined + ) +} + +/** + * Extracts HTTP status and structured server error fields from API errors, + * including AI SDK RetryError wrappers whose useful APICallError is nested in + * `lastError` / `errors`. + */ +export function extractApiErrorDetails(error: unknown): ApiErrorDetails { + for (const candidate of getApiErrorCandidates(error)) { + const statusCode = getApiErrorStatusCode(candidate) + const parsed = parseApiErrorResponseBody(getApiErrorResponseBody(candidate)) + + if (statusCode !== undefined || hasParsedApiErrorDetails(parsed)) { + return { + ...parsed, + ...(statusCode !== undefined && { statusCode }), + } + } + } + + return {} +} + // Extended error properties that various libraries add to Error objects interface ExtendedErrorProperties { status?: number @@ -330,9 +417,7 @@ export function getErrorObject( ? extError.statusCode : undefined, code: typeof extError.code === 'string' ? extError.code : undefined, - rawError: options.includeRawError - ? safeStringify(error) - : undefined, + rawError: options.includeRawError ? safeStringify(error) : undefined, // API error fields responseBody, url: typeof extError.url === 'string' ? extError.url : undefined, diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 873079f514..74a637c8ef 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -1,9 +1,7 @@ import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' import { createTestAgentRuntimeParams } from '@codebuff/common/testing/fixtures/agent-runtime' -import { - clearMockedModules, -} from '@codebuff/common/testing/mock-modules' +import { clearMockedModules } from '@codebuff/common/testing/mock-modules' import { setupDbSpies } from '@codebuff/common/testing/mocks/database' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { AbortError, promptSuccess } from '@codebuff/common/util/error' @@ -20,7 +18,7 @@ import { mock, spyOn, } from 'bun:test' -import { APICallError } from 'ai' +import { APICallError, RetryError } from 'ai' import { z } from 'zod/v4' import { loopAgentSteps } from '../run-agent-step' @@ -661,13 +659,15 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => // Mock promptAiSdk to capture the n parameter loopAgentStepsBaseParams.promptAiSdk = async (params: any) => { agentStepN = params.n - return promptSuccess(JSON.stringify([ - 'Response 1', - 'Response 2', - 'Response 3', - 'Response 4', - 'Response 5', - ])) + return promptSuccess( + JSON.stringify([ + 'Response 1', + 'Response 2', + 'Response 3', + 'Response 4', + 'Response 5', + ]), + ) } await loopAgentSteps({ @@ -972,7 +972,9 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => expect(result.output.type).toBe('error') if (result.output.type === 'error') { // Should use the server's message, NOT the generic "Forbidden" - expect(result.output.message).toBe('Free mode is not available in your country.') + expect(result.output.message).toBe( + 'Free mode is not available in your country.', + ) // Should NOT have the 'Agent run error: ' prefix since message came from responseBody expect(result.output.message).not.toContain('Agent run error:') // Should propagate the error code so the CLI can match on it @@ -1022,5 +1024,53 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => expect(result.output.error).toBeUndefined() } }) + + it('should unwrap retry errors to propagate underlying 409 gate errors', async () => { + const llmOnlyTemplate = { + ...mockTemplate, + handleSteps: undefined, + } + + const localAgentTemplates = { + 'test-agent': llmOnlyTemplate, + } + + const apiError = new APICallError({ + statusCode: 409, + message: 'Conflict', + url: 'https://api.codebuff.com/v1/chat/completions', + requestBodyValues: {}, + responseBody: JSON.stringify({ + error: 'session_superseded', + message: + 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', + }), + isRetryable: true, + }) + + loopAgentStepsBaseParams.promptAiSdkStream = async function* () { + throw new RetryError({ + message: 'Failed after 4 attempts. Last error: Conflict', + reason: 'maxRetriesExceeded', + errors: [apiError], + }) + } + + const result = await loopAgentSteps({ + ...loopAgentStepsBaseParams, + agentType: 'test-agent', + localAgentTemplates, + }) + + expect(result.output.type).toBe('error') + if (result.output.type === 'error') { + expect(result.output.message).toBe( + 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', + ) + expect(result.output.message).not.toContain('Agent run error:') + expect(result.output.error).toBe('session_superseded') + expect(result.output.statusCode).toBe(409) + } + }) }) }) diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 40c858d632..3184d7ca63 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -3,10 +3,15 @@ import { shouldUseLocalTokenCountForFreebuffDeepseekFlash } from '@codebuff/comm import { supportsCacheControl } from '@codebuff/common/old-constants' import { TOOLS_WHICH_WONT_FORCE_NEXT_STEP } from '@codebuff/common/tools/constants' import { buildArray } from '@codebuff/common/util/array' -import { AbortError, getErrorObject, isAbortError, parseApiErrorResponseBody } from '@codebuff/common/util/error' +import { + AbortError, + extractApiErrorDetails, + getErrorObject, + isAbortError, +} from '@codebuff/common/util/error' import { serializeCacheDebugCorrelation } from '@codebuff/common/util/cache-debug' import { systemMessage, userMessage } from '@codebuff/common/util/messages' -import { APICallError, type ToolSet } from 'ai' +import { type ToolSet } from 'ai' import { cloneDeep, mapValues } from 'lodash' import { CACHE_DEBUG_FULL_LOGGING } from './constants' @@ -41,11 +46,12 @@ import type { FinishAgentRunFn, StartAgentRunFn, } from '@codebuff/common/types/contracts/database' -import type { CacheDebugUsageData, PromptAiSdkFn } from '@codebuff/common/types/contracts/llm' -import type { Logger } from '@codebuff/common/types/contracts/logger' import type { - ParamsExcluding, -} from '@codebuff/common/types/function-params' + CacheDebugUsageData, + PromptAiSdkFn, +} from '@codebuff/common/types/contracts/llm' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { ParamsExcluding } from '@codebuff/common/types/function-params' import type { Message, ToolMessage, @@ -238,14 +244,14 @@ export const runAgentStep = async ( ...expireMessages(agentState.messageHistory, 'agentStep'), stepPrompt && - userMessage({ - content: stepPrompt, - tags: ['STEP_PROMPT'], + userMessage({ + content: stepPrompt, + tags: ['STEP_PROMPT'], - // James: Deprecate the below, only use tags, which are not prescriptive. - timeToLive: 'agentStep' as const, - keepDuringTruncation: true, - }), + // James: Deprecate the below, only use tags, which are not prescriptive. + timeToLive: 'agentStep' as const, + keepDuringTruncation: true, + }), ) agentState.messageHistory = agentMessagesUntruncated @@ -263,7 +269,9 @@ export const runAgentStep = async ( const iterationNum = agentState.messageHistory.length const systemTokens = countTokensJson(system) - let cacheDebugCorrelation: ReturnType | undefined + let cacheDebugCorrelation: + | ReturnType + | undefined if (CACHE_DEBUG_FULL_LOGGING) { try { cacheDebugCorrelation = createCacheDebugSnapshot({ @@ -293,37 +301,35 @@ export const runAgentStep = async ( } } - const onCacheDebugProviderRequestBuilt = - cacheDebugCorrelation - ? ({ + const onCacheDebugProviderRequestBuilt = cacheDebugCorrelation + ? ({ + provider, + rawBody, + normalizedBody, + }: { + provider: string + rawBody: unknown + normalizedBody?: unknown + }) => { + enrichCacheDebugSnapshotWithProviderRequest({ + correlation: cacheDebugCorrelation, provider, rawBody, - normalizedBody, - }: { - provider: string - rawBody: unknown - normalizedBody?: unknown - }) => { - enrichCacheDebugSnapshotWithProviderRequest({ - correlation: cacheDebugCorrelation, - provider, - rawBody, - normalized: normalizedBody ?? rawBody, - logger, - }) - } - : undefined - - const onCacheDebugUsageReceived = - cacheDebugCorrelation - ? (usage: CacheDebugUsageData) => { - enrichCacheDebugSnapshotWithUsage({ - correlation: cacheDebugCorrelation, - usage, - logger, - }) - } - : undefined + normalized: normalizedBody ?? rawBody, + logger, + }) + } + : undefined + + const onCacheDebugUsageReceived = cacheDebugCorrelation + ? (usage: CacheDebugUsageData) => { + enrichCacheDebugSnapshotWithUsage({ + correlation: cacheDebugCorrelation, + usage, + logger, + }) + } + : undefined logger.debug( { @@ -518,7 +524,9 @@ export const runAgentStep = async ( shouldEndTurn, duration: Date.now() - startTime, fullResponse, - finalMessageHistoryWithToolResults: agentState.messageHistory.concat().reverse(), + finalMessageHistoryWithToolResults: agentState.messageHistory + .concat() + .reverse(), toolCalls, toolResults, agentContext, @@ -732,27 +740,27 @@ export async function loopAgentSteps( const agentTools = useParentTools ? {} : await buildAgentToolSet({ - ...params, - spawnableAgents: agentTemplate.spawnableAgents, - agentTemplates: localAgentTemplates, - }) + ...params, + spawnableAgents: agentTemplate.spawnableAgents, + agentTemplates: localAgentTemplates, + }) const tools = useParentTools ? parentTools : await getToolSet({ - toolNames: agentTemplate.toolNames, - additionalToolDefinitions: async () => { - if (!cachedAdditionalToolDefinitions) { - cachedAdditionalToolDefinitions = await additionalToolDefinitions({ - ...params, - agentTemplate, - }) - } - return cachedAdditionalToolDefinitions - }, - agentTools, - skills: fileContext.skills ?? {}, - }) + toolNames: agentTemplate.toolNames, + additionalToolDefinitions: async () => { + if (!cachedAdditionalToolDefinitions) { + cachedAdditionalToolDefinitions = await additionalToolDefinitions({ + ...params, + agentTemplate, + }) + } + return cachedAdditionalToolDefinitions + }, + agentTools, + skills: fileContext.skills ?? {}, + }) const hasUserMessage = Boolean( prompt || @@ -775,25 +783,25 @@ export async function loopAgentSteps( keepDuringTruncation: true, }, prompt && - prompt in additionalSystemPrompts && - userMessage( - withSystemInstructionTags( - additionalSystemPrompts[ - prompt as keyof typeof additionalSystemPrompts - ], + prompt in additionalSystemPrompts && + userMessage( + withSystemInstructionTags( + additionalSystemPrompts[ + prompt as keyof typeof additionalSystemPrompts + ], + ), ), - ), , ], instructionsPrompt && - userMessage({ - content: instructionsPrompt, - tags: ['INSTRUCTIONS_PROMPT'], + userMessage({ + content: instructionsPrompt, + tags: ['INSTRUCTIONS_PROMPT'], - // James: Deprecate the below, only use tags, which are not prescriptive. - keepLastTags: ['INSTRUCTIONS_PROMPT'], - }), + // James: Deprecate the below, only use tags, which are not prescriptive. + keepLastTags: ['INSTRUCTIONS_PROMPT'], + }), ) // Convert tools to a serializable format for context-pruner token counting @@ -860,9 +868,9 @@ export async function loopAgentSteps( const messagesWithStepPrompt = buildArray( ...currentAgentState.messageHistory, stepPrompt && - userMessage({ - content: stepPrompt, - }), + userMessage({ + content: stepPrompt, + }), ) const estimateContextTokensLocally = () => @@ -1071,7 +1079,6 @@ export async function loopAgentSteps( runId, totalSteps, messageHistory: currentAgentState.messageHistory, - }, 'Agent run cancelled by user (abort error)', ) @@ -1109,36 +1116,17 @@ export async function loopAgentSteps( 'Agent execution failed', ) - let errorMessage = '' - let errorCode: string | undefined - let countryCode: string | undefined - let countryBlockReason: string | undefined - let ipPrivacySignals: string[] | undefined - let hasServerMessage = false - if (error instanceof APICallError) { - errorMessage = `${error.message}` - const parsed = parseApiErrorResponseBody(error.responseBody) - if (parsed.errorCode) errorCode = parsed.errorCode - if (parsed.countryCode) countryCode = parsed.countryCode - if (parsed.countryBlockReason) { - countryBlockReason = parsed.countryBlockReason - } - if (parsed.ipPrivacySignals) { - ipPrivacySignals = parsed.ipPrivacySignals - } - if (parsed.message) { - errorMessage = parsed.message - hasServerMessage = true - } - } else { - // Extract clean error message (just the message, not name:message format) - errorMessage = - error instanceof Error - ? error.message + (error.stack ? `\n\n${error.stack}` : '') - : String(error) - } - - const statusCode = (error as { statusCode?: number }).statusCode + const apiErrorDetails = extractApiErrorDetails(error) + const hasServerMessage = apiErrorDetails.message !== undefined + const fallbackMessage = + error instanceof Error + ? error.message + + (apiErrorDetails.statusCode === undefined && error.stack + ? `\n\n${error.stack}` + : '') + : String(error) + const errorMessage = apiErrorDetails.message ?? fallbackMessage + const statusCode = apiErrorDetails.statusCode const status = signal.aborted ? 'cancelled' : 'failed' await finishAgentRun({ @@ -1160,12 +1148,22 @@ export async function loopAgentSteps( agentState: currentAgentState, output: { type: 'error', - message: hasServerMessage ? errorMessage : 'Agent run error: ' + errorMessage, + message: hasServerMessage + ? errorMessage + : 'Agent run error: ' + errorMessage, ...(statusCode !== undefined && { statusCode }), - ...(errorCode !== undefined && { error: errorCode }), - ...(countryCode !== undefined && { countryCode }), - ...(countryBlockReason !== undefined && { countryBlockReason }), - ...(ipPrivacySignals !== undefined && { ipPrivacySignals }), + ...(apiErrorDetails.errorCode !== undefined && { + error: apiErrorDetails.errorCode, + }), + ...(apiErrorDetails.countryCode !== undefined && { + countryCode: apiErrorDetails.countryCode, + }), + ...(apiErrorDetails.countryBlockReason !== undefined && { + countryBlockReason: apiErrorDetails.countryBlockReason, + }), + ...(apiErrorDetails.ipPrivacySignals !== undefined && { + ipPrivacySignals: apiErrorDetails.ipPrivacySignals, + }), }, } } diff --git a/sdk/src/__tests__/run-cancellation.test.ts b/sdk/src/__tests__/run-cancellation.test.ts index 2eba5d3e42..ae45c19f76 100644 --- a/sdk/src/__tests__/run-cancellation.test.ts +++ b/sdk/src/__tests__/run-cancellation.test.ts @@ -4,6 +4,7 @@ import { getInitialSessionState } from '@codebuff/common/types/session-state' import { getStubProjectFileContext } from '@codebuff/common/util/file' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' +import { RetryError } from 'ai' // Type for tool call content blocks in message history interface ToolCallContentBlock { @@ -253,6 +254,61 @@ describe('Run Cancellation Handling', () => { expect(output.ipPrivacySignals).toEqual(['vpn', 'hosting']) }) + it('extracts error code and message from nested AI SDK retry errors', async () => { + spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({ + id: 'user-123', + email: 'test@example.com', + discord_id: null, + stripe_customer_id: null, + banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), + }) + spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) + spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') + spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined) + spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1') + + const apiError = new Error('Conflict') as Error & { + statusCode: number + responseBody: string + } + apiError.statusCode = 409 + apiError.responseBody = JSON.stringify({ + error: 'session_model_mismatch', + message: + 'This session is bound to deepseek; restart freebuff to switch models.', + }) + + spyOn(mainPromptModule, 'callMainPrompt').mockRejectedValue( + new RetryError({ + message: 'Failed after 4 attempts. Last error: Conflict', + reason: 'maxRetriesExceeded', + errors: [apiError], + }), + ) + + const client = new CodebuffClient({ + apiKey: 'test-key', + }) + + const result = await client.run({ + agent: 'base2', + prompt: 'hello', + }) + + const output = result.output as { + type: 'error' + message: string + statusCode?: number + error?: string + } + expect(output.message).toBe( + 'This session is bound to deepseek; restart freebuff to switch models.', + ) + expect(output.statusCode).toBe(409) + expect(output.error).toBe('session_model_mismatch') + }) + it('extracts error code from responseBody for account_suspended 403', async () => { spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({ id: 'user-123', diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 89044ab82b..f5794a7def 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -15,7 +15,7 @@ import { import { toolNames } from '@codebuff/common/tools/constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' import { AgentOutputSchema } from '@codebuff/common/types/session-state' -import { parseApiErrorResponseBody } from '@codebuff/common/util/error' +import { extractApiErrorDetails } from '@codebuff/common/util/error' import { cloneDeep } from 'lodash' import { getErrorStatusCode } from './error-utils' @@ -535,21 +535,15 @@ async function runOnce({ }).catch((error) => { let errorMessage = error instanceof Error ? error.message : String(error ?? '') - const statusCode = getErrorStatusCode(error) - - // Extract structured error details from the API response body - // (e.g., AI SDK's AI_APICallError includes a responseBody with the server's JSON response) - const responseBody = - error && typeof error === 'object' && 'responseBody' in error - ? (error as { responseBody: unknown }).responseBody - : undefined + const apiErrorDetails = extractApiErrorDetails(error) + const statusCode = apiErrorDetails.statusCode ?? getErrorStatusCode(error) const { countryBlockReason, countryCode, errorCode, ipPrivacySignals, message: parsedMessage, - } = parseApiErrorResponseBody(responseBody) + } = apiErrorDetails if (parsedMessage) { errorMessage = parsedMessage } From 2999724eeff2946ef795a243ad8c688ad72416e1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 12 May 2026 17:34:13 -0700 Subject: [PATCH 2/2] Document retry-wrapped API errors --- docs/error-schema.md | 82 ++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/docs/error-schema.md b/docs/error-schema.md index 56a7356546..5b66606844 100644 --- a/docs/error-schema.md +++ b/docs/error-schema.md @@ -16,13 +16,13 @@ The server returns JSON error responses with an HTTP status code. There are two Used for: -| Status | Example message | -|--------|----------------| -| 400 | `"Invalid JSON in request body"` | -| 400 | `"No runId found in request body"` | -| 401 | `"Unauthorized"` | -| 401 | `"Invalid Codebuff API key"` | -| 402 | `"Out of credits. Please add credits at https://codebuff.com/usage. Your free credits reset in 3 hours."` | +| Status | Example message | +| ------ | --------------------------------------------------------------------------------------------------------- | +| 400 | `"Invalid JSON in request body"` | +| 400 | `"No runId found in request body"` | +| 401 | `"Unauthorized"` | +| 401 | `"Invalid Codebuff API key"` | +| 402 | `"Out of credits. Please add credits at https://codebuff.com/usage. Your free credits reset in 3 hours."` | ### Typed errors (error code + message) @@ -32,11 +32,13 @@ Used for: Used for errors that the client needs to identify programmatically: -| Status | `error` code | Example `message` | -|--------|-------------|-------------------| -| 403 | `account_suspended` | `"Your account has been suspended. Please contact support@codebuff.com if you did not expect this."` | -| 403 | `free_mode_unavailable` | `"Free mode is not available in your country."` (Freebuff: `"Freebuff is not available in your country."`) | -| 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."` | +| Status | `error` code | Example `message` | +| ------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 403 | `account_suspended` | `"Your account has been suspended. Please contact support@codebuff.com if you did not expect this."` | +| 403 | `free_mode_unavailable` | `"Free mode is not available in your country."` (Freebuff: `"Freebuff is not available in your country."`) | +| 409 | `session_superseded` | `"Another instance of freebuff has taken over this session. Only one instance per account is allowed."` | +| 409 | `session_model_mismatch` | `"This session is bound to ; restart freebuff to switch models."` | +| 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."` | ### Catch-all server error @@ -65,20 +67,38 @@ AI SDK creates: APICallError { } ``` -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.). +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.). + +Some statuses that the AI SDK considers retryable, including HTTP 409, can be retried and then wrapped in an `AI_RetryError`: + +``` +AI_RetryError { + message: "Failed after 4 attempts. Last error: Conflict", + lastError: APICallError { statusCode: 409, responseBody: "{\"error\":\"session_superseded\",...}" }, + errors: [APICallError, ...] +} +``` + +In this case the structured server response is no longer on the top-level error. It must be recovered from `lastError` or `errors`. ## Client-Side Error Recovery -To recover the server's structured error details, we use `parseApiErrorResponseBody()` from `common/src/util/error.ts`: +To recover the server's structured error details, callers use `extractApiErrorDetails()` from `common/src/util/error.ts`: ```typescript -export function parseApiErrorResponseBody(responseBody: unknown): { +export function extractApiErrorDetails(error: unknown): { + statusCode?: number errorCode?: string message?: string + countryCode?: string + countryBlockReason?: string + ipPrivacySignals?: string[] } ``` -This is called in two places: +`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()`. + +This helper is called in two places: ### 1. Agent Runtime catch block @@ -88,18 +108,17 @@ This is the **primary** error handler. Most API errors are caught here because t ```typescript catch (error) { - if (error instanceof APICallError) { - const parsed = parseApiErrorResponseBody(error.responseBody) - // parsed.errorCode = 'free_mode_unavailable' - // parsed.message = 'Free mode is not available in your country.' - } + const apiErrorDetails = extractApiErrorDetails(error) + // apiErrorDetails.errorCode = 'free_mode_unavailable' + // apiErrorDetails.message = 'Free mode is not available in your country.' + // apiErrorDetails.statusCode = 403 // ... return { output: { type: 'error', message: hasServerMessage ? errorMessage : 'Agent run error: ' + errorMessage, - statusCode, - error: errorCode, // ← machine-readable code for client matching + statusCode: apiErrorDetails.statusCode, + error: apiErrorDetails.errorCode, // ← machine-readable code for client matching }, } } @@ -111,6 +130,8 @@ catch (error) { This is a **fallback** handler for errors that escape the agent runtime (e.g., errors during setup before the agent loop starts). +It also calls `extractApiErrorDetails()` so retry-wrapped setup errors preserve the same `statusCode`, `error`, and `message` fields as agent-loop errors. + ## Error Output Schema **File:** `common/src/types/session-state.ts` @@ -122,7 +143,7 @@ z.object({ type: z.literal('error'), message: z.string(), statusCode: z.number().optional(), - error: z.string().optional(), // machine-readable error code + error: z.string().optional(), // machine-readable error code }) ``` @@ -152,12 +173,13 @@ For all other errors, the raw `output.message` is displayed in the `UserErrorBan │ HTTP 403 │ │ │ │ │ { error, message } │ │ │ │ │────────────────────────▶│ │ │ │ - │ │ APICallError │ │ │ - │ │ .message="Forbidden" │ │ │ + │ │ APICallError or │ │ │ + │ │ AI_RetryError │ │ │ │ │ .responseBody="{...}" │ │ │ + │ │ or .lastError │ │ │ │ │────────────────────────▶│ │ │ - │ │ │ catch (APICallError) │ │ - │ │ │ parseResponseBody() │ │ + │ │ │ catch (error) │ │ + │ │ │ extractApiError...() │ │ │ │ │ extract error code │ │ │ │ │ extract message │ │ │ │ │─────────────────────▶ │ │ @@ -177,6 +199,7 @@ For all other errors, the raw `output.message` is displayed in the `UserErrorBan To add a new error type that the CLI can identify and handle specially: 1. **Server** (`web/src/app/api/v1/chat/completions/_post.ts`): Return a typed error: + ```typescript return NextResponse.json( { 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: ``` 2. **CLI error detection** (`cli/src/utils/error-handling.ts`): Add a checker: + ```typescript export const isYourError = (error: unknown): boolean => { if ( @@ -210,4 +234,4 @@ To add a new error type that the CLI can identify and handle specially: } ``` -No changes needed in the agent runtime or SDK — `parseApiErrorResponseBody` automatically extracts any `error` and `message` fields from the server's response body. +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.