Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cli/src/hooks/helpers/send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { markRunningAgentsAsCancelled } from '../../utils/block-operations'
import {
getCountryBlockFromFreeModeError,
getFreebuffGateErrorKind,
getFreebuffRateLimitErrorMessage,
isOutOfCreditsError,
isFreeModeUnavailableError,
OUT_OF_CREDITS_MESSAGE,
Expand Down Expand Up @@ -417,6 +418,15 @@ export const handleRunCompletion = (params: {
return
}

const freebuffRateLimitMessage = IS_FREEBUFF
? getFreebuffRateLimitErrorMessage(output)
: null
if (freebuffRateLimitMessage) {
updater.setError(freebuffRateLimitMessage)
finalizeAfterError()
return
}

// Pass the raw error message to setError (displayed in UserErrorBanner without additional wrapper formatting)
updater.setError(output.message ?? DEFAULT_RUN_OUTPUT_ERROR_MESSAGE)

Expand Down Expand Up @@ -517,6 +527,14 @@ export const handleRunError = (params: {
return
}

const freebuffRateLimitMessage = IS_FREEBUFF
? getFreebuffRateLimitErrorMessage(error)
: null
if (freebuffRateLimitMessage) {
updater.setError(freebuffRateLimitMessage)
return
}

// Use setError for all errors so they display in UserErrorBanner consistently
const errorMessage = errorInfo.message || 'An unexpected error occurred'
updater.setError(errorMessage)
Expand Down
111 changes: 111 additions & 0 deletions cli/src/utils/__tests__/error-handling.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, test, expect } from 'bun:test'

import {
getFreebuffRateLimitErrorMessage,
isOutOfCreditsError,
isFreeModeUnavailableError,
getCountryBlockFromFreeModeError,
OUT_OF_CREDITS_MESSAGE,
FREE_MODE_UNAVAILABLE_MESSAGE,
FREEBUFF_RATE_LIMIT_MESSAGE,
createErrorMessage,
} from '../error-handling'

Expand Down Expand Up @@ -115,6 +117,106 @@ describe('error-handling', () => {
})
})

