Skip to content

Commit 09fa12b

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@654332905d2ad8fbf436c48d77067c119d206a95
1 parent ab5900a commit 09fa12b

6 files changed

Lines changed: 238 additions & 68 deletions

File tree

bun.lock

Lines changed: 46 additions & 52 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, test, expect } from 'bun:test'
2-
import { FREEBUFF_PROVIDER_USAGE_MESSAGE } from '@codebuff/common/constants/freebuff-errors'
2+
import {
3+
FREEBUFF_PROVIDER_USAGE_MESSAGE,
4+
FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
5+
FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
6+
} from '@codebuff/common/constants/freebuff-errors'
37

48
import {
59
getFreebuffRateLimitErrorMessage,
@@ -133,6 +137,17 @@ describe('error-handling', () => {
133137
})
134138

135139
describe('getFreebuffRateLimitErrorMessage', () => {
140+
test('shows the per-turn spend breaker copy verbatim, not a rate-limit framing', () => {
141+
expect(
142+
getFreebuffRateLimitErrorMessage({
143+
type: 'error',
144+
statusCode: 429,
145+
error: FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
146+
message: FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
147+
}),
148+
).toBe(FREEBUFF_TURN_SPEND_LIMIT_MESSAGE)
149+
})
150+
136151
test('returns the generic message for untyped 429 errors', () => {
137152
expect(
138153
getFreebuffRateLimitErrorMessage({

cli/src/utils/error-handling.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { FREEBUFF_PROVIDER_USAGE_ERROR_PATTERN } from '@codebuff/common/constants/freebuff-errors'
1+
import {
2+
FREEBUFF_PROVIDER_USAGE_ERROR_PATTERN,
3+
FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
4+
FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
5+
} from '@codebuff/common/constants/freebuff-errors'
26
import { env } from '@codebuff/common/env'
37
import { extractApiErrorDetails } from '@codebuff/common/util/error'
48
import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy'
@@ -128,6 +132,11 @@ export const getFreebuffRateLimitErrorMessage = (
128132
// retry countdown — show it verbatim.
129133
return details.message ?? FREEBUFF_RATE_LIMIT_MESSAGE
130134
}
135+
if (details.errorCode === FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE) {
136+
// The per-turn spend breaker. Its copy already says what happened and
137+
// what to do (send a new message); a rate-limit framing would be wrong.
138+
return details.message ?? FREEBUFF_TURN_SPEND_LIMIT_MESSAGE
139+
}
131140
// Other 429s (e.g. relayed upstream capacity errors) keep the branded
132141
// message but include the server detail so users aren't left guessing.
133142
// Only trust messages parsed from a server response body, or the curated

common/src/constants/freebuff-errors.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,17 @@ export const FREEBUFF_PROVIDER_USAGE_ERROR_PATTERN =
55
/** Shared copy keeps every Freebuff surface clear that the user is not billed. */
66
export const FREEBUFF_PROVIDER_USAGE_MESSAGE =
77
'Freebuff ran out of provider usage and needs a refill. This is on us, not your account.'
8+
9+
/**
10+
* The completions API's per-turn spend breaker (web/src/app/api/v1/chat/
11+
* completions/_post.ts, packages/billing/src/freebuff-turn-spend.ts): a 429
12+
* whose body is `{ error: 'turn_spend_limit', message }`. It is not a quota
13+
* and not transient — the same turn is refused again on every retry, because
14+
* its spend only ever grows — so every client stops retrying on sight and
15+
* shows the message as-is. A NEW message starts a new turn with a fresh
16+
* budget, which is what the copy says.
17+
*/
18+
export const FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE = 'turn_spend_limit'
19+
20+
export const FREEBUFF_TURN_SPEND_LIMIT_MESSAGE =
21+
'Something went wrong with this turn — it kept accumulating model usage well past what a single turn should use (this usually means an agent got stuck in a loop). Your session is fine: send a new message to continue from here.'
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* The client half of the per-turn spend breaker (packages/billing/src/
3+
* freebuff-turn-spend.ts): a 429 with `error: 'turn_spend_limit'` is final
4+
* for the turn, so the SDK must not spend the AI SDK's retry budget on it —
5+
* and must hand the server's own copy to the runtime unchanged.
6+
*/
7+
import {
8+
FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
9+
FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
10+
} from '@codebuff/common/constants/freebuff-errors'
11+
import { extractApiErrorDetails } from '@codebuff/common/util/error'
12+
import { APICallError, streamText } from 'ai'
13+
import { afterEach, describe, expect, test } from 'bun:test'
14+
15+
import { getModelForRequest } from '../model-provider'
16+
17+
const originalFetch = globalThis.fetch
18+
19+
afterEach(() => {
20+
globalThis.fetch = originalFetch
21+
})
22+
23+
function serve429(body: Record<string, unknown>): { calls: () => number } {
24+
let calls = 0
25+
globalThis.fetch = (async () => {
26+
calls += 1
27+
return new Response(JSON.stringify(body), {
28+
status: 429,
29+
headers: { 'content-type': 'application/json' },
30+
})
31+
}) as unknown as typeof fetch
32+
return { calls: () => calls }
33+
}
34+
35+
/** Runs one completion and returns whatever it failed with. */
36+
async function failureOf(maxRetries: number): Promise<unknown> {
37+
const result = streamText({
38+
model: getModelForRequest({ apiKey: 'k', model: 'openai/gpt-5.6-luna' }),
39+
messages: [{ role: 'user', content: 'hi' }],
40+
maxRetries,
41+
})
42+
let error: unknown
43+
try {
44+
for await (const part of result.stream) {
45+
if (part.type === 'error') error = (part as { error: unknown }).error
46+
}
47+
} catch (thrown) {
48+
error ??= thrown
49+
}
50+
await Promise.resolve(result.text).catch((thrown: unknown) => {
51+
error ??= thrown
52+
})
53+
return error
54+
}
55+
56+
describe('per-turn spend breaker on the SDK side', () => {
57+
test('a capped turn is refused once, not retried, and keeps the server copy', async () => {
58+
const server = serve429({
59+
error: FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
60+
message: FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
61+
})
62+
63+
const error = await failureOf(3)
64+
65+
// One request even with three retries on offer: the AI SDK only retries
66+
// an APICallError that says it is retryable, and this one says no.
67+
expect(server.calls()).toBe(1)
68+
expect(APICallError.isInstance(error)).toBe(true)
69+
const apiError = error as APICallError
70+
expect(apiError.isRetryable).toBe(false)
71+
expect(apiError.statusCode).toBe(429)
72+
expect(apiError.message).toBe(FREEBUFF_TURN_SPEND_LIMIT_MESSAGE)
73+
// What the runtime's error parser (run-agent-step.ts) reads off it: the
74+
// code reaches `AgentOutput.error`, the copy replaces "Agent run error: …".
75+
expect(extractApiErrorDetails(error)).toMatchObject({
76+
statusCode: 429,
77+
errorCode: FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
78+
message: FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
79+
})
80+
})
81+
82+
test('any other 429 is still retried', async () => {
83+
const server = serve429({ error: 'free_mode_rate_limited', message: 'slow down' })
84+
85+
await failureOf(1)
86+
87+
expect(server.calls()).toBe(2)
88+
}, 15_000)
89+
})

sdk/src/impl/model-provider.ts

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
import path from 'path'
77

88
import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok'
9+
import {
10+
FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
11+
FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
12+
} from '@codebuff/common/constants/freebuff-errors'
913
import { FREEBUFF_ACTING_USER_HEADER } from '@codebuff/common/constants/freebuff-models'
1014
import { isTransientNetworkError } from '@codebuff/common/util/error'
1115
import {
@@ -82,9 +86,59 @@ function notifyCapacityDeferralFromResponse(response: Response): void {
8286
.catch(() => {})
8387
}
8488

89+
function requestUrlOf(input: Parameters<typeof globalThis.fetch>[0]): string {
90+
return typeof input === 'string'
91+
? input
92+
: input instanceof URL
93+
? input.toString()
94+
: input.url
95+
}
96+
97+
/**
98+
* The per-turn spend breaker (HTTP 429, body `{ error: 'turn_spend_limit',
99+
* message }`) is final for THIS turn: its spend only grows, so the same run
100+
* id is refused again on every retry. Left to the AI SDK, which treats every
101+
* 429 as retryable, a capped turn asked four times over ~14s and then failed
102+
* as "Failed after 4 attempts. Last error: Too Many Requests" — which every
103+
* client read as an ordinary rate limit and answered with "wait a moment or
104+
* switch models", neither of which helps. Throwing a NON-retryable
105+
* APICallError stops the retry loop on the first refusal, and carrying the
106+
* body lets the runtime's error parser hand the server's own copy (and the
107+
* `turn_spend_limit` code) to the client unchanged.
108+
*/
109+
async function throwIfTurnSpendCapped(
110+
response: Response,
111+
url: string,
112+
): Promise<void> {
113+
if (response.status !== 429) return
114+
const text = await response
115+
.clone()
116+
.text()
117+
.catch(() => '')
118+
let body: { error?: unknown; message?: unknown } | null = null
119+
try {
120+
body = JSON.parse(text)
121+
} catch {
122+
return
123+
}
124+
if (body?.error !== FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE) return
125+
throw new APICallError({
126+
message:
127+
typeof body.message === 'string' && body.message
128+
? body.message
129+
: FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
130+
url,
131+
requestBodyValues: {},
132+
statusCode: response.status,
133+
responseBody: text,
134+
isRetryable: false,
135+
})
136+
}
137+
85138
/**
86139
* Wrap global fetch so transient connection failures (socket closed/reset,
87-
* connection refused) are rethrown as retryable APICallErrors.
140+
* connection refused) are rethrown as retryable APICallErrors, and a capped
141+
* turn's 429 as a non-retryable one (see throwIfTurnSpendCapped).
88142
*
89143
* Bun's fetch throws these as plain Errors ("The socket connection was closed
90144
* unexpectedly...", code ECONNRESET/ConnectionClosed), which the AI SDK does
@@ -96,21 +150,15 @@ function notifyCapacityDeferralFromResponse(response: Response): void {
96150
function fetchWithRetryableNetworkErrors(
97151
...args: Parameters<typeof globalThis.fetch>
98152
): ReturnType<typeof globalThis.fetch> {
99-
return globalThis
100-
.fetch(...args)
101-
.then((response) => {
153+
const url = requestUrlOf(args[0])
154+
return globalThis.fetch(...args).then(
155+
async (response) => {
102156
notifyCapacityDeferralFromResponse(response)
157+
await throwIfTurnSpendCapped(response, url)
103158
return response
104-
})
105-
.catch((error: unknown) => {
159+
},
160+
(error: unknown) => {
106161
if (isTransientNetworkError(error)) {
107-
const input = args[0]
108-
const url =
109-
typeof input === 'string'
110-
? input
111-
: input instanceof URL
112-
? input.toString()
113-
: input.url
114162
throw new APICallError({
115163
message: error instanceof Error ? error.message : String(error),
116164
cause: error,
@@ -120,7 +168,8 @@ function fetchWithRetryableNetworkErrors(
120168
})
121169
}
122170
throw error
123-
})
171+
},
172+
)
124173
}
125174

126175
/**

0 commit comments

Comments
 (0)