Skip to content

Commit f7d51e3

Browse files
committed
feat: add typed error handling with network status tracking and smart
retry - Convert SDK getUserInfoFromApiKey to throw typed errors (AuthenticationError, NetworkError) - Add useNetworkStatus hook to track auth/validation service reachability - Show amber "error, retrying..." for transient errors (5xx, timeouts) - Auto-retry with exponential backoff (max 3 attempts) - Suppress login modal during retries and network outages - Fix backend middleware to catch and handle auth/network errors properly
1 parent 277cd9e commit f7d51e3

File tree

8 files changed

+301
-72
lines changed

8 files changed

+301
-72
lines changed

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

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
2-
import { getUserInfoFromApiKey, WEBSITE_URL } from '@codebuff/sdk'
2+
import {
3+
AuthenticationError,
4+
NetworkError,
5+
getUserInfoFromApiKey,
6+
WEBSITE_URL,
7+
} from '@codebuff/sdk'
38
import { userColumns } from '@codebuff/common/types/contracts/database'
49

510
import type { Logger } from '@codebuff/common/types/contracts/logger'
@@ -135,13 +140,15 @@ describe('API Integration', () => {
135140
})
136141
const testLogger = createLoggerMocks()
137142

138-
const result = await getUserInfoFromApiKey({
139-
apiKey: 'unauthorized-token',
140-
fields: ['id'],
141-
logger: testLogger,
142-
})
143+
await expect(
144+
getUserInfoFromApiKey({
145+
apiKey: 'unauthorized-token',
146+
fields: ['id'],
147+
logger: testLogger,
148+
}),
149+
).rejects.toBeInstanceOf(AuthenticationError)
143150

144-
expect(result).toBeNull()
151+
// 401s are now logged as auth failures
145152
expect(testLogger.error.mock.calls.length).toBeGreaterThan(0)
146153
})
147154
})
@@ -153,13 +160,14 @@ describe('API Integration', () => {
153160
})
154161
const testLogger = createLoggerMocks()
155162

156-
const result = await getUserInfoFromApiKey({
157-
apiKey: 'server-error-token',
158-
fields: ['id'],
159-
logger: testLogger,
160-
})
163+
await expect(
164+
getUserInfoFromApiKey({
165+
apiKey: 'server-error-token',
166+
fields: ['id'],
167+
logger: testLogger,
168+
}),
169+
).rejects.toBeInstanceOf(NetworkError)
161170

162-
expect(result).toBeNull()
163171
expect(testLogger.error.mock.calls.length).toBeGreaterThan(0)
164172
})
165173

@@ -169,13 +177,14 @@ describe('API Integration', () => {
169177
})
170178
const testLogger = createLoggerMocks()
171179

172-
const result = await getUserInfoFromApiKey({
173-
apiKey: 'timeout-token',
174-
fields: ['id'],
175-
logger: testLogger,
176-
})
180+
await expect(
181+
getUserInfoFromApiKey({
182+
apiKey: 'timeout-token',
183+
fields: ['id'],
184+
logger: testLogger,
185+
}),
186+
).rejects.toBeInstanceOf(NetworkError)
177187

178-
expect(result).toBeNull()
179188
expect(
180189
testLogger.error.mock.calls.some(([payload]) =>
181190
JSON.stringify(payload).includes('Request timed out'),
@@ -189,13 +198,14 @@ describe('API Integration', () => {
189198
})
190199
const testLogger = createLoggerMocks()
191200

192-
const result = await getUserInfoFromApiKey({
193-
apiKey: 'malformed-json-token',
194-
fields: ['id'],
195-
logger: testLogger,
196-
})
201+
await expect(
202+
getUserInfoFromApiKey({
203+
apiKey: 'malformed-json-token',
204+
fields: ['id'],
205+
logger: testLogger,
206+
}),
207+
).rejects.toBeInstanceOf(NetworkError)
197208

198-
expect(result).toBeNull()
199209
expect(testLogger.error.mock.calls.length).toBeGreaterThan(0)
200210
})
201211
})
@@ -209,13 +219,14 @@ describe('API Integration', () => {
209219
})
210220
const testLogger = createLoggerMocks()
211221

212-
const result = await getUserInfoFromApiKey({
213-
apiKey: 'network-failure-token',
214-
fields: ['id'],
215-
logger: testLogger,
216-
})
222+
await expect(
223+
getUserInfoFromApiKey({
224+
apiKey: 'network-failure-token',
225+
fields: ['id'],
226+
logger: testLogger,
227+
}),
228+
).rejects.toBeInstanceOf(NetworkError)
217229

