@@ -11,6 +11,7 @@ import {
1111 FREEBUFF_TURN_SPEND_LIMIT_MESSAGE ,
1212} from '@codebuff/common/constants/freebuff-errors'
1313import { FREEBUFF_ACTING_USER_HEADER } from '@codebuff/common/constants/freebuff-models'
14+ import { FREEBUFF_GATE_CODES } from '@codebuff/common/types/freebuff-session'
1415import { isAbortError , isFetchIdleTimeoutError , isTransientNetworkError } from '@codebuff/common/util/error'
1516import {
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 ) => {
0 commit comments