Skip to content

Commit f16c22f

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@ed25bbf4117d4365cdbc447d828b84f599f622b9
1 parent 91cd82d commit f16c22f

5 files changed

Lines changed: 175 additions & 20 deletions

File tree

‎bun.lock‎

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

‎sdk/src/impl/__tests__/provider-error-recovery.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ describe('classifyProviderErrorRecovery on constructed errors', () => {
240240
})
241241

242242
it('respects an explicit non-retryable verdict (the turn spend breaker)', () => {
243-
// model-provider.ts throwIfTurnSpendCapped: a 429 that retrying cannot fix.
243+
// model-provider.ts FINAL_REFUSALS: a 429 that retrying cannot fix.
244244
const error = apiError({
245245
statusCode: 429,
246246
isRetryable: false,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* The server answers HTTP 409 `{ error: 'session_superseded' }` when a start
3+
* was refunded (or the session was taken over). The row is gone, so every
4+
* retry gets the same answer. The AI SDK retries any 409 four times with
5+
* backoff, which added ~14s before the user saw the card telling them to start
6+
* a new session. The SDK must refuse it once and keep the server's copy.
7+
*/
8+
import { extractApiErrorDetails } from '@codebuff/common/util/error'
9+
import { APICallError, streamText } from 'ai'
10+
import { afterEach, describe, expect, test } from 'bun:test'
11+
12+
import { getModelForRequest } from '../model-provider'
13+
14+
const originalFetch = globalThis.fetch
15+
16+
afterEach(() => {
17+
globalThis.fetch = originalFetch
18+
})
19+
20+
const REFUNDED = {
21+
error: 'session_superseded',
22+
message:
23+
'This model purchase was refunded. Start a new session to try again.',
24+
}
25+
26+
function serve(
27+
status: number,
28+
body: Record<string, unknown> | string,
29+
): { calls: () => number } {
30+
let calls = 0
31+
globalThis.fetch = (async () => {
32+
calls += 1
33+
return new Response(
34+
typeof body === 'string' ? body : JSON.stringify(body),
35+
{ status, headers: { 'content-type': 'application/json' } },
36+
)
37+
}) as unknown as typeof fetch
38+
return { calls: () => calls }
39+
}
40+
41+
/** Runs one completion and returns whatever it failed with. */
42+
async function failureOf(maxRetries: number): Promise<unknown> {
43+
const result = streamText({
44+
model: getModelForRequest({ apiKey: 'k', model: 'openai/gpt-5.6-luna' }),
45+
messages: [{ role: 'user', content: 'hi' }],
46+
maxRetries,
47+
})
48+
let error: unknown
49+
try {
50+
for await (const part of result.stream) {
51+
if (part.type === 'error') error = (part as { error: unknown }).error
52+
}
53+
} catch (thrown) {
54+
error ??= thrown
55+
}
56+
await Promise.resolve(result.text).catch((thrown: unknown) => {
57+
error ??= thrown
58+
})
59+
return error
60+
}
61+
62+
describe('a refunded or superseded start (409 session_superseded)', () => {
63+
test('is refused once, not retried, and keeps the server copy and code', async () => {
64+
const server = serve(409, REFUNDED)
65+
66+
const error = await failureOf(3)
67+
68+
expect(server.calls()).toBe(1)
69+
expect(APICallError.isInstance(error)).toBe(true)
70+
const apiError = error as APICallError
71+
expect(apiError.isRetryable).toBe(false)
72+
expect(apiError.statusCode).toBe(409)
73+
expect(apiError.message).toBe(REFUNDED.message)
74+
// What every client classifies on: the CLI's superseded screen and
75+
// Desktop's session-ended state both key on this code.
76+
expect(extractApiErrorDetails(error)).toMatchObject({
77+
statusCode: 409,
78+
errorCode: 'session_superseded',
79+
message: REFUNDED.message,
80+
})
81+
})
82+
83+
test('falls back to its own copy when the body has no message', async () => {
84+
serve(409, { error: 'session_superseded' })
85+
86+
const error = (await failureOf(3)) as APICallError
87+
88+
expect(error.isRetryable).toBe(false)
89+
expect(error.message).toContain('Start a new session')
90+
})
91+
92+
test('any other 409 is still retried', async () => {
93+
const server = serve(409, {
94+
error: 'session_limit_reached',
95+
message: 'too many tabs',
96+
})
97+
98+
await failureOf(1)
99+
100+
expect(server.calls()).toBe(2)
101+
}, 15_000)
102+
103+
test('a 409 whose body is not JSON is still retried', async () => {
104+
const server = serve(409, 'Conflict')
105+
106+
await failureOf(1)
107+
108+
expect(server.calls()).toBe(2)
109+
}, 15_000)
110+
111+
test('session_superseded under any other status is not treated as final', async () => {
112+
const server = serve(503, REFUNDED)
113+
114+
await failureOf(1)
115+
116+
expect(server.calls()).toBe(2)
117+
}, 15_000)
118+
})

‎sdk/src/impl/model-provider.ts‎

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
1212
} from '@codebuff/common/constants/freebuff-errors'
1313
import { FREEBUFF_ACTING_USER_HEADER } from '@codebuff/common/constants/freebuff-models'
14+
import { FREEBUFF_GATE_CODES } from '@codebuff/common/types/freebuff-session'
1415
import { isAbortError, isFetchIdleTimeoutError, isTransientNetworkError } from '@codebuff/common/util/error'
1516
import {
1617
OpenAICompatibleChatLanguageModel,
@@ -255,22 +256,52 @@ export const BYOK_CONNECTION_FAILURE_MESSAGE =
255256
'Could not connect to the BYOK provider. Check the provider URL and network connection, then retry.'
256257

257258
/**
258-
* The per-turn spend breaker (HTTP 429, body `{ error: 'turn_spend_limit',
259-
* message }`) is final for THIS turn: its spend only grows, so the same run
260-
* id is refused again on every retry. Left to the AI SDK, which treats every
261-
* 429 as retryable, a capped turn asked four times over ~14s and then failed
262-
* as "Failed after 4 attempts. Last error: Too Many Requests" — which every
263-
* client read as an ordinary rate limit and answered with "wait a moment or
264-
* switch models", neither of which helps. Throwing a NON-retryable
265-
* APICallError stops the retry loop on the first refusal, and carrying the
266-
* body lets the runtime's error parser hand the server's own copy (and the
267-
* `turn_spend_limit` code) to the client unchanged.
259+
* Refusals the server makes on purpose and repeats IDENTICALLY on a retry,
260+
* each identified by its status AND its `error` code — never the status alone,
261+
* since 409 and 429 are also ordinary, retryable answers.
262+
*
263+
* Left to the AI SDK, which treats every 409 and 429 as retryable, each of
264+
* these was asked four times with backoff (~14s) and then failed as "Failed
265+
* after 4 attempts. Last error: …". Throwing a NON-retryable APICallError
266+
* stops the retry loop on the first refusal, and carrying the body lets the
267+
* runtime's error parser hand the server's own copy and code to the client
268+
* unchanged.
269+
*
270+
* - `turn_spend_limit` (429): the per-turn spend breaker is final for THIS
271+
* turn — its spend only grows, so the same run id is refused every time.
272+
* Clients read the retried failure as an ordinary rate limit and answered
273+
* it with "wait a moment or switch models", neither of which helps.
274+
* - `session_superseded` (409): the start was refunded (the Desktop purchase
275+
* claim, or a refund that closed admission) or the session was taken over
276+
* by another instance. The row is gone, so every retry gets the same 409;
277+
* the user waited ~14s for the card that tells them to start a new session
278+
* (5,392 runs / 2,230 users in the 72h to 2026-10-05).
268279
*/
269-
async function throwIfTurnSpendCapped(
280+
const FINAL_REFUSALS: readonly {
281+
status: number
282+
error: string
283+
/** Used only when the body carries no `message` of its own. */
284+
fallbackMessage: string
285+
}[] = [
286+
{
287+
status: 429,
288+
error: FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE,
289+
fallbackMessage: FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
290+
},
291+
{
292+
status: FREEBUFF_GATE_CODES.session_superseded.status,
293+
error: 'session_superseded',
294+
fallbackMessage:
295+
'This Freebuff session has ended. Start a new session to try again.',
296+
},
297+
]
298+
299+
async function throwIfFinalRefusal(
270300
response: Response,
271301
url: string,
272302
): Promise<void> {
273-
if (response.status !== 429) return
303+
// Only a status some refusal uses is worth reading the body for.
304+
if (!FINAL_REFUSALS.some((r) => r.status === response.status)) return
274305
const text = await response
275306
.clone()
276307
.text()
@@ -281,12 +312,15 @@ async function throwIfTurnSpendCapped(
281312
} catch {
282313
return
283314
}
284-
if (body?.error !== FREEBUFF_TURN_SPEND_LIMIT_ERROR_CODE) return
315+
const refusal = FINAL_REFUSALS.find(
316+
(r) => r.status === response.status && r.error === body?.error,
317+
)
318+
if (!refusal) return
285319
throw new APICallError({
286320
message:
287-
typeof body.message === 'string' && body.message
321+
typeof body?.message === 'string' && body.message
288322
? body.message
289-
: FREEBUFF_TURN_SPEND_LIMIT_MESSAGE,
323+
: refusal.fallbackMessage,
290324
url,
291325
requestBodyValues: {},
292326
statusCode: response.status,
@@ -297,8 +331,8 @@ async function throwIfTurnSpendCapped(
297331

298332
/**
299333
* Wrap global fetch so transient connection failures (socket closed/reset,
300-
* connection refused) are rethrown as retryable APICallErrors, and a capped
301-
* turn's 429 as a non-retryable one (see throwIfTurnSpendCapped).
334+
* connection refused) are rethrown as retryable APICallErrors, and the
335+
* server's final refusals as non-retryable ones (see FINAL_REFUSALS).
302336
*
303337
* Bun's fetch throws these as plain Errors ("The socket connection was closed
304338
* unexpectedly...", code ECONNRESET/ConnectionClosed), which the AI SDK does
@@ -314,7 +348,7 @@ function fetchWithRetryableNetworkErrors(
314348
return globalThis.fetch(...args).then(
315349
async (response) => {
316350
notifyCapacityDeferralFromResponse(response)
317-
await throwIfTurnSpendCapped(response, url)
351+
await throwIfFinalRefusal(response, url)
318352
return response
319353
},
320354
(error: unknown) => {

‎sdk/src/impl/stream-interruption.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@ export function classifyProviderErrorRecovery(params: {
249249
const { aborted, error } = params
250250
if (aborted || !APICallError.isInstance(error)) return null
251251
// An error built with a status carries an explicit retry verdict
252-
// (throwIfTurnSpendCapped marks its 429 non-retryable); respect it.
252+
// (FINAL_REFUSALS in model-provider.ts: the spend breaker's 429, a
253+
// refunded or superseded start's 409); respect it.
253254
if (error.statusCode !== undefined && !error.isRetryable) return null
254255

255256
const body = parseErrorBody(error.responseBody)

0 commit comments

Comments
 (0)