describe('getFreebuffRateLimitErrorMessage', () => {
test('returns the generic message for untyped 429 errors', () => {
expect(
getFreebuffRateLimitErrorMessage({
statusCode: 429,
message: 'Too Many Requests',
}),
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
})

test('returns the generic message for thrown API errors with status 429', () => {
expect(
getFreebuffRateLimitErrorMessage({
status: 429,
message: 'Too Many Requests',
}),
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
})

test('returns the generic message for retry-wrapped untyped 429 errors', () => {
expect(
getFreebuffRateLimitErrorMessage({
message: 'Failed after 4 attempts. Last error: Too Many Requests',
lastError: {
statusCode: 429,
message: 'Too Many Requests',
},
}),
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
})

test('returns null for non-429 status codes', () => {
expect(getFreebuffRateLimitErrorMessage({ statusCode: 402 })).toBe(null)
expect(getFreebuffRateLimitErrorMessage({ statusCode: 500 })).toBe(null)
})

test('returns null for string statusCode', () => {
expect(getFreebuffRateLimitErrorMessage({ statusCode: '429' })).toBe(
null,
)
})

test('preserves normalized free mode quota messages', () => {
const message =
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'

expect(
getFreebuffRateLimitErrorMessage({
statusCode: 429,
error: 'free_mode_rate_limited',
message,
}),
).toBe(message)
})

test('preserves responseBody free mode quota messages', () => {
const message =
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'

expect(
getFreebuffRateLimitErrorMessage({
statusCode: 429,
message: 'Too Many Requests',
responseBody: JSON.stringify({
error: 'free_mode_rate_limited',
message,
}),
}),
).toBe(message)
})

test('preserves retry-wrapped free mode quota messages', () => {
const message =
'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.'

expect(
getFreebuffRateLimitErrorMessage({
message: 'Failed after 4 attempts. Last error: Too Many Requests',
lastError: {
statusCode: 429,
message: 'Too Many Requests',
responseBody: JSON.stringify({
error: 'free_mode_rate_limited',
message,
}),
},
}),
).toBe(message)
})

test('falls back to the generic message when typed quota errors have no message', () => {
expect(
getFreebuffRateLimitErrorMessage({
statusCode: 429,
error: 'free_mode_rate_limited',
}),
).toBe(FREEBUFF_RATE_LIMIT_MESSAGE)
})
})

describe('getCountryBlockFromFreeModeError', () => {
test('extracts country block details from free-mode unavailable errors', () => {
const error = {
Expand Down Expand Up @@ -177,6 +279,15 @@ describe('error-handling', () => {
})
})

describe('FREEBUFF_RATE_LIMIT_MESSAGE', () => {
test('encourages retry without mentioning credits or payment', () => {
const message = FREEBUFF_RATE_LIMIT_MESSAGE.toLowerCase()
expect(message).toContain('try again')
expect(message).not.toContain('credit')
expect(message).not.toContain('pay')
})
})

describe('createErrorMessage', () => {
test('creates message from Error object', () => {
const error = new Error('Something went wrong')
Expand Down
53 changes: 53 additions & 0 deletions cli/src/utils/error-handling.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from '@codebuff/common/env'
import { extractApiErrorDetails } from '@codebuff/common/util/error'

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

const getTopLevelApiErrorDetails = (
error: unknown,
): {
statusCode?: number
errorCode?: string
message?: string
} => {
if (!error || typeof error !== 'object') return {}
const statusCode = (error as { statusCode?: unknown }).statusCode
const status = (error as { status?: unknown }).status
const errorCode = (error as { error?: unknown }).error
const message = (error as { message?: unknown }).message
const resolvedStatusCode =
typeof statusCode === 'number'
? statusCode
: typeof status === 'number'
? status
: undefined

return {
...(resolvedStatusCode !== undefined && { statusCode: resolvedStatusCode }),
...(typeof errorCode === 'string' && { errorCode }),
...(typeof message === 'string' && message.length > 0 && { message }),
}
}

const getCliApiErrorDetails = (error: unknown) => {
const parsed = extractApiErrorDetails(error)
const topLevel = getTopLevelApiErrorDetails(error)

return {
statusCode: topLevel.statusCode ?? parsed.statusCode,
errorCode: topLevel.errorCode ?? parsed.errorCode,
// Prefer responseBody messages over top-level HTTP status text.
message: parsed.message ?? topLevel.message,
}
}

export const getFreebuffRateLimitErrorMessage = (
error: unknown,
): string | null => {
const details = getCliApiErrorDetails(error)
if (details.statusCode !== 429) return null
if (details.errorCode === 'free_mode_rate_limited') {
return details.message ?? FREEBUFF_RATE_LIMIT_MESSAGE
}
return FREEBUFF_RATE_LIMIT_MESSAGE
}

export const getCountryBlockFromFreeModeError = (
error: unknown,
): {
Expand Down Expand Up @@ -134,6 +184,9 @@ export const getFreebuffGateErrorKind = (

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

export const FREEBUFF_RATE_LIMIT_MESSAGE =
'Freebuff is temporarily busy. Please try again in a moment.'

export const FREE_MODE_UNAVAILABLE_MESSAGE = IS_FREEBUFF
? 'Freebuff is not available in your country.'
: 'Free mode is not available in your country. You can use another mode to continue.'
Expand Down
4 changes: 4 additions & 0 deletions docs/error-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ isOutOfCreditsError(output) → shows OUT_OF_CREDITS_MESSAGE

// Checks statusCode === 403 && error === 'free_mode_unavailable'
isFreeModeUnavailableError(output) → shows FREE_MODE_UNAVAILABLE_MESSAGE

// Freebuff only: checks statusCode === 429 after waiting-room errors
getFreebuffRateLimitErrorMessage(output)
→ preserves typed quota messages or shows FREEBUFF_RATE_LIMIT_MESSAGE
```

For all other errors, the raw `output.message` is displayed in the `UserErrorBanner`.
Expand Down
Loading