Skip to content

Commit 0c08783

Browse files
committed
Fix Freebuff 429 error messaging
1 parent aa6555f commit 0c08783

4 files changed

Lines changed: 228 additions & 0 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@ import { IS_FREEBUFF } from '../../utils/constants'
1212
import { processBashContext } from '../../utils/bash-context-processor'
1313
import { markRunningAgentsAsCancelled } from '../../utils/block-operations'
1414
import {
15+
getApiErrorDisplayMessage,
1516
getCountryBlockFromFreeModeError,
1617
getFreebuffGateErrorKind,
1718
isOutOfCreditsError,
1819
isFreeModeUnavailableError,
20+
isFreeModeRateLimitedError,
21+
isRateLimitError,
1922
OUT_OF_CREDITS_MESSAGE,
2023
FREE_MODE_UNAVAILABLE_MESSAGE,
24+
FREEBUFF_RATE_LIMIT_MESSAGE,
2125
} from '../../utils/error-handling'
2226
import { formatElapsedTime } from '../../utils/format-elapsed-time'
2327
import { processImagesForMessage } from '../../utils/image-processor'
@@ -417,6 +421,16 @@ export const handleRunCompletion = (params: {
417421
return
418422
}
419423

424+
if (
425+
IS_FREEBUFF &&
426+
isRateLimitError(output) &&
427+
!isFreeModeRateLimitedError(output)
428+
) {
429+
updater.setError(FREEBUFF_RATE_LIMIT_MESSAGE)
430+
finalizeAfterError()
431+
return
432+
}
433+
420434
// Pass the raw error message to setError (displayed in UserErrorBanner without additional wrapper formatting)
421435
updater.setError(output.message ?? DEFAULT_RUN_OUTPUT_ERROR_MESSAGE)
422436

@@ -517,6 +531,18 @@ export const handleRunError = (params: {
517531
return
518532
}
519533

534+
if (IS_FREEBUFF && isFreeModeRateLimitedError(error)) {
535+
updater.setError(
536+
getApiErrorDisplayMessage(error) || 'An unexpected error occurred',
537+
)
538+
return
539+
}
540+
541+
if (IS_FREEBUFF && isRateLimitError(error)) {
542+
updater.setError(FREEBUFF_RATE_LIMIT_MESSAGE)
543+
return
544+
}
545+
520546
// Use setError for all errors so they display in UserErrorBanner consistently
521547
const errorMessage = errorInfo.message || 'An unexpected error occurred'
522548
updater.setError(errorMessage)

cli/src/utils/__tests__/error-handling.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { describe, test, expect } from 'bun:test'
22

33
import {
4+
getApiErrorDisplayMessage,
5+
getApiErrorResponseMessage,
46
isOutOfCreditsError,
57
isFreeModeUnavailableError,
8+
isFreeModeRateLimitedError,
9+
isRateLimitError,
610
getCountryBlockFromFreeModeError,
711
OUT_OF_CREDITS_MESSAGE,
812
FREE_MODE_UNAVAILABLE_MESSAGE,
13+
FREEBUFF_RATE_LIMIT_MESSAGE,
914
createErrorMessage,
1015
} from '../error-handling'
1116

@@ -115,6 +120,138 @@ describe('error-handling', () => {
115120
})
116121
})
117122

