From 7c8448b28a79325a5e31b516d6bd5f05c7282a18 Mon Sep 17 00:00:00 2001 From: chilung Date: Tue, 8 Sep 2026 19:52:54 +0000 Subject: [PATCH 1/2] fix(oauth): harden xAI Retry-After and abort handling Honor bounded numeric and HTTP-date retry delays, make backoff cancellation-aware, and preserve caller aborts across response-body and custom sleep races. Closes #4045 Closes #4046 Closes #4047 --- src/combos/failover.ts | 90 +----- src/lib/http-retry-after.ts | 96 +++++++ src/oauth/xai.ts | 17 +- tests/providers/xai/xai-oauth-retry.test.ts | 303 ++++++++++++++++++++ 4 files changed, 412 insertions(+), 94 deletions(-) create mode 100644 src/lib/http-retry-after.ts diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 3868bc4b0c..a926ea000d 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -1,5 +1,6 @@ import { parseResetCooldownMs } from "../codex/routing"; import { classifyError, isCyberPolicyCode } from "../lib/errors"; +import { parseRetryAfterMs as parseRetryAfterMsBounded } from "../lib/http-retry-after"; import type { OcxComboTarget } from "../types"; import { targetKey } from "./types"; import { @@ -29,13 +30,6 @@ const QUOTA_LIMIT_CODES = new Set([ "insufficient_quota", ]); const TRANSIENT_REQUEST_RATE_CODES = new Set(["1302", "1305"]); -const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; -const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; -const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i; -const HTTP_MONTH_INDEX: Record = { - jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, - jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, -}; /** Map<`${comboId}\0${provider/model}`, TargetCooldown> */ const targetCooldowns = new Map(); @@ -49,92 +43,12 @@ function cooldownMapKey( return `${comboId}\0${targetKey(target)}`; } -function parseUtcDateParts( - year: number, - monthName: string, - day: number, - hour: number, - minute: number, - second: number, -): number | undefined { - const month = HTTP_MONTH_INDEX[monthName.toLowerCase()]; - if (month === undefined) return undefined; - const timestamp = Date.UTC(year, month, day, hour, minute, second); - const parsed = new Date(timestamp); - return parsed.getUTCFullYear() === year - && parsed.getUTCMonth() === month - && parsed.getUTCDate() === day - && parsed.getUTCHours() === hour - && parsed.getUTCMinutes() === minute - && parsed.getUTCSeconds() === second - ? timestamp - : undefined; -} - -function parseHttpDate(value: string, now: number): number | undefined { - const imf = IMF_FIXDATE_RE.exec(value); - if (imf) { - return parseUtcDateParts( - Number(imf[3]), imf[2]!, Number(imf[1]), - Number(imf[4]), Number(imf[5]), Number(imf[6]), - ); - } - const rfc850 = RFC850_DATE_RE.exec(value); - if (rfc850) { - const current = new Date(now); - const currentYear = current.getUTCFullYear(); - const month = HTTP_MONTH_INDEX[rfc850[2]!.toLowerCase()]; - if (month === undefined) return undefined; - let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[3]); - const yearDelta = year - currentYear; - const candidateTimeOfYear = Date.UTC( - 2000, month, Number(rfc850[1]), - Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), - ); - const currentTimeOfYear = Date.UTC( - 2000, current.getUTCMonth(), current.getUTCDate(), - current.getUTCHours(), current.getUTCMinutes(), current.getUTCSeconds(), - current.getUTCMilliseconds(), - ); - if (yearDelta < -50 || (yearDelta === -50 && candidateTimeOfYear < currentTimeOfYear)) { - year += 100; - } else if (yearDelta > 50 || (yearDelta === 50 && candidateTimeOfYear > currentTimeOfYear)) { - year -= 100; - } - return parseUtcDateParts( - year, rfc850[2]!, Number(rfc850[1]), - Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), - ); - } - const asctime = ASCTIME_DATE_RE.exec(value); - if (!asctime) return undefined; - return parseUtcDateParts( - Number(asctime[6]), asctime[1]!, Number(asctime[2]), - Number(asctime[3]), Number(asctime[4]), Number(asctime[5]), - ); -} - export function parseRetryAfterMs( value: string | null | undefined, now = Date.now(), options?: { preserveImmediate?: boolean }, ): number | undefined { - const text = value?.trim(); - if (!text) return undefined; - if (/^\d+(?:\.\d+)?$/.test(text)) { - const seconds = Number(text); - if ( - Number.isFinite(seconds) - && (seconds > 0 || (options?.preserveImmediate && seconds === 0)) - ) { - return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS); - } - } - const timestamp = parseHttpDate(text, now); - if (timestamp === undefined) return undefined; - const delay = timestamp - now; - if (delay > 0) return Math.min(delay, MAX_COOLDOWN_MS); - return options?.preserveImmediate ? 1 : undefined; + return parseRetryAfterMsBounded(value, now, { ...options, maxMs: MAX_COOLDOWN_MS }); } export function isComboTargetInCooldown( diff --git a/src/lib/http-retry-after.ts b/src/lib/http-retry-after.ts new file mode 100644 index 0000000000..96e8317808 --- /dev/null +++ b/src/lib/http-retry-after.ts @@ -0,0 +1,96 @@ +const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; +const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; +const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i; +const HTTP_MONTH_INDEX: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +function parseUtcDateParts( + year: number, + monthName: string, + day: number, + hour: number, + minute: number, + second: number, +): number | undefined { + const month = HTTP_MONTH_INDEX[monthName.toLowerCase()]; + if (month === undefined) return undefined; + const timestamp = Date.UTC(year, month, day, hour, minute, second); + const parsed = new Date(timestamp); + return parsed.getUTCFullYear() === year + && parsed.getUTCMonth() === month + && parsed.getUTCDate() === day + && parsed.getUTCHours() === hour + && parsed.getUTCMinutes() === minute + && parsed.getUTCSeconds() === second + ? timestamp + : undefined; +} + +function parseHttpDate(value: string, now: number): number | undefined { + const imf = IMF_FIXDATE_RE.exec(value); + if (imf) { + return parseUtcDateParts( + Number(imf[3]), imf[2]!, Number(imf[1]), + Number(imf[4]), Number(imf[5]), Number(imf[6]), + ); + } + const rfc850 = RFC850_DATE_RE.exec(value); + if (rfc850) { + const current = new Date(now); + const currentYear = current.getUTCFullYear(); + const month = HTTP_MONTH_INDEX[rfc850[2]!.toLowerCase()]; + if (month === undefined) return undefined; + let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[3]); + const yearDelta = year - currentYear; + const candidateTimeOfYear = Date.UTC( + 2000, month, Number(rfc850[1]), + Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), + ); + const currentTimeOfYear = Date.UTC( + 2000, current.getUTCMonth(), current.getUTCDate(), + current.getUTCHours(), current.getUTCMinutes(), current.getUTCSeconds(), + current.getUTCMilliseconds(), + ); + if (yearDelta < -50 || (yearDelta === -50 && candidateTimeOfYear < currentTimeOfYear)) { + year += 100; + } else if (yearDelta > 50 || (yearDelta === 50 && candidateTimeOfYear > currentTimeOfYear)) { + year -= 100; + } + return parseUtcDateParts( + year, rfc850[2]!, Number(rfc850[1]), + Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), + ); + } + const asctime = ASCTIME_DATE_RE.exec(value); + if (!asctime) return undefined; + return parseUtcDateParts( + Number(asctime[6]), asctime[1]!, Number(asctime[2]), + Number(asctime[3]), Number(asctime[4]), Number(asctime[5]), + ); +} + +export function parseRetryAfterMs( + value: string | null | undefined, + now = Date.now(), + options?: { preserveImmediate?: boolean; maxMs?: number }, +): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + const maxMs = options?.maxMs ?? 10 * 60_000; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + if ( + Number.isFinite(seconds) + && (seconds > 0 || (options?.preserveImmediate && seconds === 0)) + ) { + return Math.min(Math.max(Math.ceil(seconds * 1000), 1), maxMs); + } + } + const timestamp = parseHttpDate(text, now); + if (timestamp === undefined) return undefined; + const delay = timestamp - now; + if (delay > 0) return Math.min(delay, maxMs); + return options?.preserveImmediate ? 1 : undefined; +} diff --git a/src/oauth/xai.ts b/src/oauth/xai.ts index f876b18b38..26df7ef2fd 100644 --- a/src/oauth/xai.ts +++ b/src/oauth/xai.ts @@ -2,6 +2,7 @@ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; import { generatePKCE } from "./pkce"; import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types"; +import { parseRetryAfterMs as parseStrictRetryAfterMs } from "../lib/http-retry-after"; const XAI_OAUTH_ISSUER = "https://auth.x.ai"; export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`; @@ -93,17 +94,21 @@ function getTokenIdentity(accessToken: string, idToken: string | undefined): { a } export class XaiTokenRequestError extends Error { constructor(public readonly status?:number,public readonly oauthError?:string,message="xAI token request failed",options?:{cause?:unknown}){super(message,options);this.name="XaiTokenRequestError";} } -export interface XaiTokenRetryDeps { sleep?:(ms:number)=>Promise; random?:()=>number } -function isAbortError(error:unknown):boolean{return error instanceof DOMException&&error.name==="AbortError";} -function retryDelay(attempt:number,retryAfter:string|null,random:()=>number):number{const base=attempt===1?100:250,j=Math.round(base*(.75+random()*.5)),seconds=retryAfter!==null&&/^\d+$/.test(retryAfter)?Number(retryAfter):0;return Math.min(2000,Math.max(j,seconds*1000));} +export interface XaiTokenRetryDeps { sleep?:(ms:number,signal?:AbortSignal)=>Promise; random?:()=>number } +const MAX_RETRY_DELAY_MS = 60_000; +function parseRetryAfterMs(header:string|null):number|undefined{const trimmed=header?.trim();if(!trimmed)return undefined;if(/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(trimmed)){const seconds=Number(trimmed);return Number.isFinite(seconds)&&seconds>0?Math.min(MAX_RETRY_DELAY_MS,Math.max(1,Math.ceil(seconds*1000))):undefined;}const parsed=parseStrictRetryAfterMs(trimmed,Date.now(),{maxMs:MAX_RETRY_DELAY_MS});return parsed===undefined?undefined:Math.min(MAX_RETRY_DELAY_MS,parsed);} +function retryDelay(attempt:number,retryAfter:string|null,random:()=>number):number{const base=attempt===1?100:250,j=Math.round(base*(.75+random()*.5)),localCap=Math.min(2000,j);const retryAfterMs=parseRetryAfterMs(retryAfter);return retryAfterMs===undefined?localCap:Math.min(MAX_RETRY_DELAY_MS,Math.max(localCap,retryAfterMs));} +function abortError(signal?:AbortSignal):unknown{return signal?.aborted?signal.reason:new DOMException("The operation was aborted.","AbortError");} +function sleep(ms:number,signal?:AbortSignal):Promise{return new Promise((resolve,reject)=>{if(signal?.aborted){reject(abortError(signal));return;}let timer:ReturnType;const cleanup=()=>signal?.removeEventListener("abort",onAbort);const onAbort=()=>{clearTimeout(timer);cleanup();reject(abortError(signal));};timer=setTimeout(()=>{cleanup();resolve();},ms);signal?.addEventListener("abort",onAbort,{once:true});if(signal?.aborted)onAbort();});} +function waitForRetry(wait:(ms:number,signal?:AbortSignal)=>Promise,ms:number,signal?:AbortSignal):Promise{if(!signal)return wait(ms);return new Promise((resolve,reject)=>{let settled=false;const cleanup=()=>signal.removeEventListener("abort",onAbort);const finish=()=>{if(settled)return;settled=true;cleanup();resolve();};const fail=(error:unknown)=>{if(settled)return;settled=true;cleanup();reject(error);};const onAbort=()=>fail(abortError(signal));signal.addEventListener("abort",onAbort,{once:true});if(signal.aborted){onAbort();return;}let pending:Promise;try{pending=wait(ms,signal);}catch(error){fail(error);return;}void pending.then(finish,fail);});} async function readTokenError(response:Response):Promise{let oauthError:string|undefined,detail="";try{const body=await response.json() as {error?:unknown;error_description?:unknown};if(typeof body.error==="string")oauthError=body.error;if(typeof body.error_description==="string")detail=body.error_description;}catch{}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);} export async function postXaiToken( tokenEndpoint: string, body: Record, signal?: AbortSignal, deps:XaiTokenRetryDeps={}, ): Promise { - const sleep=deps.sleep??(ms=>Bun.sleep(ms)),random=deps.random??Math.random;let last:unknown; - for(let attempt=1;attempt<=3;attempt++){let response:Response;try{response=await fetch(tokenEndpoint, { + const wait=deps.sleep??sleep,random=deps.random??Math.random;let last:unknown; + for(let attempt=1;attempt<=3;attempt++){if(attempt>1&&signal?.aborted)throw abortError(signal);let response:Response;try{response=await fetch(tokenEndpoint, { method: "POST", headers: { Accept: "application/json", @@ -111,7 +116,7 @@ export async function postXaiToken( }, body: new URLSearchParams(body).toString(), signal: requestSignal(signal), - });}catch(error){if(isAbortError(error)&&signal?.aborted)throw error;last=error;if(attempt===3)throw new XaiTokenRequestError(undefined,undefined,"xAI token request failed: network error",{cause:error});await sleep(retryDelay(attempt,null,random));continue;}if(response.ok)return await response.json() as XaiTokenPayload;const error=await readTokenError(response);last=error;if(!(response.status===429||response.status>=500)||attempt===3)throw error;await sleep(retryDelay(attempt,response.headers.get("retry-after"),random));}throw last; + });}catch(error){if(signal?.aborted)throw abortError(signal);if(signal?.reason!==undefined&&error===signal.reason)throw error;last=error;if(attempt===3)throw new XaiTokenRequestError(undefined,undefined,"xAI token request failed: network error",{cause:error});await waitForRetry(wait,retryDelay(attempt,null,random),signal);continue;}if(response.ok){if(signal?.aborted)throw abortError(signal);return await response.json() as XaiTokenPayload;}const error=await readTokenError(response);if(signal?.aborted)throw abortError(signal);last=error;if(!(response.status===429||response.status>=500)||attempt===3)throw error;await waitForRetry(wait,retryDelay(attempt,response.headers.get("retry-after"),random),signal);}throw last; } function credentialsFromTokenPayload(payload: XaiTokenPayload, refreshFallback = ""): OAuthCredentials { diff --git a/tests/providers/xai/xai-oauth-retry.test.ts b/tests/providers/xai/xai-oauth-retry.test.ts index 542b594a3c..778e10dbf2 100644 --- a/tests/providers/xai/xai-oauth-retry.test.ts +++ b/tests/providers/xai/xai-oauth-retry.test.ts @@ -9,4 +9,307 @@ describe("xAI retry",()=>{ test("third transient failure is final",async()=>{const calls=queue([500,502,503].map(status=>new Response("",{status})));await expect(postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async()=>{},random:()=>.5})).rejects.toMatchObject({status:503});expect(calls()).toBe(3);}); test("permanent 4xx is not retried or leaked",async()=>{const calls=queue([new Response(JSON.stringify({error:"invalid_grant"}),{status:400})]);await expect(postXaiToken("https://auth.x.ai/token",body,undefined,{sleep:async()=>{}})).rejects.toBeInstanceOf(XaiTokenRequestError);expect(calls()).toBe(1);}); test("caller abort is not retried",async()=>{const c=new AbortController();c.abort();let calls=0;globalThis.fetch=(async()=>{calls++;throw new DOMException("aborted","AbortError")}) as typeof fetch;await expect(postXaiToken("https://auth.x.ai/token",body,c.signal,{sleep:async()=>{}})).rejects.toMatchObject({name:"AbortError"});expect(calls).toBe(1);}); + + test("Retry-After: 60 respects server delay up to 60s without 2s truncation", async () => { + const calls = queue([new Response("", { status: 429, headers: { "retry-after": "60" } }), ok()]); + const d: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d.push(x); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(d).toEqual([60000]); + }); + + test("Retry-After: 1.5 parses decimal seconds into milliseconds", async () => { + const calls = queue([new Response("", { status: 429, headers: { "retry-after": "1.5" } }), ok()]); + const d: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d.push(x); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(d).toEqual([1500]); + }); + + test("Retry-After: HTTP-date format parses future date and past date", async () => { + const originalNow = Date.now; + const baseTime = 1700000000000; + try { + Date.now = () => baseTime; + const futureDate = new Date(baseTime + 10000).toUTCString(); + const calls1 = queue([new Response("", { status: 429, headers: { "retry-after": futureDate } }), ok()]); + const d1: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d1.push(x); }, + random: () => 0.5, + }); + expect(calls1()).toBe(2); + expect(d1).toEqual([10000]); + + const pastDate = new Date(baseTime - 5000).toUTCString(); + const calls2 = queue([new Response("", { status: 429, headers: { "retry-after": pastDate } }), ok()]); + const d2: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d2.push(x); }, + random: () => 0.5, + }); + expect(calls2()).toBe(2); + expect(d2[0]).toBeLessThanOrEqual(100); + } finally { + Date.now = originalNow; + } + }); + + test("caller abort with custom reason is not retried and throws custom reason", async () => { + const customReason = new Error("user cancel"); + const c = new AbortController(); + c.abort(customReason); + let calls = 0; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + calls++; + if (init?.signal?.aborted) throw init.signal.reason; + throw customReason; + }) as typeof fetch; + const sleepCalls: number[] = []; + await expect( + postXaiToken("https://auth.x.ai/token", body, c.signal, { + sleep: async ms => { sleepCalls.push(ms); }, + }), + ).rejects.toBe(customReason); + expect(calls).toBe(1); + expect(sleepCalls.length).toBe(0); + }); + + test("Retry-After: extreme values (>60s) are capped at 60s", async () => { + const calls1 = queue([new Response("", { status: 429, headers: { "retry-after": "120" } }), ok()]); + const d1: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d1.push(x); }, + random: () => 0.5, + }); + expect(calls1()).toBe(2); + expect(d1).toEqual([60000]); + + const originalNow = Date.now; + const baseTime = 1700000000000; + try { + Date.now = () => baseTime; + const futureDate = new Date(baseTime + 120000).toUTCString(); + const calls2 = queue([new Response("", { status: 429, headers: { "retry-after": futureDate } }), ok()]); + const d2: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d2.push(x); }, + random: () => 0.5, + }); + expect(calls2()).toBe(2); + expect(d2).toEqual([60000]); + } finally { + Date.now = originalNow; + } + }); + + test("Retry-After: 0, negative, and invalid string fall back to default jitter", async () => { + for (const invalidHeader of ["0", "-1", "-10.5", "invalid-delay", ""]) { + const calls = queue([new Response("", { status: 429, headers: { "retry-after": invalidHeader } }), ok()]); + const d: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d.push(x); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(d).toEqual([100]); + } + }); + + test("Retry-After: value smaller than jitter floor uses jitter floor", async () => { + const calls = queue([new Response("", { status: 429, headers: { "retry-after": "0.05" } }), ok()]); + const d: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d.push(x); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(d).toEqual([100]); + }); + + test("Retry-After: leading dot decimal (.5) and whitespace are handled", async () => { + const calls = queue([new Response("", { status: 429, headers: { "retry-after": " .5 " } }), ok()]); + const d: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d.push(x); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(d).toEqual([500]); + }); + + test("Retry-After > 60s is capped at 60000ms", async () => { + const calls = queue([new Response("", { status: 429, headers: { "retry-after": "3600" } }), ok()]); + const d: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d.push(x); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(d).toEqual([60000]); + }); + + test("transient connection timeout without caller abort is retried", async () => { + const timeoutErr = new DOMException("The operation timed out.", "TimeoutError"); + const calls = queue([timeoutErr, ok()]); + const d: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async x => { d.push(x); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(d).toEqual([100]); + }); + + test("abort during retry backoff does not start another token request", async () => { + const controller = new AbortController(); + const reason = new Error("user cancel during backoff"); + const calls = queue([new Response("", { status: 429 }), ok()]); + const sleepCalls: number[] = []; + await expect( + postXaiToken("https://auth.x.ai/token", body, controller.signal, { + sleep: async ms => { + sleepCalls.push(ms); + controller.abort(reason); + }, + random: () => 0.5, + }), + ).rejects.toBe(reason); + expect(calls()).toBe(1); + expect(sleepCalls).toEqual([100]); + }); + + test("an undefined network rejection is retried when the caller signal is not aborted", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls++; + throw undefined; + }) as typeof fetch; + const sleepCalls: number[] = []; + await expect( + postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async ms => { sleepCalls.push(ms); }, + random: () => 0.5, + }), + ).rejects.toMatchObject({ name: "XaiTokenRequestError", cause: undefined }); + expect(calls).toBe(3); + expect(sleepCalls).toEqual([100, 250]); + }); + + test("Retry-After accepts only HTTP-date forms, not arbitrary date strings", async () => { + const calls = queue([ + new Response("", { status: 429, headers: { "retry-after": "2027-01-01" } }), + ok(), + ]); + const delays: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async ms => { delays.push(ms); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(delays).toEqual([100]); + }); + + test("Retry-After rejects a trailing decimal point", async () => { + const calls = queue([ + new Response("", { status: 429, headers: { "retry-after": "1." } }), + ok(), + ]); + const delays: number[] = []; + await postXaiToken("https://auth.x.ai/token", body, undefined, { + sleep: async ms => { delays.push(ms); }, + random: () => 0.5, + }); + expect(calls()).toBe(2); + expect(delays).toEqual([100]); + }); + + test("default retry backoff is interrupted by caller abort", async () => { + const controller = new AbortController(); + const reason = new Error("user cancel during default backoff"); + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response("", { status: 429, headers: { "retry-after": "1" } }); + }) as typeof fetch; + const started = performance.now(); + const pending = postXaiToken("https://auth.x.ai/token", body, controller.signal, { random: () => 0.5 }); + setTimeout(() => controller.abort(reason), 5); + await expect(pending).rejects.toBe(reason); + expect(calls).toBe(1); + expect(performance.now() - started).toBeLessThan(500); + }); + + test("default retry backoff preserves a null caller abort reason", async () => { + const controller = new AbortController(); + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response("", { status: 429, headers: { "retry-after": "1" } }); + }) as typeof fetch; + const pending = postXaiToken("https://auth.x.ai/token", body, controller.signal, { random: () => 0.5 }); + setTimeout(() => controller.abort(null), 5); + let caught: unknown; + try { + await pending; + } catch (error) { + caught = error; + } + expect(caught).toBe(null); + expect(calls).toBe(1); + }); + + test("caller abort during the final error body read wins over the terminal HTTP error", async () => { + const controller = new AbortController(); + const reason = new Error("user cancel during error body"); + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return { + ok: false, + status: 503, + headers: new Headers(), + json: async () => { + if (calls === 3) setTimeout(() => controller.abort(reason), 5); + await new Promise(resolve => setTimeout(resolve, 15)); + return {}; + }, + } as Response; + }) as typeof fetch; + let caught: unknown; + try { + await postXaiToken("https://auth.x.ai/token", body, controller.signal, { + sleep: async () => {}, + random: () => 0.5, + }); + } catch (error) { + caught = error; + } + expect(caught).toBe(reason); + expect(calls).toBe(3); + }); + + test("caller abort races a custom retry sleep instead of waiting for it", async () => { + const controller = new AbortController(); + const reason = new Error("user cancel during custom backoff"); + const calls = queue([new Response("", { status: 429 }), ok()]); + const started = performance.now(); + setTimeout(() => controller.abort(reason), 5); + await expect( + postXaiToken("https://auth.x.ai/token", body, controller.signal, { + sleep: async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + }, + random: () => 0.5, + }), + ).rejects.toBe(reason); + expect(calls()).toBe(1); + expect(performance.now() - started).toBeLessThan(50); + }); }); From 1d19c1a47fd7c9b4c177dd4b865c3e429fe9fcbc Mon Sep 17 00:00:00 2001 From: chilung Date: Tue, 8 Sep 2026 20:16:46 +0000 Subject: [PATCH 2/2] fix(oauth): keep malformed token bodies non-fatal --- src/oauth/xai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oauth/xai.ts b/src/oauth/xai.ts index 26df7ef2fd..21b253bc48 100644 --- a/src/oauth/xai.ts +++ b/src/oauth/xai.ts @@ -101,7 +101,7 @@ function retryDelay(attempt:number,retryAfter:string|null,random:()=>number):num function abortError(signal?:AbortSignal):unknown{return signal?.aborted?signal.reason:new DOMException("The operation was aborted.","AbortError");} function sleep(ms:number,signal?:AbortSignal):Promise{return new Promise((resolve,reject)=>{if(signal?.aborted){reject(abortError(signal));return;}let timer:ReturnType;const cleanup=()=>signal?.removeEventListener("abort",onAbort);const onAbort=()=>{clearTimeout(timer);cleanup();reject(abortError(signal));};timer=setTimeout(()=>{cleanup();resolve();},ms);signal?.addEventListener("abort",onAbort,{once:true});if(signal?.aborted)onAbort();});} function waitForRetry(wait:(ms:number,signal?:AbortSignal)=>Promise,ms:number,signal?:AbortSignal):Promise{if(!signal)return wait(ms);return new Promise((resolve,reject)=>{let settled=false;const cleanup=()=>signal.removeEventListener("abort",onAbort);const finish=()=>{if(settled)return;settled=true;cleanup();resolve();};const fail=(error:unknown)=>{if(settled)return;settled=true;cleanup();reject(error);};const onAbort=()=>fail(abortError(signal));signal.addEventListener("abort",onAbort,{once:true});if(signal.aborted){onAbort();return;}let pending:Promise;try{pending=wait(ms,signal);}catch(error){fail(error);return;}void pending.then(finish,fail);});} -async function readTokenError(response:Response):Promise{let oauthError:string|undefined,detail="";try{const body=await response.json() as {error?:unknown;error_description?:unknown};if(typeof body.error==="string")oauthError=body.error;if(typeof body.error_description==="string")detail=body.error_description;}catch{}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);} +async function readTokenError(response:Response):Promise{let oauthError:string|undefined,detail="";try{const body=await response.json() as {error?:unknown;error_description?:unknown};if(typeof body.error==="string")oauthError=body.error;if(typeof body.error_description==="string")detail=body.error_description;}catch(error){if(error instanceof SyntaxError)detail="";}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);} export async function postXaiToken( tokenEndpoint: string, body: Record,