From 95d2c0198d5a083bfb2de094eb47da4aa49b11d7 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 2 Jan 2026 15:58:43 -0800 Subject: [PATCH 1/6] Use 402 as payment required error code --- cli/src/hooks/helpers/send-message.ts | 31 +++++--------------- cli/src/utils/error-handling.ts | 23 ++------------- common/src/util/error.ts | 6 ++++ packages/agent-runtime/src/run-agent-step.ts | 19 +++++------- 4 files changed, 24 insertions(+), 55 deletions(-) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index fef3219d6..eed878b98 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -3,9 +3,8 @@ import { useChatStore } from '../../state/chat-store' import { processBashContext } from '../../utils/bash-context-processor' import { createErrorMessage, - createPaymentErrorMessage, isOutOfCreditsError, - isPaymentRequiredError, + OUT_OF_CREDITS_MESSAGE, } from '../../utils/error-handling' import { formatElapsedTime } from '../../utils/format-elapsed-time' import { processImagesForMessage } from '../../utils/image-processor' @@ -215,23 +214,11 @@ export const handleRunCompletion = (params: { return } - if (isOutOfCreditsError(output)) { - const { message, showUsageBanner } = createPaymentErrorMessage(output) - updater.setError(message) - - if (showUsageBanner) { - useChatStore.getState().setInputMode('usage') - queryClient.invalidateQueries({ - queryKey: usageQueryKeys.current(), - }) - } - } else { - const partial = createErrorMessage( - output.message ?? 'No output from agent run', - aiMessageId, - ) - updater.setError(partial.content ?? '') - } + const partial = createErrorMessage( + output.message ?? 'No output from agent run', + aiMessageId, + ) + updater.setError(partial.content ?? '') finalizeAfterError() return @@ -302,10 +289,8 @@ export const handleRunError = (params: { updateChainInProgress(false) timerController.stop('error') - if (isPaymentRequiredError(error)) { - const { message } = createPaymentErrorMessage(error) - - updater.setError(message) + if (isOutOfCreditsError(error)) { + updater.setError(OUT_OF_CREDITS_MESSAGE) useChatStore.getState().setInputMode('usage') queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() }) return diff --git a/cli/src/utils/error-handling.ts b/cli/src/utils/error-handling.ts index 4edff61ea..0ba0a4a52 100644 --- a/cli/src/utils/error-handling.ts +++ b/cli/src/utils/error-handling.ts @@ -27,16 +27,12 @@ const extractErrorMessage = (error: unknown, fallback: string): string => { * Standardized on statusCode === 402 for payment required detection. */ export const isOutOfCreditsError = (error: unknown): boolean => { - if (isPaymentRequiredError(error)) { - return true - } - // Check for error output with errorCode property (from agent run results) if ( error && typeof error === 'object' && - 'errorCode' in error && - (error as { errorCode: unknown }).errorCode === ErrorCodes.PAYMENT_REQUIRED + 'statusCode' in error && + error.statusCode === 402 ) { return true } @@ -44,20 +40,7 @@ export const isOutOfCreditsError = (error: unknown): boolean => { return false } -export const createPaymentErrorMessage = ( - error: unknown, -): { - message: string - showUsageBanner: boolean -} => { - const fallback = `Out of credits. Please add credits at ${defaultAppUrl}/usage` - const message = extractErrorMessage(error, fallback) - - return { - message, - showUsageBanner: isOutOfCreditsError(error), - } -} +export const OUT_OF_CREDITS_MESSAGE = `Out of credits. Please add credits at ${defaultAppUrl}/usage` export const createErrorMessage = ( error: unknown, diff --git a/common/src/util/error.ts b/common/src/util/error.ts index fb4e95fc9..ff2179f10 100644 --- a/common/src/util/error.ts +++ b/common/src/util/error.ts @@ -18,6 +18,8 @@ export type ErrorObject = { stack?: string /** Optional numeric HTTP status code, if available */ status?: number + /** Optional numeric HTTP status code, if available */ + statusCode?: number /** Optional machine-friendly error code, if available */ code?: string /** Optional raw error object */ @@ -49,6 +51,10 @@ export function getErrorObject( message: error.message, stack: error.stack, status: typeof anyError.status === 'number' ? anyError.status : undefined, + statusCode: + typeof anyError.statusCode === 'number' + ? anyError.statusCode + : undefined, code: typeof anyError.code === 'string' ? anyError.code : undefined, rawError: options.includeRawError ? JSON.stringify(error, null, 2) diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 197cb7785..71ced13e1 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -917,18 +917,6 @@ export async function loopAgentSteps( 'Agent execution failed', ) - // Re-throw NetworkError and PaymentRequiredError to allow SDK retry wrapper to handle it - if (error instanceof Error && error.name === 'NetworkError') { - throw error - } - - const isPaymentRequired = - (error as { statusCode?: number }).statusCode === 402 - - if (isPaymentRequired) { - throw error - } - let errorMessage = '' if (error instanceof APICallError) { errorMessage = `${error.message}` @@ -951,6 +939,13 @@ export async function loopAgentSteps( errorMessage, }) + const isPaymentRequired = + (error as { statusCode?: number }).statusCode === 402 + + if (isPaymentRequired) { + throw error + } + return { agentState: currentAgentState, output: { From 6a7281704b5f4f6b26effc23cb3112261a20a4d3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 2 Jan 2026 16:08:29 -0800 Subject: [PATCH 2/6] Use ai sdk error codes instead of our own error code schema --- .../integration/api-integration.test.ts | 16 +- cli/src/app.tsx | 12 +- .../helpers/__tests__/send-message.test.ts | 6 +- cli/src/hooks/use-auth-query.ts | 46 ++-- cli/src/utils/create-run-config.ts | 12 +- cli/src/utils/error-handling.ts | 10 +- cli/src/utils/error-messages.ts | 76 ++---- common/src/types/session-state.ts | 2 +- packages/agent-runtime/src/run-agent-step.ts | 9 +- sdk/e2e/utils/get-api-key.ts | 6 +- sdk/src/__tests__/errors.test.ts | 57 ----- sdk/src/__tests__/run-with-retry.test.ts | 8 +- sdk/src/error-utils.ts | 106 ++++++++ sdk/src/errors.ts | 228 ------------------ sdk/src/impl/database.ts | 27 ++- sdk/src/index.ts | 26 +- sdk/src/run.ts | 156 ++++++------ 17 files changed, 300 insertions(+), 503 deletions(-) delete mode 100644 sdk/src/__tests__/errors.test.ts create mode 100644 sdk/src/error-utils.ts delete mode 100644 sdk/src/errors.ts diff --git a/cli/src/__tests__/integration/api-integration.test.ts b/cli/src/__tests__/integration/api-integration.test.ts index f2af505a0..de0ff58a3 100644 --- a/cli/src/__tests__/integration/api-integration.test.ts +++ b/cli/src/__tests__/integration/api-integration.test.ts @@ -1,6 +1,4 @@ import { - AuthenticationError, - NetworkError, getUserInfoFromApiKey, } from '@codebuff/sdk' import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' @@ -143,7 +141,7 @@ describe('API Integration', () => { fields: ['id'], logger: testLogger, }), - ).rejects.toBeInstanceOf(AuthenticationError) + ).rejects.toMatchObject({ statusCode: 401 }) // 401s are now logged as auth failures expect(testLogger.error.mock.calls.length).toBeGreaterThan(0) @@ -161,7 +159,7 @@ describe('API Integration', () => { fields: ['id'], logger: testLogger, }), - ).rejects.toBeInstanceOf(AuthenticationError) + ).rejects.toMatchObject({ statusCode: 401 }) expect(testLogger.error.mock.calls.length).toBeGreaterThan(0) }) @@ -180,7 +178,7 @@ describe('API Integration', () => { fields: ['id'], logger: testLogger, }), - ).rejects.toBeInstanceOf(NetworkError) + ).rejects.toMatchObject({ statusCode: expect.any(Number) }) expect(testLogger.error.mock.calls.length).toBeGreaterThan(0) }) @@ -197,7 +195,7 @@ describe('API Integration', () => { fields: ['id'], logger: testLogger, }), - ).rejects.toBeInstanceOf(NetworkError) + ).rejects.toMatchObject({ statusCode: expect.any(Number) }) expect( testLogger.error.mock.calls.some(([payload]) => @@ -218,7 +216,7 @@ describe('API Integration', () => { fields: ['id'], logger: testLogger, }), - ).rejects.toBeInstanceOf(NetworkError) + ).rejects.toMatchObject({ statusCode: expect.any(Number) }) expect(testLogger.error.mock.calls.length).toBeGreaterThan(0) }) @@ -239,7 +237,7 @@ describe('API Integration', () => { fields: ['id'], logger: testLogger, }), - ).rejects.toBeInstanceOf(NetworkError) + ).rejects.toMatchObject({ statusCode: expect.any(Number) }) expect(fetchMock.mock.calls.length).toBe(1) expect( @@ -263,7 +261,7 @@ describe('API Integration', () => { fields: ['id'], logger: testLogger, }), - ).rejects.toBeInstanceOf(NetworkError) + ).rejects.toMatchObject({ statusCode: expect.any(Number) }) expect(fetchMock.mock.calls.length).toBe(1) expect( diff --git a/cli/src/app.tsx b/cli/src/app.tsx index 427a0117f..ab26eff7b 100644 --- a/cli/src/app.tsx +++ b/cli/src/app.tsx @@ -1,4 +1,4 @@ -import { NetworkError, RETRYABLE_ERROR_CODES } from '@codebuff/sdk' +import { isRetryableStatusCode, getErrorStatusCode } from '@codebuff/sdk' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -213,15 +213,13 @@ export const App = ({ // Derive auth reachability + retrying state inline from authQuery error const authError = authQuery.error - const networkError = - authError && authError instanceof NetworkError ? authError : null - const isRetryableNetworkError = Boolean( - networkError && RETRYABLE_ERROR_CODES.has(networkError.code), - ) + const authErrorStatusCode = authError ? getErrorStatusCode(authError) : undefined + const isRetryableNetworkError = authErrorStatusCode !== undefined && isRetryableStatusCode(authErrorStatusCode) let authStatus: AuthStatus = 'ok' if (authQuery.isError) { - if (!networkError) { + // Only show network status if it's a server/network error (5xx) + if (authErrorStatusCode === undefined || authErrorStatusCode < 500) { authStatus = 'ok' } else if (isRetryableNetworkError) { authStatus = 'retrying' diff --git a/cli/src/hooks/helpers/__tests__/send-message.test.ts b/cli/src/hooks/helpers/__tests__/send-message.test.ts index 3481829ea..3630637c3 100644 --- a/cli/src/hooks/helpers/__tests__/send-message.test.ts +++ b/cli/src/hooks/helpers/__tests__/send-message.test.ts @@ -34,7 +34,7 @@ const { setupStreamingContext, handleRunError } = await import( const { createBatchedMessageUpdater } = await import( '../../../utils/message-updater' ) -const { PaymentRequiredError } = await import('@codebuff/sdk') +import { createPaymentRequiredError } from '@codebuff/sdk' const createMockTimerController = (): SendMessageTimerController & { startCalls: string[] @@ -375,7 +375,7 @@ describe('handleRunError', () => { expect(mockInvalidateQueries).not.toHaveBeenCalled() }) - test('PaymentRequiredError uses setError, invalidates queries, and switches input mode', () => { + test('Payment required error (402) uses setError, invalidates queries, and switches input mode', () => { let messages: ChatMessage[] = [ { id: 'ai-1', @@ -397,7 +397,7 @@ describe('handleRunError', () => { setInputMode: setInputModeMock, }) - const paymentError = new PaymentRequiredError('Out of credits') + const paymentError = createPaymentRequiredError('Out of credits') handleRunError({ error: paymentError, diff --git a/cli/src/hooks/use-auth-query.ts b/cli/src/hooks/use-auth-query.ts index 206334462..d5b47c64d 100644 --- a/cli/src/hooks/use-auth-query.ts +++ b/cli/src/hooks/use-auth-query.ts @@ -2,11 +2,11 @@ import { createHash } from 'crypto' import { getCiEnv } from '@codebuff/common/env-ci' import { - AuthenticationError, - ErrorCodes, getUserInfoFromApiKey as defaultGetUserInfoFromApiKey, - NetworkError, - RETRYABLE_ERROR_CODES, + isRetryableStatusCode, + getErrorStatusCode, + createAuthError, + createServerError, MAX_RETRIES_PER_MESSAGE, RETRY_BACKOFF_BASE_DELAY_MS, } from '@codebuff/sdk' @@ -47,6 +47,14 @@ type ValidatedUserInfo = { email: string } +/** + * Check if an error is an authentication error (401, 403) + */ +function isAuthenticationError(error: unknown): boolean { + const statusCode = getErrorStatusCode(error) + return statusCode === 401 || statusCode === 403 +} + /** * Validates an API key by calling the backend * @@ -69,42 +77,39 @@ export async function validateApiKey({ if (!authResult) { logger.error('❌ API key validation failed - invalid credentials') - throw new AuthenticationError('Invalid API key', 401) + throw createAuthError('Invalid API key') } return authResult } catch (error) { - if (error instanceof AuthenticationError) { + const statusCode = getErrorStatusCode(error) + + if (isAuthenticationError(error)) { logger.error('❌ API key validation failed - authentication error') - // Rethrow the original error to preserve error type for higher layers + // Rethrow the original error to preserve statusCode for higher layers throw error } - if (error instanceof NetworkError) { + if (statusCode !== undefined && isRetryableStatusCode(statusCode)) { logger.error( { - error: error.message, - code: error.code, + error: error instanceof Error ? error.message : String(error), + statusCode, }, '❌ API key validation failed - network error', ) - // Rethrow the original error to preserve error type for higher layers + // Rethrow the original error to preserve statusCode for higher layers throw error } - // Unknown error - wrap in NetworkError for consistency + // Unknown error - wrap with statusCode for consistency logger.error( { error: error instanceof Error ? error.message : String(error), }, '❌ API key validation failed - unknown error', ) - throw new NetworkError( - 'Authentication failed', - ErrorCodes.UNKNOWN_ERROR, - undefined, - error, - ) + throw createServerError('Authentication failed') } } @@ -139,12 +144,13 @@ export function useAuthQuery(deps: UseAuthQueryDeps = {}) { // Retry only for retryable network errors (5xx, timeouts, etc.) // Don't retry authentication errors (invalid credentials) retry: (failureCount, error) => { + const statusCode = getErrorStatusCode(error) // Don't retry authentication errors - user needs to update credentials - if (error instanceof AuthenticationError) { + if (isAuthenticationError(error)) { return false } // Retry network errors if they're retryable and we haven't exceeded max retries - if (error instanceof NetworkError && RETRYABLE_ERROR_CODES.has(error.code)) { + if (statusCode !== undefined && isRetryableStatusCode(statusCode)) { return failureCount < MAX_RETRIES_PER_MESSAGE } // Don't retry other errors diff --git a/cli/src/utils/create-run-config.ts b/cli/src/utils/create-run-config.ts index e43100f6d..9d7145244 100644 --- a/cli/src/utils/create-run-config.ts +++ b/cli/src/utils/create-run-config.ts @@ -30,12 +30,12 @@ export type CreateRunConfigParams = { type RetryArgs = { attempt: number delayMs: number - errorCode?: string + statusCode?: number } type RetryExhaustedArgs = { totalAttempts: number - errorCode?: string + statusCode?: number } export const createRunConfig = (params: CreateRunConfigParams) => { @@ -63,9 +63,9 @@ export const createRunConfig = (params: CreateRunConfigParams) => { maxRetries: MAX_RETRIES_PER_MESSAGE, backoffBaseMs: RETRY_BACKOFF_BASE_DELAY_MS, backoffMaxMs: RETRY_BACKOFF_MAX_DELAY_MS, - onRetry: async ({ attempt, delayMs, errorCode }: RetryArgs) => { + onRetry: async ({ attempt, delayMs, statusCode }: RetryArgs) => { logger.warn( - { sdkAttempt: attempt, delayMs, errorCode }, + { sdkAttempt: attempt, delayMs, statusCode }, 'SDK retrying after error', ) setIsRetrying(true) @@ -73,9 +73,9 @@ export const createRunConfig = (params: CreateRunConfigParams) => { }, onRetryExhausted: async ({ totalAttempts, - errorCode, + statusCode, }: RetryExhaustedArgs) => { - logger.warn({ totalAttempts, errorCode }, 'SDK exhausted all retries') + logger.warn({ totalAttempts, statusCode }, 'SDK exhausted all retries') }, }, agentDefinitions, diff --git a/cli/src/utils/error-handling.ts b/cli/src/utils/error-handling.ts index 0ba0a4a52..a7b19dfe8 100644 --- a/cli/src/utils/error-handling.ts +++ b/cli/src/utils/error-handling.ts @@ -1,5 +1,4 @@ import { env } from '@codebuff/common/env' -import { ErrorCodes, isPaymentRequiredError } from '@codebuff/sdk' import type { ChatMessage } from '../types/chat' @@ -14,7 +13,7 @@ const extractErrorMessage = (error: unknown, fallback: string): string => { return error.message + (error.stack ? `\n\n${error.stack}` : '') } if (error && typeof error === 'object' && 'message' in error) { - const candidate = (error as any).message + const candidate = (error as { message: unknown }).message if (typeof candidate === 'string' && candidate.length > 0) { return candidate } @@ -27,16 +26,14 @@ const extractErrorMessage = (error: unknown, fallback: string): string => { * Standardized on statusCode === 402 for payment required detection. */ export const isOutOfCreditsError = (error: unknown): boolean => { - // Check for error output with errorCode property (from agent run results) if ( error && typeof error === 'object' && 'statusCode' in error && - error.statusCode === 402 + (error as { statusCode: unknown }).statusCode === 402 ) { return true } - return false } @@ -55,6 +52,3 @@ export const createErrorMessage = ( isComplete: true, } } - -// Re-export for convenience in helpers -export { isPaymentRequiredError } diff --git a/cli/src/utils/error-messages.ts b/cli/src/utils/error-messages.ts index c0c78b90c..c883e615e 100644 --- a/cli/src/utils/error-messages.ts +++ b/cli/src/utils/error-messages.ts @@ -1,62 +1,35 @@ -import { - AuthenticationError, - NetworkError, - ErrorCodes, - isErrorWithCode, - sanitizeErrorMessage, -} from '@codebuff/sdk' +import { sanitizeErrorMessage, getErrorStatusCode } from '@codebuff/sdk' /** * Formats an unknown error into a user-facing markdown string. * - * The goal is to provide clear, consistent messaging across the CLI while - * reusing the SDK error typing and sanitization logic. + * The goal is to provide clear, consistent messaging across the CLI. */ export function formatErrorForDisplay(error: unknown, fallbackTitle: string): string { - // Authentication-specific messaging - if (error instanceof AuthenticationError) { - if (error.status === 401) { - return `${fallbackTitle}: Authentication failed. Please check your API key.` - } - - if (error.status === 403) { - return `${fallbackTitle}: Access forbidden. You do not have permission to access this resource.` - } + const statusCode = getErrorStatusCode(error) - return `${fallbackTitle}: Invalid API key. Please check your credentials.` + // Authentication-specific messaging based on statusCode + if (statusCode === 401) { + return `${fallbackTitle}: Authentication failed. Please check your API key.` } - - // Network-specific messaging - if (error instanceof NetworkError) { - let detail: string - - switch (error.code) { - case ErrorCodes.TIMEOUT: - detail = 'Request timed out. Please check your internet connection.' - break - case ErrorCodes.CONNECTION_REFUSED: - detail = 'Connection refused. The server may be down.' - break - case ErrorCodes.DNS_FAILURE: - detail = 'DNS resolution failed. Please check your internet connection.' - break - case ErrorCodes.SERVER_ERROR: - case ErrorCodes.SERVICE_UNAVAILABLE: - detail = 'Server error. Please try again later.' - break - case ErrorCodes.NETWORK_ERROR: - default: - detail = 'Network error. Please check your internet connection.' - break - } - - return `${fallbackTitle}: ${detail}` + if (statusCode === 403) { + return `${fallbackTitle}: Access forbidden. You do not have permission to access this resource.` } - // Any other typed error that exposes a code - if (isErrorWithCode(error)) { - const safeMessage = sanitizeErrorMessage(error) - return `${fallbackTitle}: ${safeMessage}` + // Network/server error messaging based on statusCode + if (statusCode !== undefined) { + if (statusCode === 408) { + return `${fallbackTitle}: Request timed out. Please check your internet connection.` + } + if (statusCode === 503) { + return `${fallbackTitle}: Service unavailable. The server may be down.` + } + if (statusCode >= 500) { + return `${fallbackTitle}: Server error. Please try again later.` + } + if (statusCode === 429) { + return `${fallbackTitle}: Rate limited. Please try again later.` + } } // Generic Error instance @@ -65,8 +38,9 @@ export function formatErrorForDisplay(error: unknown, fallbackTitle: string): st return `${fallbackTitle}: ${message}` } - // Fallback for unknown values - return `${fallbackTitle}: ${String(error)}` + // Try sanitizeErrorMessage for other cases + const safeMessage = sanitizeErrorMessage(error) + return `${fallbackTitle}: ${safeMessage}` } /** diff --git a/common/src/types/session-state.ts b/common/src/types/session-state.ts index d3f61ab17..08f9aa2a7 100644 --- a/common/src/types/session-state.ts +++ b/common/src/types/session-state.ts @@ -62,7 +62,7 @@ export const AgentOutputSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('error'), message: z.string(), - errorCode: z.string().optional(), + statusCode: z.number().optional(), }), ]) export type AgentOutput = z.infer diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 71ced13e1..4d2b1e144 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -928,6 +928,8 @@ export async function loopAgentSteps( : String(error) } + const statusCode = (error as { statusCode?: number }).statusCode + const status = checkLiveUserInput(params) ? 'failed' : 'cancelled' await finishAgentRun({ ...params, @@ -939,10 +941,8 @@ export async function loopAgentSteps( errorMessage, }) - const isPaymentRequired = - (error as { statusCode?: number }).statusCode === 402 - - if (isPaymentRequired) { + // Payment required errors (402) should propagate + if (statusCode === 402) { throw error } @@ -951,6 +951,7 @@ export async function loopAgentSteps( output: { type: 'error', message: 'Agent run error: ' + errorMessage, + ...(statusCode !== undefined && { statusCode }), }, } } diff --git a/sdk/e2e/utils/get-api-key.ts b/sdk/e2e/utils/get-api-key.ts index 0c4f89db4..dca63ce66 100644 --- a/sdk/e2e/utils/get-api-key.ts +++ b/sdk/e2e/utils/get-api-key.ts @@ -52,9 +52,11 @@ export function isAuthError(output: { export function isNetworkError(output: { type: string message?: string - errorCode?: string + statusCode?: number }): boolean { if (output.type !== 'error') return false const msg = output.message?.toLowerCase() ?? '' - return output.errorCode === 'NETWORK_ERROR' || msg.includes('network error') + // Check for 5xx status codes or network-related messages + const isServerError = output.statusCode !== undefined && output.statusCode >= 500 + return isServerError || msg.includes('network error') } diff --git a/sdk/src/__tests__/errors.test.ts b/sdk/src/__tests__/errors.test.ts deleted file mode 100644 index 8dfa7b57a..000000000 --- a/sdk/src/__tests__/errors.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, test, expect } from 'bun:test' - -import { - ErrorCodes, - PaymentRequiredError, - isPaymentRequiredError, - sanitizeErrorMessage, -} from '../errors' - -describe('PaymentRequiredError', () => { - test('has correct error code and status', () => { - const error = new PaymentRequiredError('Insufficient credits') - expect(error.code).toBe(ErrorCodes.PAYMENT_REQUIRED) - expect(error.status).toBe(402) - expect(error.name).toBe('PaymentRequiredError') - }) - - test('preserves the error message', () => { - const message = 'Custom payment required message.' - const error = new PaymentRequiredError(message) - expect(error.message).toBe(message) - }) -}) - -describe('isPaymentRequiredError', () => { - test('returns true for PaymentRequiredError', () => { - const error = new PaymentRequiredError('test') - expect(isPaymentRequiredError(error)).toBe(true) - }) - - test('returns false for other errors', () => { - expect(isPaymentRequiredError(new Error('test'))).toBe(false) - expect(isPaymentRequiredError(null)).toBe(false) - expect(isPaymentRequiredError(undefined)).toBe(false) - expect(isPaymentRequiredError({ code: 'PAYMENT_REQUIRED' })).toBe(false) - }) -}) - -describe('sanitizeErrorMessage', () => { - test('returns original message for PaymentRequiredError', () => { - const message = 'Payment required for this request.' - const error = new PaymentRequiredError(message) - expect(sanitizeErrorMessage(error)).toBe(message) - }) -}) - -describe('error detection patterns', () => { - test('detects out of credits in error message', () => { - const serverMessage = 'You are OUT OF CREDITS right now.' - expect(serverMessage.toLowerCase().includes('out of credits')).toBe(true) - }) - - test('detects 402 in error message', () => { - const errorWithCode = 'Error from AI SDK: 402 Payment Required' - expect(errorWithCode.includes('402')).toBe(true) - }) -}) diff --git a/sdk/src/__tests__/run-with-retry.test.ts b/sdk/src/__tests__/run-with-retry.test.ts index 87acdb98d..bb8314d28 100644 --- a/sdk/src/__tests__/run-with-retry.test.ts +++ b/sdk/src/__tests__/run-with-retry.test.ts @@ -1,7 +1,7 @@ import { assistantMessage } from '@codebuff/common/util/messages' import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' -import { ErrorCodes } from '../errors' + import { run } from '../run' import * as runModule from '../run' @@ -134,7 +134,7 @@ describe('run retry wrapper', () => { retry: { backoffBaseMs: 1, backoffMaxMs: 2, - retryableErrorCodes: new Set([ErrorCodes.SERVER_ERROR]), + retryableStatusCodes: new Set([500]), // SERVER_ERROR }, }) @@ -188,7 +188,7 @@ describe('run retry wrapper', () => { expect(onRetryCalls).toHaveLength(1) expect(onRetryCalls[0].attempt).toBe(1) expect(onRetryCalls[0].delayMs).toBe(1) - expect(onRetryCalls[0].errorCode).toBe('SERVICE_UNAVAILABLE') + expect(onRetryCalls[0].statusCode).toBe(503) }) it('calls onRetryExhausted after all retries fail', async () => { @@ -211,7 +211,7 @@ describe('run retry wrapper', () => { expect(onRetryExhaustedCalls).toHaveLength(1) expect(onRetryExhaustedCalls[0].totalAttempts).toBe(3) // Initial + 2 retries - expect(onRetryExhaustedCalls[0].errorCode).toBe('TIMEOUT') + expect(onRetryExhaustedCalls[0].statusCode).toBe(408) }) it('returns error output without sessionState on first attempt failure', async () => { diff --git a/sdk/src/error-utils.ts b/sdk/src/error-utils.ts new file mode 100644 index 000000000..f2e9ec84b --- /dev/null +++ b/sdk/src/error-utils.ts @@ -0,0 +1,106 @@ +/** + * SDK Error Utilities + * + * Simple utilities for error handling based on HTTP status codes. + * Uses the AI SDK's error types which include statusCode property. + */ + +/** + * Error type with statusCode property + */ +export type HttpError = Error & { statusCode: number } + +/** + * HTTP status codes that should trigger automatic retry + */ +export const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]) + +// ============================================================================ +// Error Factory Functions +// ============================================================================ + +/** + * Creates an Error with a statusCode property + */ +export function createHttpError(message: string, statusCode: number): HttpError { + const error = new Error(message) as HttpError + error.statusCode = statusCode + return error +} + +/** + * Creates an authentication error (401) + */ +export function createAuthError(message = 'Authentication failed'): HttpError { + return createHttpError(message, 401) +} + +/** + * Creates a forbidden error (403) + */ +export function createForbiddenError(message = 'Access forbidden'): HttpError { + return createHttpError(message, 403) +} + +/** + * Creates a payment required error (402) + */ +export function createPaymentRequiredError(message = 'Payment required'): HttpError { + return createHttpError(message, 402) +} + +/** + * Creates a server error (500 by default, or custom 5xx) + */ +export function createServerError(message = 'Server error', statusCode = 500): HttpError { + return createHttpError(message, statusCode) +} + +/** + * Creates a network error (503 - service unavailable) + * Used for connection failures, DNS errors, timeouts, etc. + */ +export function createNetworkError(message = 'Network error'): HttpError { + return createHttpError(message, 503) +} + +/** + * Checks if an HTTP status code is retryable + */ +export function isRetryableStatusCode(statusCode: number | undefined): boolean { + if (statusCode === undefined) return false + return RETRYABLE_STATUS_CODES.has(statusCode) +} + +/** + * Extracts the statusCode from an error if available + */ +export function getErrorStatusCode(error: unknown): number | undefined { + if (error && typeof error === 'object' && 'statusCode' in error) { + const statusCode = (error as { statusCode: unknown }).statusCode + if (typeof statusCode === 'number') { + return statusCode + } + } + return undefined +} + +/** + * Sanitizes error messages for display + * Removes sensitive information and formats for user consumption + */ +export function sanitizeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + if (typeof error === 'string') { + return error + } + if (error && typeof error === 'object' && 'message' in error) { + const message = (error as { message: unknown }).message + if (typeof message === 'string') { + return message + } + } + return String(error) +} diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts deleted file mode 100644 index f320f2ed0..000000000 --- a/sdk/src/errors.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * SDK Error Types and Utilities - * - * This module defines typed errors for the Codebuff SDK, including: - * - AuthenticationError: 401/403 responses indicating invalid credentials - * - NetworkError: Network failures, timeouts, 5xx errors - * - Error codes for categorizing failures - * - Type guards for runtime error checking - * - Utilities for sanitizing error messages - * - * @example - * ```typescript - * import { AuthenticationError, isNetworkError, RETRYABLE_ERROR_CODES } from '@codebuff/sdk' - * - * try { - * await getUserInfoFromApiKey({ apiKey, fields, logger }) - * } catch (error) { - * if (isAuthenticationError(error)) { - * // Show login modal - * } else if (isNetworkError(error)) { - * // Show network error, schedule retry - * } - * } - * ``` - */ - -/** - * Error codes for categorizing SDK errors - */ -export const ErrorCodes = { - // Authentication errors (401, 403) - AUTHENTICATION_FAILED: 'AUTHENTICATION_FAILED', - INVALID_API_KEY: 'INVALID_API_KEY', - FORBIDDEN: 'FORBIDDEN', - - // Payment errors (402) - PAYMENT_REQUIRED: 'PAYMENT_REQUIRED', - - // Network errors (timeouts, DNS failures, connection refused) - NETWORK_ERROR: 'NETWORK_ERROR', - TIMEOUT: 'TIMEOUT', - CONNECTION_REFUSED: 'CONNECTION_REFUSED', - DNS_FAILURE: 'DNS_FAILURE', - - // Server errors (5xx) - SERVER_ERROR: 'SERVER_ERROR', - SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', - - // Client errors (4xx, excluding auth) - BAD_REQUEST: 'BAD_REQUEST', - NOT_FOUND: 'NOT_FOUND', - - // Other errors - UNKNOWN_ERROR: 'UNKNOWN_ERROR', -} as const - -export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes] - -/** - * Error codes that should trigger automatic retry - */ -export const RETRYABLE_ERROR_CODES = new Set([ - ErrorCodes.NETWORK_ERROR, - ErrorCodes.TIMEOUT, - ErrorCodes.CONNECTION_REFUSED, - ErrorCodes.DNS_FAILURE, - ErrorCodes.SERVER_ERROR, - ErrorCodes.SERVICE_UNAVAILABLE, -]) - -/** - * Authentication error class - * Thrown when API returns 401 or 403 status codes - */ -export class AuthenticationError extends Error { - public readonly code: ErrorCode - public readonly status: number - - constructor(message: string, status: number) { - super(message) - this.name = 'AuthenticationError' - this.status = status - - if (status === 401) { - this.code = ErrorCodes.AUTHENTICATION_FAILED - } else if (status === 403) { - this.code = ErrorCodes.FORBIDDEN - } else { - this.code = ErrorCodes.INVALID_API_KEY - } - - // Maintains proper stack trace for where error was thrown (V8 engines only) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, AuthenticationError) - } - } -} - -/** - * Payment required error class - * Thrown when API returns 402 status code (insufficient credits) - */ -export class PaymentRequiredError extends Error { - public readonly code = ErrorCodes.PAYMENT_REQUIRED - public readonly status = 402 - - constructor(message: string) { - super(message) - this.name = 'PaymentRequiredError' - - if (Error.captureStackTrace) { - Error.captureStackTrace(this, PaymentRequiredError) - } - } -} - -/** - * Network error class - * Thrown for network failures, timeouts, and server errors (5xx) - */ -export class NetworkError extends Error { - public readonly code: ErrorCode - public readonly status?: number - public readonly originalError?: unknown - - constructor(message: string, code: ErrorCode, status?: number, originalError?: unknown) { - super(message) - this.name = 'NetworkError' - this.code = code - this.status = status - this.originalError = originalError - - // Maintains proper stack trace for where error was thrown (V8 engines only) - if (Error.captureStackTrace) { - Error.captureStackTrace(this, NetworkError) - } - } -} - -/** - * Type guard to check if an error is an AuthenticationError - */ -export function isAuthenticationError(error: unknown): error is AuthenticationError { - return error instanceof AuthenticationError -} - -/** - * Type guard to check if an error is a PaymentRequiredError or an AI SDK APICallError with 402 status - */ -export function isPaymentRequiredError(error: unknown): error is PaymentRequiredError { - // Check for our custom PaymentRequiredError - if (error instanceof PaymentRequiredError) { - return true - } - - // Check for AI SDK's APICallError with 402 status code - // Use duck typing since we can't import APICallError directly - if ( - error && - typeof error === 'object' && - 'statusCode' in error && - (error as { statusCode: unknown }).statusCode === 402 - ) { - return true - } - - return false -} - -/** - * Type guard to check if an error is a NetworkError - */ -export function isNetworkError(error: unknown): error is NetworkError { - return error instanceof NetworkError -} - -/** - * Type guard to check if an error has an error code property - */ -export function isErrorWithCode(error: unknown): error is { code: ErrorCode } { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - typeof (error as any).code === 'string' - ) -} - -/** - * Sanitizes error messages for display - * Removes sensitive information and formats for user consumption - */ -export function sanitizeErrorMessage(error: unknown): string { - if (isAuthenticationError(error)) { - if (error.status === 401) { - return 'Authentication failed. Please check your API key.' - } else if (error.status === 403) { - return 'Access forbidden. You do not have permission to access this resource.' - } - return 'Invalid API key. Please check your credentials.' - } - - if (isPaymentRequiredError(error)) { - return error.message - } - - if (isNetworkError(error)) { - switch (error.code) { - case ErrorCodes.TIMEOUT: - return 'Request timed out. Please check your internet connection.' - case ErrorCodes.CONNECTION_REFUSED: - return 'Connection refused. The server may be down.' - case ErrorCodes.DNS_FAILURE: - return 'DNS resolution failed. Please check your internet connection.' - case ErrorCodes.SERVER_ERROR: - case ErrorCodes.SERVICE_UNAVAILABLE: - return 'Server error. Please try again later.' - default: - return 'Network error. Please check your internet connection.' - } - } - - if (error instanceof Error) { - return error.message - } - - return String(error) -} diff --git a/sdk/src/impl/database.ts b/sdk/src/impl/database.ts index 5fb0ed907..a2f592b3a 100644 --- a/sdk/src/impl/database.ts +++ b/sdk/src/impl/database.ts @@ -4,7 +4,12 @@ import { getErrorObject } from '@codebuff/common/util/error' import z from 'zod/v4' import { WEBSITE_URL } from '../constants' -import { AuthenticationError, ErrorCodes, NetworkError } from '../errors' +import { + createAuthError, + createNetworkError, + createServerError, + createHttpError, +} from '../error-utils' import type { AddAgentStepFn, @@ -39,7 +44,7 @@ export async function getUserInfoFromApiKey( const cached = userInfoCache[apiKey] if (cached === null) { - throw new AuthenticationError('Authentication failed', 401) + throw createAuthError() } if ( cached && @@ -77,7 +82,7 @@ export async function getUserInfoFromApiKey( 'getUserInfoFromApiKey network error', ) // Network-level failure: DNS, connection refused, timeout, etc. - throw new NetworkError('Network request failed', ErrorCodes.NETWORK_ERROR, undefined, error) + throw createNetworkError('Network request failed') } if (response.status === 401 || response.status === 403 || response.status === 404) { @@ -89,7 +94,7 @@ export async function getUserInfoFromApiKey( delete userInfoCache[apiKey] // If the server returns 404 for invalid credentials, surface as 401 to callers const normalizedStatus = response.status === 404 ? 401 : response.status - throw new AuthenticationError('Authentication failed', normalizedStatus) + throw createHttpError('Authentication failed', normalizedStatus) } if (response.status >= 500 && response.status <= 599) { @@ -97,11 +102,7 @@ export async function getUserInfoFromApiKey( { apiKey, fields, status: response.status }, 'getUserInfoFromApiKey server error', ) - throw new NetworkError( - 'Server error', - response.status === 503 ? ErrorCodes.SERVICE_UNAVAILABLE : ErrorCodes.SERVER_ERROR, - response.status, - ) + throw createServerError('Server error', response.status) } if (!response.ok) { @@ -109,7 +110,7 @@ export async function getUserInfoFromApiKey( { apiKey, fields, status: response.status }, 'getUserInfoFromApiKey request failed', ) - throw new NetworkError('Request failed', ErrorCodes.UNKNOWN_ERROR, response.status) + throw createHttpError('Request failed', response.status) } const cachedBeforeMerge = userInfoCache[apiKey] @@ -124,12 +125,12 @@ export async function getUserInfoFromApiKey( { error: getErrorObject(error), apiKey, fields }, 'getUserInfoFromApiKey JSON parse error', ) - throw new NetworkError('Failed to parse response', ErrorCodes.UNKNOWN_ERROR, response.status, error) + throw createHttpError('Failed to parse response', response.status) } const userInfo = userInfoCache[apiKey] if (userInfo === null) { - throw new AuthenticationError('Authentication failed', 401) + throw createAuthError() } if ( !userInfo || @@ -141,7 +142,7 @@ export async function getUserInfoFromApiKey( { apiKey, fields }, 'getUserInfoFromApiKey: response missing required fields', ) - throw new NetworkError('Request failed', ErrorCodes.UNKNOWN_ERROR, response.status) + throw createHttpError('Request failed', response.status) } return Object.fromEntries( fields.map((field) => [field, userInfo[field]]), diff --git a/sdk/src/index.ts b/sdk/src/index.ts index f07c1bd3e..9b7cbf4c0 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -6,7 +6,7 @@ export type { TextPart, ImagePart, } from '@codebuff/common/types/messages/content-part' -export { run, getRetryableErrorCode } from './run' +export { run, getRetryableStatusCode } from './run' export type { RunOptions, RetryOptions, @@ -44,20 +44,20 @@ export type { export { validateAgents } from './validate-agents' export type { ValidationResult, ValidateAgentsOptions } from './validate-agents' -// Error types and utilities +// Error utilities export { - ErrorCodes, - RETRYABLE_ERROR_CODES, - AuthenticationError, - PaymentRequiredError, - NetworkError, - isAuthenticationError, - isPaymentRequiredError, - isNetworkError, - isErrorWithCode, + isRetryableStatusCode, + getErrorStatusCode, sanitizeErrorMessage, -} from './errors' -export type { ErrorCode } from './errors' + RETRYABLE_STATUS_CODES, + createHttpError, + createAuthError, + createForbiddenError, + createPaymentRequiredError, + createServerError, + createNetworkError, +} from './error-utils' +export type { HttpError } from './error-utils' // Retry configuration constants export { diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 1ae6b994d..04b560b64 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -15,13 +15,11 @@ import { getErrorObject } from '@codebuff/common/util/error' import { cloneDeep } from 'lodash' import { - RETRYABLE_ERROR_CODES, - isNetworkError, - isPaymentRequiredError, - ErrorCodes, - NetworkError, + getErrorStatusCode, sanitizeErrorMessage, -} from './errors' + RETRYABLE_STATUS_CODES, + createHttpError, +} from './error-utils' import { getAgentRuntimeImpl } from './impl/agent-runtime' import { getUserInfoFromApiKey } from './impl/database' import { @@ -38,7 +36,6 @@ import { getFiles } from './tools/read-files' import { runTerminalCommand } from './tools/run-terminal-command' import type { CustomToolDefinition } from './custom-tool' -import type { ErrorCode } from './errors' import type { RunState } from './run-state' import type { ServerAction } from '@codebuff/common/actions' import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition' @@ -141,10 +138,10 @@ export type RetryOptions = { */ backoffMaxMs?: number /** - * Error codes that should trigger retry. - * Defaults to RETRYABLE_ERROR_CODES. + * HTTP status codes that should trigger retry. + * Defaults to RETRYABLE_STATUS_CODES (408, 429, 5xx). */ - retryableErrorCodes?: Set + retryableStatusCodes?: Set /** * Optional callback invoked before each retry attempt. */ @@ -152,7 +149,7 @@ export type RetryOptions = { attempt: number error: unknown delayMs: number - errorCode?: ErrorCode + statusCode?: number }) => void | Promise /** * Optional callback invoked when all SDK retries are exhausted. @@ -161,7 +158,7 @@ export type RetryOptions = { onRetryExhausted?: (params: { totalAttempts: number error: unknown - errorCode?: ErrorCode + statusCode?: number }) => void | Promise } @@ -195,17 +192,17 @@ type NormalizedRetryOptions = { maxRetries: number backoffBaseMs: number backoffMaxMs: number - retryableErrorCodes: Set + retryableStatusCodes: Set onRetry?: (params: { attempt: number error: unknown delayMs: number - errorCode?: ErrorCode + statusCode?: number }) => void | Promise onRetryExhausted?: (params: { totalAttempts: number error: unknown - errorCode?: ErrorCode + statusCode?: number }) => void | Promise } @@ -213,7 +210,7 @@ const defaultRetryOptions: NormalizedRetryOptions = { maxRetries: MAX_RETRIES_PER_MESSAGE, backoffBaseMs: RETRY_BACKOFF_BASE_DELAY_MS, backoffMaxMs: RETRY_BACKOFF_MAX_DELAY_MS, - retryableErrorCodes: RETRYABLE_ERROR_CODES, + retryableStatusCodes: RETRYABLE_STATUS_CODES, } const createAbortError = (signal?: AbortSignal) => { @@ -226,10 +223,14 @@ const createAbortError = (signal?: AbortSignal) => { } /** - * Checks if an error should trigger a retry attempt. + * Checks if an error should trigger a retry attempt based on statusCode. */ -const isRetryableError = (error: unknown): boolean => { - return isNetworkError(error) && RETRYABLE_ERROR_CODES.has(error.code) +const isRetryableError = ( + error: unknown, + retryableStatusCodes: Set, +): boolean => { + const statusCode = getErrorStatusCode(error) + return statusCode !== undefined && retryableStatusCodes.has(statusCode) } const normalizeRetryOptions = ( @@ -245,8 +246,8 @@ const normalizeRetryOptions = ( maxRetries: retry.maxRetries ?? defaultRetryOptions.maxRetries, backoffBaseMs: retry.backoffBaseMs ?? defaultRetryOptions.backoffBaseMs, backoffMaxMs: retry.backoffMaxMs ?? defaultRetryOptions.backoffMaxMs, - retryableErrorCodes: - retry.retryableErrorCodes ?? defaultRetryOptions.retryableErrorCodes, + retryableStatusCodes: + retry.retryableStatusCodes ?? defaultRetryOptions.retryableStatusCodes, onRetry: retry.onRetry, onRetryExhausted: retry.onRetryExhausted, } @@ -323,11 +324,13 @@ export async function run(options: RunExecutionOptions): Promise { // Check if result contains a retryable error in the output if (result.output.type === 'error') { - const retryableCode = getRetryableErrorCode(result.output.message) + const statusCode = + result.output.statusCode ?? + getRetryableStatusCode(result.output.message) const canRetry = - retryableCode && + statusCode !== undefined && attemptIndex < retryOptions.maxRetries && - retryOptions.retryableErrorCodes.has(retryableCode) + retryOptions.retryableStatusCodes.has(statusCode) if (canRetry) { // Treat this as a retryable error - continue retry loop @@ -343,7 +346,7 @@ export async function run(options: RunExecutionOptions): Promise { attempt: attemptIndex + 1, maxRetries: retryOptions.maxRetries, delayMs, - errorCode: retryableCode, + statusCode, errorMessage: result.output.message, }, 'SDK retrying after error', @@ -354,7 +357,7 @@ export async function run(options: RunExecutionOptions): Promise { attempt: attemptIndex + 1, error: new Error(result.output.message), delayMs, - errorCode: retryableCode, + statusCode, }) await waitWithAbort(delayMs, signal) @@ -367,7 +370,7 @@ export async function run(options: RunExecutionOptions): Promise { { attemptIndex, totalAttempts: attemptIndex + 1, - errorCode: retryableCode, + statusCode, }, 'SDK exhausted all retries', ) @@ -376,7 +379,7 @@ export async function run(options: RunExecutionOptions): Promise { await retryOptions.onRetryExhausted?.({ totalAttempts: attemptIndex + 1, error: new Error(result.output.message), - errorCode: retryableCode ?? undefined, + statusCode, }) } } @@ -406,23 +409,19 @@ export async function run(options: RunExecutionOptions): Promise { // Unexpected exception - convert to error output and check if retryable // Use sanitizeErrorMessage to get clean user-facing message without stack traces const errorMessage = sanitizeErrorMessage(error) - const errorCode = isNetworkError(error) - ? error.code - : isPaymentRequiredError(error) - ? error.code - : undefined - const retryableCode = errorCode ?? getRetryableErrorCode(errorMessage) + const statusCode = + getErrorStatusCode(error) ?? getRetryableStatusCode(errorMessage) const canRetry = - retryableCode && + statusCode !== undefined && attemptIndex < retryOptions.maxRetries && - retryOptions.retryableErrorCodes.has(retryableCode) + retryOptions.retryableStatusCodes.has(statusCode) if (rest.logger) { rest.logger.error( { attemptIndex, - errorCode: retryableCode, + statusCode, canRetry, error: errorMessage, }, @@ -448,7 +447,7 @@ export async function run(options: RunExecutionOptions): Promise { output: { type: 'error', message: errorMessage, - ...(errorCode && { errorCode }), + ...(statusCode !== undefined && { statusCode }), }, } } @@ -465,7 +464,7 @@ export async function run(options: RunExecutionOptions): Promise { attempt: attemptIndex + 1, maxRetries: retryOptions.maxRetries, delayMs, - errorCode: retryableCode, + statusCode, errorMessage, }, 'SDK retrying after unexpected exception', @@ -476,7 +475,7 @@ export async function run(options: RunExecutionOptions): Promise { attempt: attemptIndex + 1, error: error instanceof Error ? error : new Error(errorMessage), delayMs, - errorCode: retryableCode, + statusCode, }) await waitWithAbort(delayMs, signal) @@ -806,18 +805,17 @@ export async function runOnce({ userId, signal: signal ?? new AbortController().signal, }).catch((error) => { - // Let retryable errors and PaymentRequiredError propagate so the retry wrapper can handle them - const isRetryable = isRetryableError(error) - const isPaymentRequired = isPaymentRequiredError(error) + // Let retryable errors and payment errors propagate so the retry wrapper can handle them + const statusCode = getErrorStatusCode(error) + const isRetryable = isRetryableError( + error, + defaultRetryOptions.retryableStatusCodes, + ) + const isPaymentRequired = statusCode === 402 logger?.warn( { - isNetworkError: isNetworkError(error), + statusCode, isPaymentRequired, - errorCode: isNetworkError(error) - ? error.code - : isPaymentRequired - ? error.code - : undefined, isRetryable, error: getErrorObject(error), }, @@ -825,7 +823,7 @@ export async function runOnce({ ) if (isRetryable || isPaymentRequired) { - // Reject the promise so the retry wrapper can catch it and include the error code + // Reject the promise so the retry wrapper can catch it and include the statusCode reject(error) return } @@ -981,12 +979,12 @@ async function handleToolCall({ } /** - * Extracts an error code from a prompt error message. - * Returns the appropriate ErrorCode if the error is retryable, null otherwise. + * Extracts an HTTP status code from a prompt error message. + * Returns the status code if the error is retryable, undefined otherwise. */ -export const getRetryableErrorCode = ( +export const getRetryableStatusCode = ( errorMessage: string, -): ErrorCode | null => { +): number | undefined => { const lowerMessage = errorMessage.toLowerCase() // AI SDK's built-in retry error (e.g., "Failed after 4 attempts. Last error: Service Unavailable") @@ -997,52 +995,56 @@ export const getRetryableErrorCode = ( ) { // Extract the underlying error type from the message if (lowerMessage.includes('service unavailable')) { - return ErrorCodes.SERVICE_UNAVAILABLE + return 503 } if (lowerMessage.includes('timeout')) { - return ErrorCodes.TIMEOUT + return 408 } if (lowerMessage.includes('connection refused')) { - return ErrorCodes.CONNECTION_REFUSED + return 503 } - // Default to SERVER_ERROR for other AI SDK retry failures - return ErrorCodes.SERVER_ERROR + // Default to 500 for other AI SDK retry failures + return 500 } if ( errorMessage.includes('503') || lowerMessage.includes('service unavailable') ) { - return ErrorCodes.SERVICE_UNAVAILABLE + return 503 + } + if (errorMessage.includes('504')) { + return 504 + } + if (errorMessage.includes('502')) { + return 502 } - if (lowerMessage.includes('timeout')) { - return ErrorCodes.TIMEOUT + if (lowerMessage.includes('timeout') || errorMessage.includes('408')) { + return 408 } if ( lowerMessage.includes('econnrefused') || lowerMessage.includes('connection refused') ) { - return ErrorCodes.CONNECTION_REFUSED + return 503 } if (lowerMessage.includes('dns') || lowerMessage.includes('enotfound')) { - return ErrorCodes.DNS_FAILURE + return 503 } - if ( - lowerMessage.includes('server error') || - lowerMessage.includes('500') || - lowerMessage.includes('502') || - lowerMessage.includes('504') - ) { - return ErrorCodes.SERVER_ERROR + if (lowerMessage.includes('server error') || errorMessage.includes('500')) { + return 500 + } + if (errorMessage.includes('429') || lowerMessage.includes('rate limit')) { + return 429 } if ( lowerMessage.includes('network error') || lowerMessage.includes('fetch failed') ) { - return ErrorCodes.NETWORK_ERROR + return 503 } - return null + return undefined } async function handlePromptResponse({ @@ -1059,10 +1061,10 @@ async function handlePromptResponse({ if (action.type === 'prompt-error') { onError({ message: action.message }) - // If this is a retryable error, throw NetworkError so retry wrapper can handle it - const retryableCode = getRetryableErrorCode(action.message) - if (retryableCode) { - throw new NetworkError(action.message, retryableCode) + // If this is a retryable error, throw with statusCode so retry wrapper can handle it + const retryableStatusCode = getRetryableStatusCode(action.message) + if (retryableStatusCode) { + throw createHttpError(action.message, retryableStatusCode) } // For non-retryable errors, resolve with error state From 872d65fc0b001afd7e66883b0018aa069f944eba Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 3 Jan 2026 11:54:52 -0800 Subject: [PATCH 3/6] Remove all the sdk retry code. I think retries should be at the level of individual llm calls, not the whole run, which could have unexpected behavior --- cli/src/hooks/use-send-message.ts | 3 - cli/src/utils/create-run-config.ts | 44 --- sdk/src/__tests__/run-with-retry.test.ts | 314 ------------------ sdk/src/index.ts | 1 - sdk/src/run.ts | 388 ++--------------------- 5 files changed, 26 insertions(+), 724 deletions(-) delete mode 100644 sdk/src/__tests__/run-with-retry.test.ts diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 5d8457923..9ab3c3dce 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -373,11 +373,8 @@ export const useSendMessage = ({ prompt: effectivePrompt, content: messageContent, previousRunState: previousRunStateRef.current, - abortController, agentDefinitions, eventHandlerState, - setIsRetrying, - setStreamStatus, }) const runState = await client.run(runConfig) diff --git a/cli/src/utils/create-run-config.ts b/cli/src/utils/create-run-config.ts index 9d7145244..c4dff5aa4 100644 --- a/cli/src/utils/create-run-config.ts +++ b/cli/src/utils/create-run-config.ts @@ -1,9 +1,3 @@ -import { - MAX_RETRIES_PER_MESSAGE, - RETRY_BACKOFF_BASE_DELAY_MS, - RETRY_BACKOFF_MAX_DELAY_MS, -} from '@codebuff/sdk' - import { createEventHandler, createStreamChunkHandler, @@ -12,7 +6,6 @@ import { import type { EventHandlerState } from './sdk-event-handlers' import type { AgentDefinition, MessageContent, RunState } from '@codebuff/sdk' import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { StreamStatus } from '../hooks/use-message-queue' export type CreateRunConfigParams = { logger: Logger @@ -20,22 +13,8 @@ export type CreateRunConfigParams = { prompt: string content: MessageContent[] | undefined previousRunState: RunState | null - abortController: AbortController agentDefinitions: AgentDefinition[] eventHandlerState: EventHandlerState - setIsRetrying: (retrying: boolean) => void - setStreamStatus: (status: StreamStatus) => void -} - -type RetryArgs = { - attempt: number - delayMs: number - statusCode?: number -} - -type RetryExhaustedArgs = { - totalAttempts: number - statusCode?: number } export const createRunConfig = (params: CreateRunConfigParams) => { @@ -45,11 +24,8 @@ export const createRunConfig = (params: CreateRunConfigParams) => { prompt, content, previousRunState, - abortController, agentDefinitions, eventHandlerState, - setIsRetrying, - setStreamStatus, } = params return { @@ -58,26 +34,6 @@ export const createRunConfig = (params: CreateRunConfigParams) => { prompt, content, previousRun: previousRunState ?? undefined, - abortController, - retry: { - maxRetries: MAX_RETRIES_PER_MESSAGE, - backoffBaseMs: RETRY_BACKOFF_BASE_DELAY_MS, - backoffMaxMs: RETRY_BACKOFF_MAX_DELAY_MS, - onRetry: async ({ attempt, delayMs, statusCode }: RetryArgs) => { - logger.warn( - { sdkAttempt: attempt, delayMs, statusCode }, - 'SDK retrying after error', - ) - setIsRetrying(true) - setStreamStatus('waiting') - }, - onRetryExhausted: async ({ - totalAttempts, - statusCode, - }: RetryExhaustedArgs) => { - logger.warn({ totalAttempts, statusCode }, 'SDK exhausted all retries') - }, - }, agentDefinitions, maxAgentSteps: 100, handleStreamChunk: createStreamChunkHandler(eventHandlerState), diff --git a/sdk/src/__tests__/run-with-retry.test.ts b/sdk/src/__tests__/run-with-retry.test.ts deleted file mode 100644 index bb8314d28..000000000 --- a/sdk/src/__tests__/run-with-retry.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { assistantMessage } from '@codebuff/common/util/messages' -import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' - - -import { run } from '../run' -import * as runModule from '../run' - -import type { RunState } from '../run-state' -import type { SessionState } from '@codebuff/common/types/session-state' - -const baseOptions = { - apiKey: 'test-key', - fingerprintId: 'fp', - agent: 'base2', - prompt: 'hi', -} as const - -describe('run retry wrapper', () => { - afterEach(() => { - mock.restore() - }) - - it('returns immediately on success without retrying', async () => { - const expectedState: RunState = { - sessionState: {} as SessionState, - output: { type: 'lastMessage', value: [assistantMessage('hi')] }, - } - const runSpy = spyOn(runModule, 'runOnce').mockResolvedValueOnce( - expectedState, - ) - - const result = await run(baseOptions) - - expect(result).toBe(expectedState) - expect(runSpy).toHaveBeenCalledTimes(1) - }) - - it('retries once on retryable error output and then succeeds', async () => { - const errorState: RunState = { - sessionState: {} as SessionState, - output: { type: 'error', message: 'NetworkError: Service unavailable' }, - } - const successState: RunState = { - sessionState: {} as SessionState, - output: { type: 'lastMessage', value: [assistantMessage('hi')] }, - } - - const runSpy = spyOn(runModule, 'runOnce') - .mockResolvedValueOnce(errorState) - .mockResolvedValueOnce(successState) - - const result = await run({ - ...baseOptions, - retry: { backoffBaseMs: 1, backoffMaxMs: 2 }, - }) - - expect(result).toBe(successState) - expect(runSpy).toHaveBeenCalledTimes(2) - }) - - it('stops after max retries are exhausted and returns error output', async () => { - const errorState = { - sessionState: {} as any, - output: { type: 'error', message: 'NetworkError: Connection timeout' }, - } as RunState - - const runSpy = spyOn(runModule, 'runOnce').mockResolvedValue(errorState) - - const result = await run({ - ...baseOptions, - retry: { maxRetries: 1, backoffBaseMs: 1, backoffMaxMs: 1 }, - }) - - // Should return error output after exhausting retries - expect(result.output.type).toBe('error') - if (result.output.type === 'error') { - expect(result.output.message).toContain('timeout') - } - // Initial attempt + one retry - expect(runSpy).toHaveBeenCalledTimes(2) - }) - - it('does not retry non-retryable error outputs', async () => { - const errorState = { - sessionState: {} as any, - output: { type: 'error', message: 'Invalid input' }, - } as RunState - - const runSpy = spyOn(runModule, 'runOnce').mockResolvedValue(errorState) - - const result = await run({ - ...baseOptions, - retry: { maxRetries: 3, backoffBaseMs: 1, backoffMaxMs: 1 }, - }) - - // Should return immediately without retrying - expect(result.output.type).toBe('error') - expect(runSpy).toHaveBeenCalledTimes(1) - }) - - it('skips retry when retry is false even for retryable error outputs', async () => { - const errorState = { - sessionState: {} as any, - output: { type: 'error', message: 'NetworkError: Connection failed' }, - } as RunState - - const runSpy = spyOn(runModule, 'runOnce').mockResolvedValue(errorState) - - const result = await run({ - ...baseOptions, - retry: false, - }) - - expect(result.output.type).toBe('error') - expect(runSpy).toHaveBeenCalledTimes(1) - }) - - it('retries when provided custom retryableErrorCodes set', async () => { - const errorState: RunState = { - sessionState: {} as any, - output: { type: 'error', message: 'Server error (500)' }, - } - const successState: RunState = { - sessionState: {} as SessionState, - output: { type: 'lastMessage', value: [assistantMessage('hi')] }, - } - - const runSpy = spyOn(runModule, 'runOnce') - .mockResolvedValueOnce(errorState) - .mockResolvedValueOnce(successState) - - const result = await run({ - ...baseOptions, - retry: { - backoffBaseMs: 1, - backoffMaxMs: 2, - retryableStatusCodes: new Set([500]), // SERVER_ERROR - }, - }) - - expect(result).toBe(successState) - expect(runSpy).toHaveBeenCalledTimes(2) - }) - - it('returns error output on abort before first attempt', async () => { - const controller = new AbortController() - controller.abort('cancelled') - - const runSpy = spyOn(runModule, 'runOnce') - - const result = await run({ - ...baseOptions, - retry: { backoffBaseMs: 1, backoffMaxMs: 2 }, - abortController: controller, - }) - - expect(result.output.type).toBe('error') - if (result.output.type === 'error') { - expect(result.output.message).toContain('Aborted') - } - expect(runSpy).toHaveBeenCalledTimes(0) - }) - - it('calls onRetry callback with correct parameters on error output', async () => { - const errorState: RunState = { - sessionState: {} as SessionState, - output: { type: 'error', message: 'Service unavailable (503)' }, - } - const successState: RunState = { - sessionState: {} as SessionState, - output: { type: 'lastMessage', value: [assistantMessage('done')] }, - } - - const runSpy = spyOn(runModule, 'runOnce') - .mockResolvedValueOnce(errorState) - .mockResolvedValueOnce(successState) - - const onRetryCalls: any[] = [] - const onRetry = async (params: any) => { - onRetryCalls.push(params) - } - - await run({ - ...baseOptions, - retry: { backoffBaseMs: 1, backoffMaxMs: 2, onRetry }, - }) - - expect(onRetryCalls).toHaveLength(1) - expect(onRetryCalls[0].attempt).toBe(1) - expect(onRetryCalls[0].delayMs).toBe(1) - expect(onRetryCalls[0].statusCode).toBe(503) - }) - - it('calls onRetryExhausted after all retries fail', async () => { - const errorState = { - sessionState: {} as any, - output: { type: 'error', message: 'NetworkError: timeout' }, - } as RunState - - spyOn(runModule, 'runOnce').mockResolvedValue(errorState) - - const onRetryExhaustedCalls: any[] = [] - const onRetryExhausted = async (params: any) => { - onRetryExhaustedCalls.push(params) - } - - await run({ - ...baseOptions, - retry: { maxRetries: 2, backoffBaseMs: 1, onRetryExhausted }, - }) - - expect(onRetryExhaustedCalls).toHaveLength(1) - expect(onRetryExhaustedCalls[0].totalAttempts).toBe(3) // Initial + 2 retries - expect(onRetryExhaustedCalls[0].statusCode).toBe(408) - }) - - it('returns error output without sessionState on first attempt failure', async () => { - const errorState = { - output: { type: 'error', message: 'Not retryable' }, - } as RunState - - spyOn(runModule, 'runOnce').mockResolvedValue(errorState) - - const result = await run({ - ...baseOptions, - retry: { maxRetries: 3, backoffBaseMs: 1 }, - }) - - expect(result.output.type).toBe('error') - expect(result.sessionState).toBeUndefined() - }) - - it('preserves sessionState from previousRun on retry', async () => { - const previousSession = { fileContext: { cwd: '/test' } } as any - const errorState: RunState = { - sessionState: { fileContext: { cwd: '/new' } } as SessionState, - output: { type: 'error', message: 'Service unavailable' }, - } - const successState: RunState = { - sessionState: { fileContext: { cwd: '/final' } } as SessionState, - output: { type: 'lastMessage', value: [assistantMessage('ok')] }, - } - - const runSpy = spyOn(runModule, 'runOnce') - .mockResolvedValueOnce(errorState) - .mockResolvedValueOnce(successState) - - const result = await run({ - ...baseOptions, - previousRun: { - sessionState: previousSession, - output: { type: 'lastMessage', value: [assistantMessage('prev')] }, - }, - retry: { backoffBaseMs: 1, backoffMaxMs: 2 }, - }) - - expect(result).toBe(successState) - expect(result.sessionState?.fileContext.cwd).toBe('/final') - }) - - it('handles 503 Service Unavailable errors as retryable', async () => { - const errorState: RunState = { - sessionState: {} as SessionState, - output: { - type: 'error', - message: 'Error from AI SDK: 503 Service Unavailable', - }, - } - const successState: RunState = { - sessionState: {} as SessionState, - output: { type: 'lastMessage', value: [assistantMessage('ok')] }, - } - - const runSpy = spyOn(runModule, 'runOnce') - .mockResolvedValueOnce(errorState) - .mockResolvedValueOnce(successState) - - const result = await run({ - ...baseOptions, - retry: { backoffBaseMs: 1, maxRetries: 1 }, - }) - - expect(result).toBe(successState) - expect(runSpy).toHaveBeenCalledTimes(2) - }) - - it('applies exponential backoff correctly', async () => { - const errorState: RunState = { - sessionState: {} as SessionState, - output: { type: 'error', message: 'NetworkError: Connection refused' }, - } as RunState - const successState: RunState = { - sessionState: {} as SessionState, - output: { type: 'lastMessage', value: [assistantMessage('ok')] }, - } - - spyOn(runModule, 'runOnce') - .mockResolvedValueOnce(errorState) - .mockResolvedValueOnce(errorState) - .mockResolvedValueOnce(successState) - - const delays: number[] = [] - const onRetry = async ({ delayMs }: any) => { - delays.push(delayMs) - } - - await run({ - ...baseOptions, - retry: { maxRetries: 3, backoffBaseMs: 100, backoffMaxMs: 1000, onRetry }, - }) - - expect(delays).toEqual([100, 200]) // First two retries (third succeeds, no retry callback) - }) -}) diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 9b7cbf4c0..669bfb78e 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -9,7 +9,6 @@ export type { export { run, getRetryableStatusCode } from './run' export type { RunOptions, - RetryOptions, MessageContent, TextContent, ImageContent, diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 04b560b64..7d11200c7 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -11,22 +11,11 @@ import { toOptionalFile } from '@codebuff/common/old-constants' import { toolNames } from '@codebuff/common/tools/constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' import { AgentOutputSchema } from '@codebuff/common/types/session-state' -import { getErrorObject } from '@codebuff/common/util/error' import { cloneDeep } from 'lodash' -import { - getErrorStatusCode, - sanitizeErrorMessage, - RETRYABLE_STATUS_CODES, - createHttpError, -} from './error-utils' +import { getErrorStatusCode } from './error-utils' import { getAgentRuntimeImpl } from './impl/agent-runtime' import { getUserInfoFromApiKey } from './impl/database' -import { - MAX_RETRIES_PER_MESSAGE, - RETRY_BACKOFF_BASE_DELAY_MS, - RETRY_BACKOFF_MAX_DELAY_MS, -} from './retry-config' import { initialSessionState, applyOverridesToSessionState } from './run-state' import { changeFile } from './tools/change-file' import { codeSearch } from './tools/code-search' @@ -123,45 +112,6 @@ export type CodebuffClientOptions = { logger?: Logger } -export type RetryOptions = { - /** - * Maximum number of retry attempts after the initial failure. - * A value of 0 disables retries. - */ - maxRetries?: number - /** - * Base delay in milliseconds for exponential backoff. - */ - backoffBaseMs?: number - /** - * Maximum delay in milliseconds for exponential backoff. - */ - backoffMaxMs?: number - /** - * HTTP status codes that should trigger retry. - * Defaults to RETRYABLE_STATUS_CODES (408, 429, 5xx). - */ - retryableStatusCodes?: Set - /** - * Optional callback invoked before each retry attempt. - */ - onRetry?: (params: { - attempt: number - error: unknown - delayMs: number - statusCode?: number - }) => void | Promise - /** - * Optional callback invoked when all SDK retries are exhausted. - * This allows the caller to be notified before the error is thrown. - */ - onRetryExhausted?: (params: { - totalAttempts: number - error: unknown - statusCode?: number - }) => void | Promise -} - export type ImageContent = { type: 'image' image: string // base64 encoded @@ -184,33 +134,6 @@ export type RunOptions = { previousRun?: RunState extraToolResults?: ToolMessage[] signal?: AbortSignal - abortController?: AbortController - retry?: boolean | RetryOptions -} - -type NormalizedRetryOptions = { - maxRetries: number - backoffBaseMs: number - backoffMaxMs: number - retryableStatusCodes: Set - onRetry?: (params: { - attempt: number - error: unknown - delayMs: number - statusCode?: number - }) => void | Promise - onRetryExhausted?: (params: { - totalAttempts: number - error: unknown - statusCode?: number - }) => void | Promise -} - -const defaultRetryOptions: NormalizedRetryOptions = { - maxRetries: MAX_RETRIES_PER_MESSAGE, - backoffBaseMs: RETRY_BACKOFF_BASE_DELAY_MS, - backoffMaxMs: RETRY_BACKOFF_MAX_DELAY_MS, - retryableStatusCodes: RETRYABLE_STATUS_CODES, } const createAbortError = (signal?: AbortSignal) => { @@ -222,269 +145,31 @@ const createAbortError = (signal?: AbortSignal) => { return error } -/** - * Checks if an error should trigger a retry attempt based on statusCode. - */ -const isRetryableError = ( - error: unknown, - retryableStatusCodes: Set, -): boolean => { - const statusCode = getErrorStatusCode(error) - return statusCode !== undefined && retryableStatusCodes.has(statusCode) -} - -const normalizeRetryOptions = ( - retry: RunOptions['retry'], -): NormalizedRetryOptions => { - if (!retry) { - return { ...defaultRetryOptions, maxRetries: 0 } - } - if (retry === true) { - return { ...defaultRetryOptions } - } - return { - maxRetries: retry.maxRetries ?? defaultRetryOptions.maxRetries, - backoffBaseMs: retry.backoffBaseMs ?? defaultRetryOptions.backoffBaseMs, - backoffMaxMs: retry.backoffMaxMs ?? defaultRetryOptions.backoffMaxMs, - retryableStatusCodes: - retry.retryableStatusCodes ?? defaultRetryOptions.retryableStatusCodes, - onRetry: retry.onRetry, - onRetryExhausted: retry.onRetryExhausted, - } -} - -const waitWithAbort = (delayMs: number, signal?: AbortSignal) => { - if (delayMs <= 0) return Promise.resolve() - - return new Promise((resolve, reject) => { - let timeoutId: ReturnType - - const onAbort = () => { - clearTimeout(timeoutId) - signal?.removeEventListener('abort', onAbort) - reject(createAbortError(signal)) - } - - timeoutId = setTimeout(() => { - if (signal) { - signal.removeEventListener('abort', onAbort) - } - resolve() - }, delayMs) - - if (!signal) { - return - } - - if (signal.aborted) { - onAbort() - return - } - - signal.addEventListener('abort', onAbort, { once: true }) - }) -} - type RunExecutionOptions = RunOptions & CodebuffClientOptions & { apiKey: string fingerprintId: string } -type RunOnceOptions = Omit type RunReturnType = RunState export async function run(options: RunExecutionOptions): Promise { - const { retry, abortController, ...rest } = options - const retryOptions = normalizeRetryOptions(retry) - - // Prefer provided signal; otherwise reuse a shared controller across retries. - const sharedController = - abortController ?? (rest.signal ? undefined : new AbortController()) - const signal = rest.signal ?? sharedController?.signal - - let attemptIndex = 0 - while (true) { - if (signal?.aborted) { - // Return error output for abort instead of throwing - const abortError = createAbortError(signal) - return { - sessionState: rest.previousRun?.sessionState, - output: { - type: 'error', - message: abortError.message, - }, - } - } - - try { - const result = await runOnce({ - ...rest, - signal, - }) - - // Check if result contains a retryable error in the output - if (result.output.type === 'error') { - const statusCode = - result.output.statusCode ?? - getRetryableStatusCode(result.output.message) - const canRetry = - statusCode !== undefined && - attemptIndex < retryOptions.maxRetries && - retryOptions.retryableStatusCodes.has(statusCode) - - if (canRetry) { - // Treat this as a retryable error - continue retry loop - const delayMs = Math.min( - retryOptions.backoffBaseMs * Math.pow(2, attemptIndex), - retryOptions.backoffMaxMs, - ) - - // Log retry attempt with full context - if (rest.logger) { - rest.logger.warn( - { - attempt: attemptIndex + 1, - maxRetries: retryOptions.maxRetries, - delayMs, - statusCode, - errorMessage: result.output.message, - }, - 'SDK retrying after error', - ) - } - - await retryOptions.onRetry?.({ - attempt: attemptIndex + 1, - error: new Error(result.output.message), - delayMs, - statusCode, - }) + const { signal } = options - await waitWithAbort(delayMs, signal) - attemptIndex++ - continue - } else if (attemptIndex > 0) { - // Non-retryable error or exhausted retries - if (rest.logger) { - rest.logger.warn( - { - attemptIndex, - totalAttempts: attemptIndex + 1, - statusCode, - }, - 'SDK exhausted all retries', - ) - } - - await retryOptions.onRetryExhausted?.({ - totalAttempts: attemptIndex + 1, - error: new Error(result.output.message), - statusCode, - }) - } - } - - // Log successful completion after retries - if (attemptIndex > 0 && rest.logger) { - rest.logger.info( - { attemptIndex, totalAttempts: attemptIndex + 1 }, - 'SDK run succeeded after retries', - ) - } - - return result - } catch (error) { - // Handle unexpected exceptions by converting to error output - if (signal?.aborted) { - const abortError = createAbortError(signal) - return { - sessionState: rest.previousRun?.sessionState, - output: { - type: 'error', - message: abortError.message, - }, - } - } - - // Unexpected exception - convert to error output and check if retryable - // Use sanitizeErrorMessage to get clean user-facing message without stack traces - const errorMessage = sanitizeErrorMessage(error) - const statusCode = - getErrorStatusCode(error) ?? getRetryableStatusCode(errorMessage) - - const canRetry = - statusCode !== undefined && - attemptIndex < retryOptions.maxRetries && - retryOptions.retryableStatusCodes.has(statusCode) - - if (rest.logger) { - rest.logger.error( - { - attemptIndex, - statusCode, - canRetry, - error: errorMessage, - }, - 'Unexpected exception in SDK run', - ) - } - - if (!canRetry) { - // Can't retry - convert to error output and return - if (attemptIndex > 0 && rest.logger) { - rest.logger.warn( - { - attemptIndex, - totalAttempts: attemptIndex + 1, - }, - 'SDK exhausted all retries after unexpected exception', - ) - } - - // Return error output instead of throwing - return { - sessionState: rest.previousRun?.sessionState, - output: { - type: 'error', - message: errorMessage, - ...(statusCode !== undefined && { statusCode }), - }, - } - } - - // Exception is retryable - trigger retry - const delayMs = Math.min( - retryOptions.backoffBaseMs * Math.pow(2, attemptIndex), - retryOptions.backoffMaxMs, - ) - - if (rest.logger) { - rest.logger.warn( - { - attempt: attemptIndex + 1, - maxRetries: retryOptions.maxRetries, - delayMs, - statusCode, - errorMessage, - }, - 'SDK retrying after unexpected exception', - ) - } - - await retryOptions.onRetry?.({ - attempt: attemptIndex + 1, - error: error instanceof Error ? error : new Error(errorMessage), - delayMs, - statusCode, - }) - - await waitWithAbort(delayMs, signal) - attemptIndex++ + if (signal?.aborted) { + const abortError = createAbortError(signal) + return { + sessionState: options.previousRun?.sessionState, + output: { + type: 'error', + message: abortError.message, + }, } } + + return runOnce(options) } -export async function runOnce({ +async function runOnce({ apiKey, fingerprintId, @@ -512,7 +197,7 @@ export async function runOnce({ previousRun, extraToolResults, signal, -}: RunOnceOptions): Promise { +}: RunExecutionOptions): Promise { const fs = await (typeof fsSource === 'function' ? fsSource() : fsSource) const spawn: CodebuffSpawn = ( spawnSource ? await spawnSource : require('child_process').spawn @@ -572,7 +257,7 @@ export async function runOnce({ let pendingAgentResponse = '' /** Calculates the current session state if cancelled. * - * This includes the user'e message and pending assistant message. + * This includes the user's message and pending assistant message. */ function getCancelledSessionState(message: string): SessionState { const state = cloneDeep(sessionState) @@ -805,33 +490,17 @@ export async function runOnce({ userId, signal: signal ?? new AbortController().signal, }).catch((error) => { - // Let retryable errors and payment errors propagate so the retry wrapper can handle them - const statusCode = getErrorStatusCode(error) - const isRetryable = isRetryableError( - error, - defaultRetryOptions.retryableStatusCodes, - ) - const isPaymentRequired = statusCode === 402 - logger?.warn( - { - statusCode, - isPaymentRequired, - isRetryable, - error: getErrorObject(error), - }, - 'callMainPrompt caught error, checking if retryable', - ) - - if (isRetryable || isPaymentRequired) { - // Reject the promise so the retry wrapper can catch it and include the statusCode - reject(error) - return - } - - // For non-retryable errors, resolve with cancelled state const errorMessage = error instanceof Error ? error.message : String(error ?? '') - resolve(getCancelledRunState(errorMessage)) + const statusCode = getErrorStatusCode(error) + resolve({ + sessionState: getCancelledSessionState(errorMessage), + output: { + type: 'error', + message: errorMessage, + ...(statusCode !== undefined && { statusCode }), + }, + }) }) return promise @@ -1061,18 +730,13 @@ async function handlePromptResponse({ if (action.type === 'prompt-error') { onError({ message: action.message }) - // If this is a retryable error, throw with statusCode so retry wrapper can handle it - const retryableStatusCode = getRetryableStatusCode(action.message) - if (retryableStatusCode) { - throw createHttpError(action.message, retryableStatusCode) - } - - // For non-retryable errors, resolve with error state + const statusCode = getRetryableStatusCode(action.message) resolve({ sessionState: initialSessionState, output: { type: 'error', message: action.message, + ...(statusCode !== undefined && { statusCode }), }, }) } else if (action.type === 'prompt-response') { From e46bbf011e392a8c223bddc7ebf673c236df150f Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 3 Jan 2026 12:00:10 -0800 Subject: [PATCH 4/6] Show out of credits error --- cli/src/hooks/helpers/send-message.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index eed878b98..889fc3f71 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -6,6 +6,7 @@ import { isOutOfCreditsError, OUT_OF_CREDITS_MESSAGE, } from '../../utils/error-handling' +import { usageQueryKeys } from '../use-usage-query' import { formatElapsedTime } from '../../utils/format-elapsed-time' import { processImagesForMessage } from '../../utils/image-processor' import { logger } from '../../utils/logger' @@ -16,7 +17,6 @@ import { type BatchedMessageUpdater, } from '../../utils/message-updater' import { createModeDividerMessage } from '../../utils/send-message-helpers' -import { usageQueryKeys } from '../use-usage-query' import type { PendingImage } from '../../state/chat-store' import type { ChatMessage } from '../../types/chat' @@ -214,6 +214,14 @@ export const handleRunCompletion = (params: { return } + if (isOutOfCreditsError(output)) { + updater.setError(OUT_OF_CREDITS_MESSAGE) + useChatStore.getState().setInputMode('usage') + queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() }) + finalizeAfterError() + return + } + const partial = createErrorMessage( output.message ?? 'No output from agent run', aiMessageId, From b44093273fbc5103ebcdddca3349801b708160c3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 3 Jan 2026 12:08:38 -0800 Subject: [PATCH 5/6] Tweaks from reviewer --- common/src/util/error.ts | 4 ++-- sdk/e2e/utils/get-api-key.ts | 13 +++++++++---- sdk/src/index.ts | 2 +- sdk/src/run.ts | 9 +++++---- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/common/src/util/error.ts b/common/src/util/error.ts index ff2179f10..788009e04 100644 --- a/common/src/util/error.ts +++ b/common/src/util/error.ts @@ -16,9 +16,9 @@ export type ErrorObject = { name: string message: string stack?: string - /** Optional numeric HTTP status code, if available */ + /** HTTP status code from error.status (used by some libraries) */ status?: number - /** Optional numeric HTTP status code, if available */ + /** HTTP status code from error.statusCode (used by AI SDK and Codebuff errors) */ statusCode?: number /** Optional machine-friendly error code, if available */ code?: string diff --git a/sdk/e2e/utils/get-api-key.ts b/sdk/e2e/utils/get-api-key.ts index dca63ce66..0e0dbf9e8 100644 --- a/sdk/e2e/utils/get-api-key.ts +++ b/sdk/e2e/utils/get-api-key.ts @@ -47,7 +47,7 @@ export function isAuthError(output: { } /** - * Check if output indicates a network error (e.g., backend unreachable). + * Check if output indicates a network error (e.g., backend unreachable, timeout, rate limit). */ export function isNetworkError(output: { type: string @@ -56,7 +56,12 @@ export function isNetworkError(output: { }): boolean { if (output.type !== 'error') return false const msg = output.message?.toLowerCase() ?? '' - // Check for 5xx status codes or network-related messages - const isServerError = output.statusCode !== undefined && output.statusCode >= 500 - return isServerError || msg.includes('network error') + // Check for retryable status codes (408 timeout, 429 rate limit, 5xx server errors) + // or network-related messages + const isRetryableStatusCode = + output.statusCode !== undefined && + (output.statusCode === 408 || + output.statusCode === 429 || + output.statusCode >= 500) + return isRetryableStatusCode || msg.includes('network error') } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 669bfb78e..6c487a7fa 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -6,7 +6,7 @@ export type { TextPart, ImagePart, } from '@codebuff/common/types/messages/content-part' -export { run, getRetryableStatusCode } from './run' +export { run } from './run' export type { RunOptions, MessageContent, diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 7d11200c7..673b75ae6 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -648,10 +648,11 @@ async function handleToolCall({ } /** - * Extracts an HTTP status code from a prompt error message. - * Returns the status code if the error is retryable, undefined otherwise. + * Extracts an HTTP status code from an error message string. + * Parses common error patterns to identify the underlying status code. + * Returns the status code if found, undefined otherwise. */ -export const getRetryableStatusCode = ( +export const extractStatusCodeFromMessage = ( errorMessage: string, ): number | undefined => { const lowerMessage = errorMessage.toLowerCase() @@ -730,7 +731,7 @@ async function handlePromptResponse({ if (action.type === 'prompt-error') { onError({ message: action.message }) - const statusCode = getRetryableStatusCode(action.message) + const statusCode = extractStatusCodeFromMessage(action.message) resolve({ sessionState: initialSessionState, output: { From 20c99d2da1a0236b94f69950d9fbee51433350d1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 3 Jan 2026 12:28:48 -0800 Subject: [PATCH 6/6] small tweak --- cli/src/app.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/cli/src/app.tsx b/cli/src/app.tsx index ab26eff7b..7d8da89e8 100644 --- a/cli/src/app.tsx +++ b/cli/src/app.tsx @@ -211,21 +211,20 @@ export const App = ({ ) }, [logoComponent, projectRoot, theme]) - // Derive auth reachability + retrying state inline from authQuery error + // Derive auth reachability + retrying state from authQuery error const authError = authQuery.error const authErrorStatusCode = authError ? getErrorStatusCode(authError) : undefined - const isRetryableNetworkError = authErrorStatusCode !== undefined && isRetryableStatusCode(authErrorStatusCode) let authStatus: AuthStatus = 'ok' - if (authQuery.isError) { - // Only show network status if it's a server/network error (5xx) - if (authErrorStatusCode === undefined || authErrorStatusCode < 500) { - authStatus = 'ok' - } else if (isRetryableNetworkError) { + if (authQuery.isError && authErrorStatusCode !== undefined) { + if (isRetryableStatusCode(authErrorStatusCode)) { + // Retryable errors (408 timeout, 429 rate limit, 5xx server errors) authStatus = 'retrying' - } else { + } else if (authErrorStatusCode >= 500) { + // Non-retryable server errors (unlikely but possible future codes) authStatus = 'unreachable' } + // 4xx client errors (401, 403, etc.) keep 'ok' - network is fine, just auth failed } // Render login modal when not authenticated AND auth service is reachable