Skip to content

Commit 476bfd7

Browse files
committed
Show better error message if someone uses freebuff in unsupported country
1 parent f207306 commit 476bfd7

File tree

3 files changed

+187
-1
lines changed

3 files changed

+187
-1
lines changed

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { describe, test, expect } from 'bun:test'
22

33
import {
44
isOutOfCreditsError,
5+
isFreeModeUnavailableError,
56
OUT_OF_CREDITS_MESSAGE,
7+
FREE_MODE_UNAVAILABLE_MESSAGE,
68
createErrorMessage,
79
} from '../error-handling'
810

@@ -66,6 +68,50 @@ describe('error-handling', () => {
6668
})
6769
})
6870

71+
describe('isFreeModeUnavailableError', () => {
72+
test('returns true for error with statusCode 403 and error free_mode_unavailable', () => {
73+
const error = { statusCode: 403, error: 'free_mode_unavailable', message: 'Free mode is not available in your country.' }
74+
expect(isFreeModeUnavailableError(error)).toBe(true)
75+
})
76+
77+
test('returns false for 403 without error field', () => {
78+
const error = { statusCode: 403, message: 'Forbidden' }
79+
expect(isFreeModeUnavailableError(error)).toBe(false)
80+
})
81+
82+
test('returns false for 403 with different error code', () => {
83+
const error = { statusCode: 403, error: 'account_suspended', message: 'Suspended' }
84+
expect(isFreeModeUnavailableError(error)).toBe(false)
85+
})
86+
87+
test('returns false for non-403 status with free_mode_unavailable error', () => {
88+
const error = { statusCode: 400, error: 'free_mode_unavailable', message: 'Bad request' }
89+
expect(isFreeModeUnavailableError(error)).toBe(false)
90+
})
91+
92+
test('returns false for null', () => {
93+
expect(isFreeModeUnavailableError(null)).toBe(false)
94+
})
95+
96+
test('returns false for undefined', () => {
97+
expect(isFreeModeUnavailableError(undefined)).toBe(false)
98+
})
99+
100+
test('returns false for plain Error object', () => {
101+
expect(isFreeModeUnavailableError(new Error('Forbidden'))).toBe(false)
102+
})
103+
})
104+
105+
describe('FREE_MODE_UNAVAILABLE_MESSAGE', () => {
106+
test('mentions free mode', () => {
107+
expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain('free mode')
108+
})
109+
110+
test('mentions paid plan', () => {
111+
expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain('paid plan')
112+
})
113+
})
114+
69115
describe('OUT_OF_CREDITS_MESSAGE', () => {
70116
test('contains usage URL', () => {
71117
expect(OUT_OF_CREDITS_MESSAGE).toContain('/usage')

sdk/src/__tests__/run-cancellation.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,121 @@ describe('Run Cancellation Handling', () => {
184184
expect(messageHistory.length).toBe(3)
185185
})
186186

187+
it('extracts error code and message from AI SDK responseBody on 403', async () => {
188+
spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({
189+
id: 'user-123',
190+
email: 'test@example.com',
191+
discord_id: null,
192+
referral_code: null,
193+
stripe_customer_id: null,
194+
banned: false,
195+
})
196+
spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null)
197+
spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1')
198+
spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined)
199+
spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1')
200+
201+
// Simulate AI SDK's AI_APICallError with responseBody (what the server returns for free_mode_unavailable)
202+
const apiError = new Error('Forbidden') as Error & { statusCode: number; responseBody: string }
203+
apiError.statusCode = 403
204+
apiError.responseBody = JSON.stringify({
205+
error: 'free_mode_unavailable',
206+
message: 'Free mode is not available in your country.',
207+
})
208+
209+
spyOn(mainPromptModule, 'callMainPrompt').mockRejectedValue(apiError)
210+
211+
const client = new CodebuffClient({
212+
apiKey: 'test-key',
213+
})
214+
215+
const result = await client.run({
216+
agent: 'base2',
217+
prompt: 'hello',
218+
})
219+
220+
expect(result.output.type).toBe('error')
221+
const output = result.output as { type: 'error'; message: string; statusCode?: number; error?: string }
222+
// Should use the message from the response body, not the generic "Forbidden"
223+
expect(output.message).toBe('Free mode is not available in your country.')
224+
expect(output.statusCode).toBe(403)
225+
// Should propagate the error code so isFreeModeUnavailableError can match
226+
expect(output.error).toBe('free_mode_unavailable')
227+
})
228+
229+
it('extracts error code from responseBody for account_suspended 403', async () => {
230+
spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({
231+
id: 'user-123',
232+
email: 'test@example.com',
233+
discord_id: null,
234+
referral_code: null,
235+
stripe_customer_id: null,
236+
banned: false,
237+
})
238+
spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null)
239+
spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1')
240+
spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined)
241+
spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1')
242+
243+
const apiError = new Error('Forbidden') as Error & { statusCode: number; responseBody: string }
244+
apiError.statusCode = 403
245+
apiError.responseBody = JSON.stringify({
246+
error: 'account_suspended',
247+
message: 'Your account has been suspended due to billing issues.',
248+
})
249+
250+
spyOn(mainPromptModule, 'callMainPrompt').mockRejectedValue(apiError)
251+
252+
const client = new CodebuffClient({
253+
apiKey: 'test-key',
254+
})
255+
256+
const result = await client.run({
257+
agent: 'base2',
258+
prompt: 'hello',
259+
})
260+
261+
const output = result.output as { type: 'error'; message: string; statusCode?: number; error?: string }
262+
expect(output.message).toBe('Your account has been suspended due to billing issues.')
263+
expect(output.statusCode).toBe(403)
264+
expect(output.error).toBe('account_suspended')
265+
})
266+
267+
it('falls back to error.message when responseBody is not valid JSON', async () => {
268+
spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({
269+
id: 'user-123',
270+
email: 'test@example.com',
271+
discord_id: null,
272+
referral_code: null,
273+
stripe_customer_id: null,
274+
banned: false,
275+
})
276+
spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null)
277+
spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1')
278+
spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined)
279+
spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1')
280+
281+
const apiError = new Error('Forbidden') as Error & { statusCode: number; responseBody: string }
282+
apiError.statusCode = 403
283+
apiError.responseBody = 'not valid json'
284+
285+
spyOn(mainPromptModule, 'callMainPrompt').mockRejectedValue(apiError)
286+
287+
const client = new CodebuffClient({
288+
apiKey: 'test-key',
289+
})
290+
291+
const result = await client.run({
292+
agent: 'base2',
293+
prompt: 'hello',
294+
})
295+
296+
const output = result.output as { type: 'error'; message: string; statusCode?: number; error?: string }
297+
expect(output.message).toBe('Forbidden')
298+
expect(output.statusCode).toBe(403)
299+
expect(output.error).toBeUndefined()
300+
})
301+
187302
it('preserves user message when callMainPrompt throws an error', async () => {
188303
spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({
189304
id: 'user-123',

sdk/src/run.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,15 +510,40 @@ async function runOnce({
510510
userId,
511511
signal: signal ?? new AbortController().signal,
512512
}).catch((error) => {
513-
const errorMessage =
513+
let errorMessage =
514514
error instanceof Error ? error.message : String(error ?? '')
515515
const statusCode = getErrorStatusCode(error)
516+
517+
// Extract structured error details from the API response body
518+
// (e.g., AI SDK's AI_APICallError includes a responseBody with the server's JSON response)
519+
let errorCode: string | undefined
520+
const responseBody =
521+
error && typeof error === 'object' && 'responseBody' in error
522+
? (error as { responseBody: unknown }).responseBody
523+
: undefined
524+
if (typeof responseBody === 'string') {
525+
try {
526+
const parsed: unknown = JSON.parse(responseBody)
527+
if (parsed && typeof parsed === 'object') {
528+
if ('error' in parsed && typeof (parsed as { error: unknown }).error === 'string') {
529+
errorCode = (parsed as { error: string }).error
530+
}
531+
if ('message' in parsed && typeof (parsed as { message: unknown }).message === 'string') {
532+
errorMessage = (parsed as { message: string }).message
533+
}
534+
}
535+
} catch {
536+
// responseBody wasn't valid JSON; keep original errorMessage
537+
}
538+
}
539+
516540
resolve({
517541
sessionState: getCancelledSessionState(errorMessage),
518542
output: {
519543
type: 'error',
520544
message: errorMessage,
521545
...(statusCode !== undefined && { statusCode }),
546+
...(errorCode !== undefined && { error: errorCode }),
522547
},
523548
})
524549
})

0 commit comments

Comments
 (0)