123+
describe('isRateLimitError', () => {
124+
test('returns true for error with statusCode 429', () => {
125+
const error = { statusCode: 429, message: 'Too Many Requests' }
126+
expect(isRateLimitError(error)).toBe(true)
127+
})
128+
129+
test('returns true for thrown API error with status 429', () => {
130+
const error = { status: 429, message: 'Too Many Requests' }
131+
expect(isRateLimitError(error)).toBe(true)
132+
})
133+
134+
test('returns true for retry-wrapped API errors with nested 429 statusCode', () => {
135+
expect(
136+
isRateLimitError({
137+
message: 'Failed after 4 attempts. Last error: Too Many Requests',
138+
lastError: {
139+
statusCode: 429,
140+
message: 'Too Many Requests',
141+
},
142+
}),
143+
).toBe(true)
144+
})
145+
146+
test('returns false for non-429 status codes', () => {
147+
expect(isRateLimitError({ statusCode: 402 })).toBe(false)
148+
expect(isRateLimitError({ statusCode: 500 })).toBe(false)
149+
})
150+
151+
test('returns false for string statusCode', () => {
152+
expect(isRateLimitError({ statusCode: '429' })).toBe(false)
153+
})
154+
})
155+
156+
describe('isFreeModeRateLimitedError', () => {
157+
test('returns true for typed free mode rate limit errors', () => {
158+
expect(
159+
isFreeModeRateLimitedError({
160+
statusCode: 429,
161+
error: 'free_mode_rate_limited',
162+
}),
163+
).toBe(true)
164+
})
165+
166+
test('returns true when AI SDK API errors keep the code in responseBody', () => {
167+
expect(
168+
isFreeModeRateLimitedError({
169+
statusCode: 429,
170+
message: 'Too Many Requests',
171+
responseBody: JSON.stringify({
172+
error: 'free_mode_rate_limited',
173+
message:
174+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.',
175+
}),
176+
}),
177+
).toBe(true)
178+
})
179+
180+
test('returns true for retry-wrapped API errors with responseBody details', () => {
181+
expect(
182+
isFreeModeRateLimitedError({
183+
message: 'Failed after 4 attempts. Last error: Too Many Requests',
184+
lastError: {
185+
statusCode: 429,
186+
message: 'Too Many Requests',
187+
responseBody: JSON.stringify({
188+
error: 'free_mode_rate_limited',
189+
message:
190+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.',
191+
}),
192+
},
193+
}),
194+
).toBe(true)
195+
})
196+
197+
test('returns false for untyped provider rate limit errors', () => {
198+
expect(isFreeModeRateLimitedError({ statusCode: 429 })).toBe(false)
199+
})
200+
})
201+
202+
describe('getApiErrorResponseMessage', () => {
203+
test('extracts the server message from API error responseBody', () => {
204+
const message =
205+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'
206+
207+
expect(
208+
getApiErrorResponseMessage({
209+
statusCode: 429,
210+
message: 'Too Many Requests',
211+
responseBody: JSON.stringify({
212+
error: 'free_mode_rate_limited',
213+
message,
214+
}),
215+
}),
216+
).toBe(message)
217+
})
218+
219+
test('returns undefined when responseBody has no parsed message', () => {
220+
expect(getApiErrorResponseMessage({ statusCode: 429 })).toBeUndefined()
221+
})
222+
})
223+
224+
describe('getApiErrorDisplayMessage', () => {
225+
test('prefers the server message from API error responseBody', () => {
226+
const message =
227+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'
228+
229+
expect(
230+
getApiErrorDisplayMessage({
231+
statusCode: 429,
232+
message: 'Too Many Requests',
233+
responseBody: JSON.stringify({
234+
error: 'free_mode_rate_limited',
235+
message,
236+
}),
237+
}),
238+
).toBe(message)
239+
})
240+
241+
test('falls back to top-level message for normalized API errors', () => {
242+
const message =
243+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'
244+
245+
expect(
246+
getApiErrorDisplayMessage({
247+
statusCode: 429,
248+
error: 'free_mode_rate_limited',
249+
message,
250+
}),
251+
).toBe(message)
252+
})
253+
})
254+
118255
describe('getCountryBlockFromFreeModeError', () => {
119256
test('extracts country block details from free-mode unavailable errors', () => {
120257
const error = {
@@ -177,6 +314,15 @@ describe('error-handling', () => {
177314
})
178315
})
179316

317+
describe('FREEBUFF_RATE_LIMIT_MESSAGE', () => {
318+
test('encourages retry without mentioning credits or payment', () => {
319+
const message = FREEBUFF_RATE_LIMIT_MESSAGE.toLowerCase()
320+
expect(message).toContain('try again')
321+
expect(message).not.toContain('credit')
322+
expect(message).not.toContain('pay')
323+
})
324+
})
325+
180326
describe('createErrorMessage', () => {
181327
test('creates message from Error object', () => {
182328
const error = new Error('Something went wrong')

cli/src/utils/error-handling.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { env } from '@codebuff/common/env'
2+
import { extractApiErrorDetails } from '@codebuff/common/util/error'
23

34
import type { ChatMessage } from '../types/chat'
45
import type {
@@ -61,6 +62,55 @@ export const isFreeModeUnavailableError = (error: unknown): boolean => {
6162
return false
6263
}
6364

65+
const getApiStatusCode = (error: unknown): number | undefined => {
66+
if (!error || typeof error !== 'object') return undefined
67+
const statusCode = (error as { statusCode?: unknown }).statusCode
68+
if (typeof statusCode === 'number') return statusCode
69+
const status = (error as { status?: unknown }).status
70+
if (typeof status === 'number') return status
71+
return extractApiErrorDetails(error).statusCode
72+
}
73+
74+
/**
75+
* Check if an error indicates rate limiting.
76+
* Agent outputs use statusCode; some thrown API errors use status or wrap the
77+
* API error in retry fields.
78+
*/
79+
export const isRateLimitError = (error: unknown): boolean => {
80+
return getApiStatusCode(error) === 429
81+
}
82+
83+
const getApiErrorCode = (error: unknown): string | undefined => {
84+
if (error && typeof error === 'object') {
85+
const errorCode = (error as { error?: unknown }).error
86+
if (typeof errorCode === 'string') return errorCode
87+
}
88+
return extractApiErrorDetails(error).errorCode
89+
}
90+
91+
export const getApiErrorResponseMessage = (
92+
error: unknown,
93+
): string | undefined => extractApiErrorDetails(error).message
94+
95+
export const getApiErrorDisplayMessage = (
96+
error: unknown,
97+
): string | undefined => {
98+
const parsedMessage = getApiErrorResponseMessage(error)
99+
if (parsedMessage) return parsedMessage
100+
101+
if (error && typeof error === 'object' && 'message' in error) {
102+
const message = (error as { message?: unknown }).message
103+
if (typeof message === 'string' && message.length > 0) return message
104+
}
105+
106+
return undefined
107+
}
108+
109+
export const isFreeModeRateLimitedError = (error: unknown): boolean => {
110+
if (!isRateLimitError(error)) return false
111+
return getApiErrorCode(error) === 'free_mode_rate_limited'
112+
}
113+
64114
export const getCountryBlockFromFreeModeError = (
65115
error: unknown,
66116
): {
@@ -134,6 +184,9 @@ export const getFreebuffGateErrorKind = (
134184

135185
export const OUT_OF_CREDITS_MESSAGE = `Out of credits. Please add credits at ${defaultAppUrl}/usage`
136186

187+
export const FREEBUFF_RATE_LIMIT_MESSAGE =
188+
'Freebuff is temporarily busy. Please try again in a moment.'
189+
137190
export const FREE_MODE_UNAVAILABLE_MESSAGE = IS_FREEBUFF
138191
? 'Freebuff is not available in your country.'
139192
: 'Free mode is not available in your country. You can use another mode to continue.'

docs/error-schema.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ isOutOfCreditsError(output) → shows OUT_OF_CREDITS_MESSAGE
161161

162162
// Checks statusCode === 403 && error === 'free_mode_unavailable'
163163
isFreeModeUnavailableError(output) → shows FREE_MODE_UNAVAILABLE_MESSAGE
164+
165+
// Freebuff only: checks statusCode === 429 after waiting-room/free-mode quota errors
166+
isRateLimitError(output) → shows FREEBUFF_RATE_LIMIT_MESSAGE
164167
```
165168

166169
For all other errors, the raw `output.message` is displayed in the `UserErrorBanner`.

0 commit comments

Comments
 (0)