Skip to content

Commit aba2e54

Browse files
authored
Reuse ai sdk status codes (#402)
1 parent db97285 commit aba2e54

File tree

20 files changed

+302
-1221
lines changed

20 files changed

+302
-1221
lines changed

cli/src/__tests__/integration/api-integration.test.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import {
2-
AuthenticationError,
3-
NetworkError,
42
getUserInfoFromApiKey,
53
} from '@codebuff/sdk'
64
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
@@ -143,7 +141,7 @@ describe('API Integration', () => {
143141
fields: ['id'],
144142
logger: testLogger,
145143
}),
146-
).rejects.toBeInstanceOf(AuthenticationError)
144+
).rejects.toMatchObject({ statusCode: 401 })
147145

148146
// 401s are now logged as auth failures
149147
expect(testLogger.error.mock.calls.length).toBeGreaterThan(0)
@@ -161,7 +159,7 @@ describe('API Integration', () => {
161159
fields: ['id'],
162160
logger: testLogger,
163161
}),
164-
).rejects.toBeInstanceOf(AuthenticationError)
162+
).rejects.toMatchObject({ statusCode: 401 })
165163

166164
expect(testLogger.error.mock.calls.length).toBeGreaterThan(0)
167165
})
@@ -180,7 +178,7 @@ describe('API Integration', () => {
180178
fields: ['id'],
181179
logger: testLogger,
182180
}),
183-
).rejects.toBeInstanceOf(NetworkError)
181+
).rejects.toMatchObject({ statusCode: expect.any(Number) })
184182

185183
expect(testLogger.error.mock.calls.length).toBeGreaterThan(0)
186184
})
@@ -197,7 +195,7 @@ describe('API Integration', () => {
197195
fields: ['id'],
198196
logger: testLogger,
199197
}),
200-
).rejects.toBeInstanceOf(NetworkError)
198+
).rejects.toMatchObject({ statusCode: expect.any(Number) })
201199

202200
expect(
203201
testLogger.error.mock.calls.some(([payload]) =>
@@ -218,7 +216,7 @@ describe('API Integration', () => {
218216
fields: ['id'],
219217
logger: testLogger,
220218
}),
221-
).rejects.toBeInstanceOf(NetworkError)
219+
).rejects.toMatchObject({ statusCode: expect.any(Number) })
222220

223221
expect(testLogger.error.mock.calls.length).toBeGreaterThan(0)
224222
})
@@ -239,7 +237,7 @@ describe('API Integration', () => {
239237
fields: ['id'],
240238
logger: testLogger,
241239
}),
242-
).rejects.toBeInstanceOf(NetworkError)
240+
).rejects.toMatchObject({ statusCode: expect.any(Number) })
243241

244242
expect(fetchMock.mock.calls.length).toBe(1)
245243
expect(
@@ -263,7 +261,7 @@ describe('API Integration', () => {
263261
fields: ['id'],
264262
logger: testLogger,
265263
}),
266-
).rejects.toBeInstanceOf(NetworkError)
264+
).rejects.toMatchObject({ statusCode: expect.any(Number) })
267265

268266
expect(fetchMock.mock.calls.length).toBe(1)
269267
expect(

cli/src/app.tsx

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { NetworkError, RETRYABLE_ERROR_CODES } from '@codebuff/sdk'
1+
import { isRetryableStatusCode, getErrorStatusCode } from '@codebuff/sdk'
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import { useShallow } from 'zustand/react/shallow'
44

@@ -211,23 +211,20 @@ export const App = ({
211211
)
212212
}, [logoComponent, projectRoot, theme])
213213

214-
// Derive auth reachability + retrying state inline from authQuery error
214+
// Derive auth reachability + retrying state from authQuery error
215215
const authError = authQuery.error
216-
const networkError =
217-
authError && authError instanceof NetworkError ? authError : null
218-
const isRetryableNetworkError = Boolean(
219-
networkError && RETRYABLE_ERROR_CODES.has(networkError.code),
220-
)
216+
const authErrorStatusCode = authError ? getErrorStatusCode(authError) : undefined
221217

