Skip to content

Commit 55d1515

Browse files
Retry attributable xAI capacity protocol mismatches (#881)
* Retry attributable xAI capacity protocol mismatches xAI and Grok sometimes surface capacity or overload as protocol_mismatch instead of a retryable status. Remap those attributable phrases to retryable while leaving quota exhaustion, unknown providers, and OpenCode Go unchanged. * Match xAI capacity copy on raw, veto quota markers, and drop retrying wording
1 parent 8d0e5c7 commit 55d1515

3 files changed

Lines changed: 233 additions & 2 deletions

File tree

src/agent/retry-policy.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,39 @@ describe("createCorbitsRetryPolicy", () => {
3434
expect(decision).toEqual({ kind: "retry", delayMs: 500 });
3535
});
3636

37+
test("bounds attributable xAI capacity retries to three attempts", async () => {
38+
const decide = policy({ providerId: "xai/default" });
39+
const situation = (attempt: number) => ({
40+
attempt,
41+
elapsedMs: 0,
42+
error: {
43+
category: "protocol_mismatch" as const,
44+
message: "The model is currently at capacity",
45+
},
46+
});
47+
48+
expect(await decide(situation(1))).toEqual({ kind: "retry", delayMs: 500 });
49+
expect(await decide(situation(2))).toEqual({
50+
kind: "retry",
51+
delayMs: 1000,
52+
});
53+
expect(await decide(situation(3))).toEqual({ kind: "abort" });
54+
});
55+
56+
test("aborts attributable xAI quota exhaustion", async () => {
57+
const decision = await policy({ providerId: "xai/default" })({
58+
attempt: 1,
59+
elapsedMs: 0,
60+
error: {
61+
category: "quota_exhausted",
62+
message: "Service temporarily unavailable: quota exhausted",
63+
statusCode: 429,
64+
retryAfterMs: 86_400_000,
65+
},
66+
});
67+
expect(decision).toEqual({ kind: "abort" });
68+
});
69+
3770
test("aborts an OpenCode Go malformed streamed SSE schema response", async () => {
3871
const decision = await policy({ providerId: "opencode-go/corbits" })({
3972
attempt: 1,

src/inference-gateway-error.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isGatewayOverloadInferenceError,
55
looksLikeHtmlGatewayBody,
66
normalizeInferenceErrorForRetry,
7+
XAI_CAPACITY_USER_MESSAGE,
78
} from "./inference-gateway-error.js";
89

910
const CLOUDFLARE_503_HTML = `<!DOCTYPE html>
@@ -281,6 +282,117 @@ describe("normalizeInferenceErrorForRetry", () => {
281282
expect(normalized).toBe(error);
282283
});
283284

285+
test("known-xAI message-only capacity protocol error becomes retryable", () => {
286+
const normalized = normalizeInferenceErrorForRetry({
287+
category: "protocol_mismatch",
288+
message: "The model is currently at capacity. Please try again later.",
289+
providerId: "xai/default",
290+
retryAfterMs: 2_500,
291+
});
292+
expect(normalized.category).toBe("retryable");
293+
expect(normalized.retryAfterMs).toBe(2_500);
294+
});
295+
296+
test("known-xAI JSON-bodied high-demand protocol error becomes retryable", () => {
297+
const normalized = normalizeInferenceErrorForRetry({
298+
category: "protocol_mismatch",
299+
message: "malformed JSON in SSE data payload",
300+
providerId: "xai/default",
301+
raw: {
302+
error: {
303+
message: "The service is unavailable due to high demand",
304+
},
305+
},
306+
});
307+
expect(normalized.category).toBe("retryable");
308+
});
309+
310+
test("known-xAI exact temporary-unavailable phrase becomes retryable", () => {
311+
const normalized = normalizeInferenceErrorForRetry({
312+
category: "protocol_mismatch",
313+
message: "Service temporarily unavailable",
314+
providerId: "xai/default",
315+
});
316+
expect(normalized.category).toBe("retryable");
317+
});
318+
319+
test("known-xAI exact phrase carried on raw becomes retryable", () => {
320+
const normalized = normalizeInferenceErrorForRetry({
321+
category: "protocol_mismatch",
322+
message: "malformed JSON in SSE data payload",
323+
providerId: "xai/default",
324+
raw: "Service temporarily unavailable",
325+
});
326+
expect(normalized.category).toBe("retryable");
327+
});
328+
329+
test("known-xAI exact phrase nested in JSON raw becomes retryable", () => {
330+
const normalized = normalizeInferenceErrorForRetry({
331+
category: "protocol_mismatch",
332+
message: "malformed JSON in SSE data payload",
333+
providerId: "xai/default",
334+
raw: { error: { message: "Service temporarily unavailable" } },
335+
});
336+
expect(normalized.category).toBe("retryable");
337+
});
338+
339+
test("remapped xAI capacity copy does not claim an ongoing retry", () => {
340+
const normalized = normalizeInferenceErrorForRetry({
341+
category: "protocol_mismatch",
342+
message: "The model is currently at capacity",
343+
providerId: "xai/default",
344+
});
345+
expect(normalized.message).toBe(XAI_CAPACITY_USER_MESSAGE);
346+
expect(normalized.message).not.toContain("retrying");
347+
});
348+
349+
test("mixed xAI capacity and quota copy stays unchanged", () => {
350+
const error = {
351+
category: "protocol_mismatch" as const,
352+
message: "The model is currently at capacity: quota exceeded",
353+
providerId: "xai/default",
354+
};
355+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
356+
});
357+
358+
test("explicit Grok adapter overload protocol error becomes retryable", () => {
359+
const normalized = normalizeInferenceErrorForRetry({
360+
category: "protocol_mismatch",
361+
message: "The upstream service is overloaded",
362+
providerId: "grok-responses",
363+
});
364+
expect(normalized.category).toBe("retryable");
365+
});
366+
367+
test("unknown provider capacity protocol_mismatch stays unchanged", () => {
368+
const error = {
369+
category: "protocol_mismatch" as const,
370+
message: "The model is currently at capacity",
371+
providerId: "openai",
372+
};
373+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
374+
});
375+
376+
test("OpenCode Go capacity prose stays protocol_mismatch", () => {
377+
const error = {
378+
category: "protocol_mismatch" as const,
379+
message: "The model is currently at capacity",
380+
providerId: "opencode-go/default",
381+
};
382+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
383+
});
384+
385+
test("known-xAI quota exhaustion stays non-retryable despite capacity prose", () => {
386+
const error = {
387+
category: "quota_exhausted" as const,
388+
message: "Service temporarily unavailable: quota exhausted",
389+
statusCode: 429,
390+
providerId: "xai/default",
391+
retryAfterMs: 86_400_000,
392+
};
393+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
394+
});
395+
284396
test("known-xAI bare 429 reclassifies as retryable", () => {
285397
const bare = {
286398
category: "quota_exhausted" as const,

src/inference-gateway-error.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,88 @@ function textHasXaiQuotaMarkers(...parts: string[]): boolean {
238238
return XAI_QUOTA_BODY_MARKERS.some((marker) => combined.includes(marker));
239239
}
240240

241+
/**
242+
* User-visible line for an attributable xAI / Grok capacity error. Worded
243+
* without "retrying" for the same reason as RATE_LIMIT_USER_MESSAGE — it also
244+
* surfaces terminally once retries are exhausted.
245+
*/
246+
export const XAI_CAPACITY_USER_MESSAGE = "xAI at capacity";
247+
248+
/**
249+
* xAI / Grok capacity and overload phrases that arrive as protocol_mismatch
250+
* (message-only or JSON raw) when the stream is not valid SSE. Exact
251+
* "Service temporarily unavailable" is intentional — do not widen to the
252+
* gateway "service unavailable" substring, which would rematch quota copy.
253+
*/
254+
const XAI_CAPACITY_TEXT_MARKERS = [
255+
"currently at capacity",
256+
"overloaded",
257+
"high demand",
258+
] as const;
259+
260+
const XAI_CAPACITY_EXACT_MESSAGES = new Set([
261+
"service temporarily unavailable",
262+
]);
263+
264+
/**
265+
* Exact-match only — never substring, so quota-suffixed copy stays out. Checks
266+
* the part itself and common JSON message fields, since intx puts the server
267+
* body on `raw` while `message` carries parser detail.
268+
*/
269+
function isXaiCapacityExactPhrase(part: string): boolean {
270+
if (XAI_CAPACITY_EXACT_MESSAGES.has(part.trim().toLowerCase())) return true;
271+
const parsed = tryParseJSON(part);
272+
if (typeof parsed !== "object" || parsed === null) return false;
273+
const record = parsed as Record<string, unknown>;
274+
const nested = record.error;
275+
const candidates = [
276+
record.message,
277+
typeof nested === "string" ? nested : undefined,
278+
typeof nested === "object" && nested !== null
279+
? (nested as Record<string, unknown>).message
280+
: undefined,
281+
];
282+
return candidates.some(
283+
(candidate) =>
284+
typeof candidate === "string" &&
285+
XAI_CAPACITY_EXACT_MESSAGES.has(candidate.trim().toLowerCase()),
286+
);
287+
}
288+
289+
function textSuggestsXaiCapacity(...parts: string[]): boolean {
290+
const combined = parts.join("\n").toLowerCase();
291+
if (XAI_CAPACITY_TEXT_MARKERS.some((marker) => combined.includes(marker))) {
292+
return true;
293+
}
294+
return parts.some(isXaiCapacityExactPhrase);
295+
}
296+
297+
/**
298+
* Remap attributable xAI / Grok capacity protocol_mismatch errors to retryable.
299+
* Unknown providers and OpenCode Go stay terminal. Quota markers anywhere in
300+
* the copy veto the remap — mixed capacity+quota text stays a real quota error.
301+
*/
302+
export function normalizeXaiCapacityError(
303+
error: InferenceErrorWithGoContext,
304+
): InferenceError {
305+
if (error.category !== "protocol_mismatch") return error;
306+
if (!isKnownXaiProviderId(error.providerId)) return error;
307+
const messageText = error.message ?? "";
308+
const rawText = stringFromRaw(error.raw);
309+
if (textHasXaiQuotaMarkers(messageText, rawText)) return error;
310+
if (!textSuggestsXaiCapacity(messageText, rawText)) return error;
311+
312+
return {
313+
category: "retryable",
314+
message: XAI_CAPACITY_USER_MESSAGE,
315+
statusCode: error.statusCode ?? 503,
316+
...(error.raw !== undefined ? { raw: error.raw } : {}),
317+
...(error.retryAfterMs !== undefined
318+
? { retryAfterMs: error.retryAfterMs }
319+
: {}),
320+
};
321+
}
322+
241323
/**
242324
* True when a known-xAI HTTP 429 looks like a short rate limit rather than a
243325
* usage/quota window. Used by both retry normalization and transcript copy —
@@ -400,8 +482,9 @@ function normalizeCodexUsageLimitError(
400482
* Reclassify gateway overload errors so the default retry policy treats them as
401483
* transient instead of aborting on protocol_mismatch. Also normalizes OpenCode
402484
* Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short
403-
* 429s, Codex usage limits (nested detail.error with resets_in_seconds), and
404-
* known-Codex short 429s that are not usage_limit_reached.
485+
* 429s, attributable xAI capacity protocol_mismatch, Codex usage limits
486+
* (nested detail.error with resets_in_seconds), and known-Codex short 429s that
487+
* are not usage_limit_reached.
405488
*/
406489
export function normalizeInferenceErrorForRetry(
407490
error: InferenceErrorWithGoContext,
@@ -412,6 +495,9 @@ export function normalizeInferenceErrorForRetry(
412495
const xaiNormalized = normalizeXaiRateLimitError(error);
413496
if (xaiNormalized !== error) return xaiNormalized;
414497

498+
const xaiCapacity = normalizeXaiCapacityError(error);
499+
if (xaiCapacity !== error) return xaiCapacity;
500+
415501
const codexNormalized = normalizeCodexUsageLimitError(error);
416502
if (codexNormalized !== error) return codexNormalized;
417503

0 commit comments

Comments
 (0)