From 782b3ab4792d8182f358a48edea09f8efe4419cd Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 16 May 2026 13:53:55 -0700 Subject: [PATCH] Fix Freebuff 429 error messaging --- cli/src/hooks/helpers/send-message.ts | 18 +++ .../utils/__tests__/error-handling.test.ts | 111 ++++++++++++++++++ cli/src/utils/error-handling.ts | 53 +++++++++ docs/error-schema.md | 4 + 4 files changed, 186 insertions(+) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 0265e9fdf6..d9e680316d 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -14,6 +14,7 @@ import { markRunningAgentsAsCancelled } from '../../utils/block-operations' import { getCountryBlockFromFreeModeError, getFreebuffGateErrorKind, + getFreebuffRateLimitErrorMessage, isOutOfCreditsError, isFreeModeUnavailableError, OUT_OF_CREDITS_MESSAGE, @@ -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) @@ -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) diff --git a/cli/src/utils/__tests__/error-handling.test.ts b/cli/src/utils/__tests__/error-handling.test.ts index 1900093268..28a43726c6 100644 --- a/cli/src/utils/__tests__/error-handling.test.ts +++ b/cli/src/utils/__tests__/error-handling.test.ts @@ -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' @@ -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 = { @@ -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') diff --git a/cli/src/utils/error-handling.ts b/cli/src/utils/error-handling.ts index 2d25ae14db..9adedc6d28 100644 --- a/cli/src/utils/error-handling.ts +++ b/cli/src/utils/error-handling.ts @@ -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 { @@ -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, ): { @@ -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.' diff --git a/docs/error-schema.md b/docs/error-schema.md index 5b66606844..3301efb759 100644 --- a/docs/error-schema.md +++ b/docs/error-schema.md @@ -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`.