218-
expect(result).toBeNull()
219230
expect(fetchMock.mock.calls.length).toBe(1)
220231
expect(
221232
testLogger.error.mock.calls.some(([payload]) =>
@@ -232,13 +243,14 @@ describe('API Integration', () => {
232243
})
233244
const testLogger = createLoggerMocks()
234245

235-
const result = await getUserInfoFromApiKey({
236-
apiKey: 'dns-failure-token',
237-
fields: ['id'],
238-
logger: testLogger,
239-
})
246+
await expect(
247+
getUserInfoFromApiKey({
248+
apiKey: 'dns-failure-token',
249+
fields: ['id'],
250+
logger: testLogger,
251+
}),
252+
).rejects.toBeInstanceOf(NetworkError)
240253

241-
expect(result).toBeNull()
242254
expect(fetchMock.mock.calls.length).toBe(1)
243255
expect(
244256
testLogger.error.mock.calls.some(([payload]) =>

cli/src/app.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ import { Chat } from './chat'
99
import { LoginModal } from './components/login-modal'
1010
import { TerminalLink } from './components/terminal-link'
1111
import { ToolCallItem } from './components/tools/tool-call-item'
12+
import { useAgentValidation } from './hooks/use-agent-validation'
13+
import { useAuthQuery } from './hooks/use-auth-query'
1214
import { useAuthState } from './hooks/use-auth-state'
1315
import { useLogo } from './hooks/use-logo'
1416
import { useTerminalDimensions } from './hooks/use-terminal-dimensions'
1517
import { useTheme } from './hooks/use-theme'
18+
import { NetworkError, RETRYABLE_ERROR_CODES } from '@codebuff/sdk'
19+
import type { AuthStatus } from './utils/status-indicator-state'
1620
import { getProjectRoot } from './project-files'
1721
import { useChatStore } from './state/chat-store'
1822
import { createValidationErrorBlocks } from './utils/create-validation-error-blocks'
@@ -60,6 +64,9 @@ export const App = ({
6064
})),
6165
)
6266

67+
// Get auth query for network status tracking
68+
const authQuery = useAuthQuery()
69+
6370
const {
6471
isAuthenticated,
6572
setIsAuthenticated,
@@ -74,6 +81,9 @@ export const App = ({
7481
resetChatStore,
7582
})
7683

84+
// Agent validation
85+
const { validate: validateAgents } = useAgentValidation(validationErrors)
86+
7787
const headerContent = useMemo(() => {
7888
const homeDir = os.homedir()
7989
const repoRoot = getProjectRoot()
@@ -203,8 +213,32 @@ export const App = ({
203213
separatorWidth,
204214
])
205215

206-
// Render login modal when not authenticated, otherwise render chat
207-
if (requireAuth !== null && isAuthenticated === false) {
216+
// Derive auth reachability + retrying state inline from authQuery error
217+
const authError = authQuery.error
218+
const networkError =
219+
authError && authError instanceof NetworkError ? authError : null
220+
const isRetryableNetworkError = Boolean(
221+
networkError && RETRYABLE_ERROR_CODES.has(networkError.code),
222+
)
223+
224+
let authStatus: AuthStatus = 'ok'
225+
if (authQuery.isError) {
226+
if (!networkError) {
227+
authStatus = 'ok'
228+
} else if (isRetryableNetworkError) {
229+
authStatus = 'retrying'
230+
} else {
231+
authStatus = 'unreachable'
232+
}
233+
}
234+
235+
// Render login modal when not authenticated AND auth service is reachable
236+
// Don't show login modal during network outages OR while retrying
237+
if (
238+
requireAuth !== null &&
239+
isAuthenticated === false &&
240+
authStatus === 'ok'
241+
) {
208242
return (
209243
<LoginModal
210244
onLoginSuccess={handleLoginSuccess}
@@ -227,6 +261,7 @@ export const App = ({
227261
logoutMutation={logoutMutation}
228262
continueChat={continueChat}
229263
continueChatId={continueChatId}
264+
authStatus={authStatus}
230265
/>
231266
)
232267
}

cli/src/chat.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { useFeedbackStore } from './state/feedback-store'
3232
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
3333
import { loadLocalAgents } from './utils/local-agent-registry'
3434
import { buildMessageTree } from './utils/message-tree-utils'
35-
import { getStatusIndicatorState } from './utils/status-indicator-state'
35+
import { getStatusIndicatorState, type AuthStatus } from './utils/status-indicator-state'
3636
import { computeInputLayoutMetrics } from './utils/text-layout'
3737
import { createMarkdownPalette } from './utils/theme-system'
3838

@@ -58,6 +58,7 @@ export const Chat = ({
5858
logoutMutation,
5959
continueChat,
6060
continueChatId,
61+
authStatus,
6162
}: {
6263
headerContent: React.ReactNode
6364
initialPrompt: string | null
@@ -74,6 +75,7 @@ export const Chat = ({
7475
logoutMutation: UseMutationResult<boolean, Error, void, unknown>
7576
continueChat: boolean
7677
continueChatId?: string
78+
authStatus: AuthStatus
7779
}) => {
7880
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
7981
const [hasOverflow, setHasOverflow] = useState(false)
@@ -786,6 +788,7 @@ export const Chat = ({
786788
streamStatus,
787789
nextCtrlCWillExit,
788790
isConnected,
791+
authStatus,
789792
})
790793
const hasStatusIndicatorContent = statusIndicatorState.kind !== 'idle'
791794
const inputBoxTitle = useMemo(() => {
@@ -927,6 +930,7 @@ export const Chat = ({
927930
timerStartTime={timerStartTime}
928931
nextCtrlCWillExit={nextCtrlCWillExit}
929932
isConnected={isConnected}
933+
authStatus={authStatus}
930934
isAtBottom={isAtBottom}
931935
scrollToLatest={scrollToLatest}
932936
/>

cli/src/components/__tests__/status-indicator.test.tsx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, test, expect } from 'bun:test'
22

3-
import { getStatusIndicatorState } from '../../utils/status-indicator-state'
3+
import {
4+
getStatusIndicatorState,
5+
type AuthStatus,
6+
} from '../../utils/status-indicator-state'
47
import type { StatusIndicatorStateArgs } from '../../utils/status-indicator-state'
58

69
describe('StatusIndicator state logic', () => {
@@ -41,6 +44,16 @@ describe('StatusIndicator state logic', () => {
4144
}
4245
})
4346

47+
test('returns retrying state when auth is retrying even if connected and reachable', () => {
48+
const state = getStatusIndicatorState({
49+
...baseArgs,
50+
isConnected: true,
51+
authStatus: 'retrying',
52+
streamStatus: 'streaming',
53+
})
54+
expect(state.kind).toBe('retrying')
55+
})
56+
4457
test('returns connecting state when not connected (third priority)', () => {
4558
const state = getStatusIndicatorState({
4659
...baseArgs,
@@ -50,6 +63,26 @@ describe('StatusIndicator state logic', () => {
5063
expect(state.kind).toBe('connecting')
5164
})
5265

66+
test('returns connecting state when auth service is unreachable', () => {
67+
const state = getStatusIndicatorState({
68+
...baseArgs,
69+
isConnected: true,
70+
authStatus: 'unreachable',
71+
streamStatus: 'streaming',
72+
})
73+
expect(state.kind).toBe('connecting')
74+
})
75+
76+
test('returns connecting state when both WebSocket and auth service are unreachable', () => {
77+
const state = getStatusIndicatorState({
78+
...baseArgs,
79+
isConnected: false,
80+
authStatus: 'unreachable',
81+
streamStatus: 'streaming',
82+
})
83+
expect(state.kind).toBe('connecting')
84+
})
85+
5386
test('returns waiting state when streamStatus is waiting', () => {
5487
const state = getStatusIndicatorState({
5588
...baseArgs,
@@ -95,6 +128,16 @@ describe('StatusIndicator state logic', () => {
95128
expect(state.kind).toBe('clipboard')
96129
})
97130

131+
test('retrying beats waiting', () => {
132+
const state = getStatusIndicatorState({
133+
...baseArgs,
134+
isConnected: true,
135+
authStatus: 'retrying',
136+
streamStatus: 'waiting',
137+
})
138+
expect(state.kind).toBe('retrying')
139+
})
140+
98141
test('connecting beats waiting', () => {
99142
const state = getStatusIndicatorState({
100143
...baseArgs,
@@ -104,6 +147,16 @@ describe('StatusIndicator state logic', () => {
104147
expect(state.kind).toBe('connecting')
105148
})
106149

150+
test('auth unreachable beats waiting', () => {
151+
const state = getStatusIndicatorState({
152+
...baseArgs,
153+
isConnected: true,
154+
authStatus: 'unreachable',
155+
streamStatus: 'waiting',
156+
})
157+
expect(state.kind).toBe('connecting')
158+
})
159+
107160
test('waiting beats streaming', () => {
108161
const state = getStatusIndicatorState({
109162
...baseArgs,

0 commit comments

Comments
 (0)