Skip to content

Commit a694007

Browse files
committed
feat(sdk): add retry logic with exponential backoff and disable AI SDK
retries - Add SDK-level retry wrapper with exponential backoff (up to 3 attempts) - Add onRetry and onRetryExhausted callbacks for observability - Convert prompt errors to NetworkError with proper error codes for retry detection - Add comprehensive logging for all retry attempts and outcomes
1 parent 6b998ba commit a694007

File tree

4 files changed

+446
-11
lines changed

4 files changed

+446
-11
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
2+
3+
import { ErrorCodes, NetworkError } from '../errors'
4+
import { run } from '../run'
5+
import * as runModule from '../run'
6+
7+
import type { RunState } from '../run-state'
8+
9+
const baseOptions = {
10+
apiKey: 'test-key',
11+
fingerprintId: 'fp',
12+
agent: 'base',
13+
prompt: 'hi',
14+
} as const
15+
16+
describe('run retry wrapper', () => {
17+
afterEach(() => {
18+
mock.restore()
19+
})
20+
21+
it('returns immediately on success without retrying', async () => {
22+
const expectedState = { output: { type: 'success' } } as RunState
23+
const runSpy = spyOn(runModule, 'runOnce').mockResolvedValueOnce(expectedState)
24+
25+
const result = await run(baseOptions)
26+
27+
expect(result).toBe(expectedState)
28+
expect(runSpy).toHaveBeenCalledTimes(1)
29+
})
30+
31+
it('retries once on retryable network error and then succeeds', async () => {
32+
const expectedState = { output: { type: 'success' } } as RunState
33+
const runSpy = spyOn(runModule, 'runOnce')
34+
.mockRejectedValueOnce(
35+
new NetworkError('temporary', ErrorCodes.NETWORK_ERROR),
36+
)
37+
.mockResolvedValueOnce(expectedState)
38+
39+
const result = await run({
40+
...baseOptions,
41+
retry: { backoffBaseMs: 1, backoffMaxMs: 2 },
42+
})
43+
44+
expect(result).toBe(expectedState)
45+
expect(runSpy).toHaveBeenCalledTimes(2)
46+
})
47+
48+
it('stops after max retries are exhausted', async () => {
49+
const runSpy = spyOn(runModule, 'runOnce').mockRejectedValue(
50+
new NetworkError('offline', ErrorCodes.NETWORK_ERROR),
51+
)
52+
53+
await expect(
54+
run({
55+
...baseOptions,
56+
retry: { maxRetries: 1, backoffBaseMs: 1, backoffMaxMs: 1 },
57+
}),
58+
).rejects.toBeInstanceOf(NetworkError)
59+
60+
// Initial attempt + one retry
61+
expect(runSpy).toHaveBeenCalledTimes(2)
62+
})
63+
64+
it('does not retry non-network errors', async () => {
65+
const error = new Error('boom')
66+
const runSpy = spyOn(runModule, 'runOnce').mockRejectedValue(error)
67+
68+
await expect(
69+
run({
70+
...baseOptions,
71+
retry: { maxRetries: 3, backoffBaseMs: 1, backoffMaxMs: 1 },
72+
}),
73+
).rejects.toBe(error)
74+
75+
expect(runSpy).toHaveBeenCalledTimes(1)
76+
})
77+
78+
it('skips retry when retry is false even for retryable errors', async () => {
79+
const runSpy = spyOn(runModule, 'runOnce').mockRejectedValue(
80+
new NetworkError('offline', ErrorCodes.NETWORK_ERROR),
81+
)
82+
83+
await expect(
84+
run({
85+
...baseOptions,
86+
retry: false,
87+
}),
88+
).rejects.toBeInstanceOf(NetworkError)
89+
90+
expect(runSpy).toHaveBeenCalledTimes(1)
91+
})
92+
93+
it('retries when provided custom retryableErrorCodes set', async () => {
94+
const expectedState = { output: { type: 'success' } } as RunState
95+
const runSpy = spyOn(runModule, 'runOnce')
96+
.mockRejectedValueOnce(
97+
new NetworkError('temporary', ErrorCodes.SERVER_ERROR),
98+
)
99+
.mockResolvedValueOnce(expectedState)
100+
101+
const result = await run({
102+
...baseOptions,
103+
retry: {
104+
backoffBaseMs: 1,
105+
backoffMaxMs: 2,
106+
retryableErrorCodes: new Set([ErrorCodes.SERVER_ERROR]),
107+
},
108+
})
109+
110+
expect(result).toBe(expectedState)
111+
expect(runSpy).toHaveBeenCalledTimes(2)
112+
})
113+
114+
it('honors abort controller during backoff', async () => {
115+
const runSpy = spyOn(runModule, 'runOnce').mockRejectedValue(
116+
new NetworkError('offline', ErrorCodes.NETWORK_ERROR),
117+
)
118+
const controller = new AbortController()
119+
120+
const promise = run({
121+
...baseOptions,
122+
retry: { backoffBaseMs: 20, backoffMaxMs: 20 },
123+
abortController: controller,
124+
})
125+
126+
controller.abort('cancelled')
127+
128+
await expect(promise).rejects.toHaveProperty('name', 'AbortError')
129+
expect(runSpy).toHaveBeenCalledTimes(1)
130+
})
131+
})

sdk/src/impl/database.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,16 @@ export async function getUserInfoFromApiKey<T extends UserColumn>(
6868
throw new NetworkError('Network request failed', ErrorCodes.NETWORK_ERROR, undefined, error)
6969
}
7070

71-
if (response.status === 401 || response.status === 403) {
71+
if (response.status === 401 || response.status === 403 || response.status === 404) {
7272
logger.error(
7373
{ apiKey, fields, status: response.status },
7474
'getUserInfoFromApiKey authentication failed',
7575
)
7676
// Don't cache auth failures - allow retry with potentially updated credentials
7777
delete userInfoCache[apiKey]
78-
throw new AuthenticationError('Authentication failed', response.status)
78+
// If the server returns 404 for invalid credentials, surface as 401 to callers
79+
const normalizedStatus = response.status === 404 ? 401 : response.status
80+
throw new AuthenticationError('Authentication failed', normalizedStatus)
7981
}
8082

8183
if (response.status >= 500 && response.status <= 599) {

sdk/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ export type * from '../../common/src/types/json'
22
export type * from '../../common/src/types/messages/codebuff-message'
33
export type * from '../../common/src/types/messages/data-content'
44
export type * from '../../common/src/types/print-mode'
5-
export type * from './run'
5+
export { run } from './run'
6+
export type { RunOptions, RetryOptions } from './run'
67
// Agent type exports
78
export type { AgentDefinition } from '../../common/src/templates/initial-agents-dir/types/agent-definition'
89
export type { ToolName } from '../../common/src/tools/constants'

0 commit comments

Comments
 (0)