Skip to content

Commit dabe26a

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

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

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

Lines changed: 28 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+
getApiErrorResponseMessage,
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,20 @@ export const handleRunError = (params: {
517531
return
518532
}
519533

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

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

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

33
import {
4+
getApiErrorResponseMessage,
45
isOutOfCreditsError,
56
isFreeModeUnavailableError,
7+
isFreeModeRateLimitedError,
8+
isRateLimitError,
69
getCountryBlockFromFreeModeError,
710
OUT_OF_CREDITS_MESSAGE,
811
FREE_MODE_UNAVAILABLE_MESSAGE,
12+
FREEBUFF_RATE_LIMIT_MESSAGE,
913
createErrorMessage,
1014
} from '../error-handling'
1115

@@ -115,6 +119,107 @@ describe('error-handling', () => {
115119
})
116120
})
117121

122+
describe('isRateLimitError', () => {
123+
test('returns true for error with statusCode 429', () => {
124+
const error = { statusCode: 429, message: 'Too Many Requests' }
125+
expect(isRateLimitError(error)).toBe(true)
126+
})
127+
128+
test('returns true for thrown API error with status 429', () => {
129+
const error = { status: 429, message: 'Too Many Requests' }
130+
expect(isRateLimitError(error)).toBe(true)
131+
})
132+
133+
test('returns true for retry-wrapped API errors with nested 429 statusCode', () => {
134+
expect(
135+
isRateLimitError({
136+
message: 'Failed after 4 attempts. Last error: Too Many Requests',
137+
lastError: {
138+
statusCode: 429,
139+
message: 'Too Many Requests',
140+
},
141+
}),
142+
).toBe(true)
143+
})
144+
145+
test('returns false for non-429 status codes', () => {
146+
expect(isRateLimitError({ statusCode: 402 })).toBe(false)
147+
expect(isRateLimitError({ statusCode: 500 })).toBe(false)
148+
})
149+
150+
test('returns false for string statusCode', () => {
151+
expect(isRateLimitError({ statusCode: '429' })).toBe(false)
152+
})
153+
})
154+
155+
describe('isFreeModeRateLimitedError', () => {
156+
test('returns true for typed free mode rate limit errors', () => {
157+
expect(
158+
isFreeModeRateLimitedError({
159+
statusCode: 429,
160+
error: 'free_mode_rate_limited',
161+
}),
162+
).toBe(true)
163+
})
164+
165+
test('returns true when AI SDK API errors keep the code in responseBody', () => {
166+
expect(
167+
isFreeModeRateLimitedError({
168+
statusCode: 429,
169+
message: 'Too Many Requests',
170+
responseBody: JSON.stringify({
171+
error: 'free_mode_rate_limited',
172+
message:
173+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.',
174+
}),
175+
}),
176+
).toBe(true)
177+
})
178+
179+
test('returns true for retry-wrapped API errors with responseBody details', () => {
180+
expect(
181+
isFreeModeRateLimitedError({
182+
message: 'Failed after 4 attempts. Last error: Too Many Requests',
183+
lastError: {
184+
statusCode: 429,
185+
message: 'Too Many Requests',
186+
responseBody: JSON.stringify({
187+
error: 'free_mode_rate_limited',
188+
message:
189+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.',
190+
}),
191+
},
192+
}),
193+
).toBe(true)
194+
})
195+
196+
test('returns false for untyped provider rate limit errors', () => {
197+
expect(isFreeModeRateLimitedError({ statusCode: 429 })).toBe(false)
198+
})
199+
})
200+
201+
describe('getApiErrorResponseMessage', () => {
202+
test('extracts the server message from API error responseBody', () => {
203+
const message =
204+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'
205+
206+
expect(
207+
getApiErrorResponseMessage({
208+
statusCode: 429,
209+
message: 'Too Many Requests',
210+
responseBody: JSON.stringify({
211+
error: 'free_mode_rate_limited',
212+
message,
213+
}),
214+
}),
215+
).toBe(message)
216+
})
217+
218+
test('returns undefined when responseBody has no parsed message', () => {
219+
expect(getApiErrorResponseMessage({ statusCode: 429 })).toBeUndefined()
220+
})
221+
})
222+
118223
describe('getCountryBlockFromFreeModeError', () => {
119224
test('extracts country block details from free-mode unavailable errors', () => {
120225
const error = {
@@ -177,6 +282,15 @@ describe('error-handling', () => {
177282
})
178283
})
179284

285+
describe('FREEBUFF_RATE_LIMIT_MESSAGE', () => {
286+
test('encourages retry without mentioning credits or payment', () => {
287+
const message = FREEBUFF_RATE_LIMIT_MESSAGE.toLowerCase()
288+
expect(message).toContain('try again')
289+
expect(message).not.toContain('credit')
290+
expect(message).not.toContain('pay')
291+
})
292+
})
293+
180294
describe('createErrorMessage', () => {
181295
test('creates message from Error object', () => {
182296
const error = new Error('Something went wrong')

cli/src/utils/error-handling.ts

Lines changed: 39 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,41 @@ 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 isFreeModeRateLimitedError = (error: unknown): boolean => {
96+
if (!isRateLimitError(error)) return false
97+
return getApiErrorCode(error) === 'free_mode_rate_limited'
98+
}
99+
64100
export const getCountryBlockFromFreeModeError = (
65101
error: unknown,
66102
): {
@@ -134,6 +170,9 @@ export const getFreebuffGateErrorKind = (
134170

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

173+
export const FREEBUFF_RATE_LIMIT_MESSAGE =
174+
'Freebuff is temporarily busy. Please try again in a moment.'
175+
137176
export const FREE_MODE_UNAVAILABLE_MESSAGE = IS_FREEBUFF
138177
? 'Freebuff is not available in your country.'
139178
: '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)