222218
let authStatus: AuthStatus = 'ok'
223-
if (authQuery.isError) {
224-
if (!networkError) {
225-
authStatus = 'ok'
226-
} else if (isRetryableNetworkError) {
219+
if (authQuery.isError && authErrorStatusCode !== undefined) {
220+
if (isRetryableStatusCode(authErrorStatusCode)) {
221+
// Retryable errors (408 timeout, 429 rate limit, 5xx server errors)
227222
authStatus = 'retrying'
228-
} else {
223+
} else if (authErrorStatusCode >= 500) {
224+
// Non-retryable server errors (unlikely but possible future codes)
229225
authStatus = 'unreachable'
230226
}
227+
// 4xx client errors (401, 403, etc.) keep 'ok' - network is fine, just auth failed
231228
}
232229

233230
// Render login modal when not authenticated AND auth service is reachable

cli/src/hooks/helpers/__tests__/send-message.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const { setupStreamingContext, handleRunError } = await import(
3434
const { createBatchedMessageUpdater } = await import(
3535
'../../../utils/message-updater'
3636
)
37-
const { PaymentRequiredError } = await import('@codebuff/sdk')
37+
import { createPaymentRequiredError } from '@codebuff/sdk'
3838

3939
const createMockTimerController = (): SendMessageTimerController & {
4040
startCalls: string[]
@@ -375,7 +375,7 @@ describe('handleRunError', () => {
375375
expect(mockInvalidateQueries).not.toHaveBeenCalled()
376376
})
377377

378-
test('PaymentRequiredError uses setError, invalidates queries, and switches input mode', () => {
378+
test('Payment required error (402) uses setError, invalidates queries, and switches input mode', () => {
379379
let messages: ChatMessage[] = [
380380
{
381381
id: 'ai-1',
@@ -397,7 +397,7 @@ describe('handleRunError', () => {
397397
setInputMode: setInputModeMock,
398398
})
399399

400-
const paymentError = new PaymentRequiredError('Out of credits')
400+
const paymentError = createPaymentRequiredError('Out of credits')
401401

402402
handleRunError({
403403
error: paymentError,

cli/src/hooks/helpers/send-message.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { useChatStore } from '../../state/chat-store'
33
import { processBashContext } from '../../utils/bash-context-processor'
44
import {
55
createErrorMessage,
6-
createPaymentErrorMessage,
76
isOutOfCreditsError,
8-
isPaymentRequiredError,
7+
OUT_OF_CREDITS_MESSAGE,
98
} from '../../utils/error-handling'
9+
import { usageQueryKeys } from '../use-usage-query'
1010
import { formatElapsedTime } from '../../utils/format-elapsed-time'
1111
import { processImagesForMessage } from '../../utils/image-processor'
1212
import { logger } from '../../utils/logger'
@@ -17,7 +17,6 @@ import {
1717
type BatchedMessageUpdater,
1818
} from '../../utils/message-updater'
1919
import { createModeDividerMessage } from '../../utils/send-message-helpers'
20-
import { usageQueryKeys } from '../use-usage-query'
2120

2221
import type { PendingImage } from '../../state/chat-store'
2322
import type { ChatMessage } from '../../types/chat'
@@ -216,23 +215,19 @@ export const handleRunCompletion = (params: {
216215
}
217216

218217
if (isOutOfCreditsError(output)) {
219-
const { message, showUsageBanner } = createPaymentErrorMessage(output)
220-
updater.setError(message)
221-
222-
if (showUsageBanner) {
223-
useChatStore.getState().setInputMode('usage')
224-
queryClient.invalidateQueries({
225-
queryKey: usageQueryKeys.current(),
226-
})
227-
}
228-
} else {
229-
const partial = createErrorMessage(
230-
output.message ?? 'No output from agent run',
231-
aiMessageId,
232-
)
233-
updater.setError(partial.content ?? '')
218+
updater.setError(OUT_OF_CREDITS_MESSAGE)
219+
useChatStore.getState().setInputMode('usage')
220+
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
221+
finalizeAfterError()
222+
return
234223
}
235224

225+
const partial = createErrorMessage(
226+
output.message ?? 'No output from agent run',
227+
aiMessageId,
228+
)
229+
updater.setError(partial.content ?? '')
230+
236231
finalizeAfterError()
237232
return
238233
}
@@ -302,10 +297,8 @@ export const handleRunError = (params: {
302297
updateChainInProgress(false)
303298
timerController.stop('error')
304299

305-
if (isPaymentRequiredError(error)) {
306-
const { message } = createPaymentErrorMessage(error)
307-
308-
updater.setError(message)
300+
if (isOutOfCreditsError(error)) {
301+
updater.setError(OUT_OF_CREDITS_MESSAGE)
309302
useChatStore.getState().setInputMode('usage')
310303
queryClient.invalidateQueries({ queryKey: usageQueryKeys.current() })
311304
return

cli/src/hooks/use-auth-query.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { createHash } from 'crypto'
22

33
import { getCiEnv } from '@codebuff/common/env-ci'
44
import {
5-
AuthenticationError,
6-
ErrorCodes,
75
getUserInfoFromApiKey as defaultGetUserInfoFromApiKey,
8-
NetworkError,
9-
RETRYABLE_ERROR_CODES,
6+
isRetryableStatusCode,
7+
getErrorStatusCode,
8+
createAuthError,
9+
createServerError,
1010
MAX_RETRIES_PER_MESSAGE,
1111
RETRY_BACKOFF_BASE_DELAY_MS,
1212
} from '@codebuff/sdk'
@@ -47,6 +47,14 @@ type ValidatedUserInfo = {
4747
email: string
4848
}
4949

50+
/**
51+
* Check if an error is an authentication error (401, 403)
52+
*/
53+
function isAuthenticationError(error: unknown): boolean {
54+
const statusCode = getErrorStatusCode(error)
55+
return statusCode === 401 || statusCode === 403
56+
}
57+
5058
/**
5159
* Validates an API key by calling the backend
5260
*
@@ -69,42 +77,39 @@ export async function validateApiKey({
6977

7078
if (!authResult) {
7179
logger.error('❌ API key validation failed - invalid credentials')
72-
throw new AuthenticationError('Invalid API key', 401)
80+
throw createAuthError('Invalid API key')
7381
}
7482

7583
return authResult
7684
} catch (error) {
77-
if (error instanceof AuthenticationError) {
85+
const statusCode = getErrorStatusCode(error)
86+
87+
if (isAuthenticationError(error)) {
7888
logger.error('❌ API key validation failed - authentication error')
79-
// Rethrow the original error to preserve error type for higher layers
89+
// Rethrow the original error to preserve statusCode for higher layers
8090
throw error
8191
}
8292

83-
if (error instanceof NetworkError) {
93+
if (statusCode !== undefined && isRetryableStatusCode(statusCode)) {
8494
logger.error(
8595
{
86-
error: error.message,
87-
code: error.code,
96+
error: error instanceof Error ? error.message : String(error),
97+
statusCode,
8898
},
8999
'❌ API key validation failed - network error',
90100
)
91-
// Rethrow the original error to preserve error type for higher layers
101+
// Rethrow the original error to preserve statusCode for higher layers
92102
throw error
93103
}
94104

95-
// Unknown error - wrap in NetworkError for consistency
105+
// Unknown error - wrap with statusCode for consistency
96106
logger.error(
97107
{
98108
error: error instanceof Error ? error.message : String(error),
99109
},
100110
'❌ API key validation failed - unknown error',
101111
)
102-
throw new NetworkError(
103-
'Authentication failed',
104-
ErrorCodes.UNKNOWN_ERROR,
105-
undefined,
106-
error,
107-
)
112+
throw createServerError('Authentication failed')
108113
}
109114
}
110115

@@ -139,12 +144,13 @@ export function useAuthQuery(deps: UseAuthQueryDeps = {}) {
139144
// Retry only for retryable network errors (5xx, timeouts, etc.)
140145
// Don't retry authentication errors (invalid credentials)
141146
retry: (failureCount, error) => {
147+
const statusCode = getErrorStatusCode(error)
142148
// Don't retry authentication errors - user needs to update credentials
143-
if (error instanceof AuthenticationError) {
149+
if (isAuthenticationError(error)) {
144150
return false
145151
}
146152
// Retry network errors if they're retryable and we haven't exceeded max retries
147-
if (error instanceof NetworkError && RETRYABLE_ERROR_CODES.has(error.code)) {
153+
if (statusCode !== undefined && isRetryableStatusCode(statusCode)) {
148154
return failureCount < MAX_RETRIES_PER_MESSAGE
149155
}
150156
// Don't retry other errors

cli/src/hooks/use-send-message.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -373,11 +373,8 @@ export const useSendMessage = ({
373373
prompt: effectivePrompt,
374374
content: messageContent,
375375
previousRunState: previousRunStateRef.current,
376-
abortController,
377376
agentDefinitions,
378377
eventHandlerState,
379-
setIsRetrying,
380-
setStreamStatus,
381378
})
382379

383380
const runState = await client.run(runConfig)

cli/src/utils/create-run-config.ts

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
import {
2-
MAX_RETRIES_PER_MESSAGE,
3-
RETRY_BACKOFF_BASE_DELAY_MS,
4-
RETRY_BACKOFF_MAX_DELAY_MS,
5-
} from '@codebuff/sdk'
6-
71
import {
82
createEventHandler,
93
createStreamChunkHandler,
@@ -12,30 +6,15 @@ import {
126
import type { EventHandlerState } from './sdk-event-handlers'
137
import type { AgentDefinition, MessageContent, RunState } from '@codebuff/sdk'
148
import type { Logger } from '@codebuff/common/types/contracts/logger'
15-
import type { StreamStatus } from '../hooks/use-message-queue'
169

1710
export type CreateRunConfigParams = {
1811
logger: Logger
1912
agent: AgentDefinition | string
2013
prompt: string
2114
content: MessageContent[] | undefined
2215
previousRunState: RunState | null
23-
abortController: AbortController
2416
agentDefinitions: AgentDefinition[]
2517
eventHandlerState: EventHandlerState
26-
setIsRetrying: (retrying: boolean) => void
27-
setStreamStatus: (status: StreamStatus) => void
28-
}
29-
30-
type RetryArgs = {
31-
attempt: number
32-
delayMs: number
33-
errorCode?: string
34-
}
35-
36-
type RetryExhaustedArgs = {
37-
totalAttempts: number
38-
errorCode?: string
3918
}
4019

4120
export const createRunConfig = (params: CreateRunConfigParams) => {
@@ -45,11 +24,8 @@ export const createRunConfig = (params: CreateRunConfigParams) => {
4524
prompt,
4625
content,
4726
previousRunState,
48-
abortController,
4927
agentDefinitions,
5028
eventHandlerState,
51-
setIsRetrying,
52-
setStreamStatus,
5329
} = params
5430

5531
return {
@@ -58,26 +34,6 @@ export const createRunConfig = (params: CreateRunConfigParams) => {
5834
prompt,
5935
content,
6036
previousRun: previousRunState ?? undefined,
61-
abortController,
62-
retry: {
63-
maxRetries: MAX_RETRIES_PER_MESSAGE,
64-
backoffBaseMs: RETRY_BACKOFF_BASE_DELAY_MS,
65-
backoffMaxMs: RETRY_BACKOFF_MAX_DELAY_MS,
66-
onRetry: async ({ attempt, delayMs, errorCode }: RetryArgs) => {
67-
logger.warn(
68-
{ sdkAttempt: attempt, delayMs, errorCode },
69-
'SDK retrying after error',
70-
)
71-
setIsRetrying(true)
72-
setStreamStatus('waiting')
73-
},
74-
onRetryExhausted: async ({
75-
totalAttempts,
76-
errorCode,
77-
}: RetryExhaustedArgs) => {
78-
logger.warn({ totalAttempts, errorCode }, 'SDK exhausted all retries')
79-
},
80-
},
8137
agentDefinitions,
8238
maxAgentSteps: 100,
8339
handleStreamChunk: createStreamChunkHandler(eventHandlerState),

0 commit comments

Comments
 (0)