Skip to content

Commit 3dac148

Browse files
[codex] Fix Freebuff 429 error messaging (#697)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent aa6555f commit 3dac148

4 files changed

Lines changed: 186 additions & 0 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { markRunningAgentsAsCancelled } from '../../utils/block-operations'
1414
import {
1515
getCountryBlockFromFreeModeError,
1616
getFreebuffGateErrorKind,
17+
getFreebuffRateLimitErrorMessage,
1718
isOutOfCreditsError,
1819
isFreeModeUnavailableError,
1920
OUT_OF_CREDITS_MESSAGE,
@@ -417,6 +418,15 @@ export const handleRunCompletion = (params: {
417418
return
418419
}
419420

421+
const freebuffRateLimitMessage = IS_FREEBUFF
422+
? getFreebuffRateLimitErrorMessage(output)
423+
: null
424+
if (freebuffRateLimitMessage) {
425+
updater.setError(freebuffRateLimitMessage)
426+
finalizeAfterError()
427+
return
428+
}
429+
420430
// Pass the raw error message to setError (displayed in UserErrorBanner without additional wrapper formatting)
421431
updater.setError(output.message ?? DEFAULT_RUN_OUTPUT_ERROR_MESSAGE)
422432

@@ -517,6 +527,14 @@ export const handleRunError = (params: {
517527
return
518528
}
519529

530+
const freebuffRateLimitMessage = IS_FREEBUFF
531+
? getFreebuffRateLimitErrorMessage(error)
532+
: null
533+
if (freebuffRateLimitMessage) {
534+
updater.setError(freebuffRateLimitMessage)
535+
return
536+
}
537+
520538
// Use setError for all errors so they display in UserErrorBanner consistently
521539
const errorMessage = errorInfo.message || 'An unexpected error occurred'
522540
updater.setError(errorMessage)

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

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

33
import {
4+
getFreebuffRateLimitErrorMessage,
45
isOutOfCreditsError,
56
isFreeModeUnavailableError,
67
getCountryBlockFromFreeModeError,
78
OUT_OF_CREDITS_MESSAGE,
89
FREE_MODE_UNAVAILABLE_MESSAGE,
10+
FREEBUFF_RATE_LIMIT_MESSAGE,
911
createErrorMessage,
1012
} from '../error-handling'
1113

@@ -115,6 +117,106 @@ describe('error-handling', () => {
115117
})
116118
})
117119

120+
describe('getFreebuffRateLimitErrorMessage', () => {
121+
test('returns the generic message for untyped 429 errors', () => {
122+
expect(
123+
getFreebuffRateLimitErrorMessage({
124+
statusCode: 429,
125+
message: 'Too Many Requests',
126+
}),
127+
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
128+
})
129+
130+
test('returns the generic message for thrown API errors with status 429', () => {
131+
expect(
132+
getFreebuffRateLimitErrorMessage({
133+
status: 429,
134+
message: 'Too Many Requests',
135+
}),
136+
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
137+
})
138+
139+
test('returns the generic message for retry-wrapped untyped 429 errors', () => {
140+
expect(
141+
getFreebuffRateLimitErrorMessage({
142+
message: 'Failed after 4 attempts. Last error: Too Many Requests',
143+
lastError: {
144+
statusCode: 429,
145+
message: 'Too Many Requests',
146+
},
147+
}),
148+
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
149+
})
150+
151+
test('returns null for non-429 status codes', () => {
152+
expect(getFreebuffRateLimitErrorMessage({ statusCode: 402 })).toBe(null)
153+
expect(getFreebuffRateLimitErrorMessage({ statusCode: 500 })).toBe(null)
154+
})
155+
156+
test('returns null for string statusCode', () => {
157+
expect(getFreebuffRateLimitErrorMessage({ statusCode: '429' })).toBe(
158+
null,
159+
)
160+
})
161+
162+
test('preserves normalized free mode quota messages', () => {
163+
const message =
164+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'
165+
166+
expect(
167+
getFreebuffRateLimitErrorMessage({
168+
statusCode: 429,
169+
error: 'free_mode_rate_limited',
170+
message,
171+
}),
172+
).toBe(message)
173+
})
174+
175+
test('preserves responseBody free mode quota messages', () => {
176+
const message =
177+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'
178+
179+
expect(
180+
getFreebuffRateLimitErrorMessage({
181+
statusCode: 429,
182+
message: 'Too Many Requests',
183+
responseBody: JSON.stringify({
184+
error: 'free_mode_rate_limited',
185+
message,
186+
}),
187+
}),
188+
).toBe(message)
189+
})
190+
191+
test('preserves retry-wrapped free mode quota messages', () => {
192+
const message =
193+
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'
194+
195+
expect(
196+
getFreebuffRateLimitErrorMessage({
197+
message: 'Failed after 4 attempts. Last error: Too Many Requests',
198+
lastError: {
199+
statusCode: 429,
200+
message: 'Too Many Requests',
201+
responseBody: JSON.stringify({
202+
error: 'free_mode_rate_limited',
203+
message,
204+
}),
205+
},
206+
}),
207+
).toBe(message)
208+
})
209+
210+
test('falls back to the generic message when typed quota errors have no message', () => {
211+
expect(
212+
getFreebuffRateLimitErrorMessage({
213+
statusCode: 429,
214+
error: 'free_mode_rate_limited',
215+
}),
216+
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
217+
})
218+
})
219+
118220
describe('getCountryBlockFromFreeModeError', () => {
119221
test('extracts country block details from free-mode unavailable errors', () => {
120222
const error = {
@@ -177,6 +279,15 @@ describe('error-handling', () => {
177279
})
178280
})
179281

282+
describe('FREEBUFF_RATE_LIMIT_MESSAGE', () => {
283+
test('encourages retry without mentioning credits or payment', () => {
284+
const message = FREEBUFF_RATE_LIMIT_MESSAGE.toLowerCase()
285+
expect(message).toContain('try again')
286+
expect(message).not.toContain('credit')
287+
expect(message).not.toContain('pay')
288+
})
289+
})
290+
180291
describe('createErrorMessage', () => {
181292
test('creates message from Error object', () => {
182293
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 getTopLevelApiErrorDetails = (
66+
error: unknown,
67+
): {
68+
statusCode?: number
69+
errorCode?: string
70+
message?: string
71+
} => {
72+
if (!error || typeof error !== 'object') return {}
73+
const statusCode = (error as { statusCode?: unknown }).statusCode
74+
const status = (error as { status?: unknown }).status
75+
const errorCode = (error as { error?: unknown }).error
76+
const message = (error as { message?: unknown }).message
77+
const resolvedStatusCode =
78+
typeof statusCode === 'number'
79+
? statusCode
80+
: typeof status === 'number'
81+
? status
82+
: undefined
83+
84+
return {
85+
...(resolvedStatusCode !== undefined && { statusCode: resolvedStatusCode }),
86+
...(typeof errorCode === 'string' && { errorCode }),
87+
...(typeof message === 'string' && message.length > 0 && { message }),
88+
}
89+
}
90+
91+
const getCliApiErrorDetails = (error: unknown) => {
92+
const parsed = extractApiErrorDetails(error)
93+
const topLevel = getTopLevelApiErrorDetails(error)
94+
95+
return {
96+
statusCode: topLevel.statusCode ?? parsed.statusCode,
97+
errorCode: topLevel.errorCode ?? parsed.errorCode,
98+
// Prefer responseBody messages over top-level HTTP status text.
99+
message: parsed.message ?? topLevel.message,
100+
}
101+
}
102+
103+
export const getFreebuffRateLimitErrorMessage = (
104+
error: unknown,
105+
): string | null => {
106+
const details = getCliApiErrorDetails(error)
107+
if (details.statusCode !== 429) return null
108+
if (details.errorCode === 'free_mode_rate_limited') {
109+
return details.message ?? FREEBUFF_RATE_LIMIT_MESSAGE
110+
}
111+
return FREEBUFF_RATE_LIMIT_MESSAGE
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ 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 errors
166+
getFreebuffRateLimitErrorMessage(output)
167+
preserves typed quota messages or shows FREEBUFF_RATE_LIMIT_MESSAGE
164168
```
165169

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

0 commit comments

Comments
 (0)