Skip to content

Commit eeabf25

Browse files
committed
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.
1 parent 16673f3 commit eeabf25

3 files changed

Lines changed: 160 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: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,78 @@ describe("normalizeInferenceErrorForRetry", () => {
281281
expect(normalized).toBe(error);
282282
});
283283

284+
test("known-xAI message-only capacity protocol error becomes retryable", () => {
285+
const normalized = normalizeInferenceErrorForRetry({
286+
category: "protocol_mismatch",
287+
message: "The model is currently at capacity. Please try again later.",
288+
providerId: "xai/default",
289+
retryAfterMs: 2_500,
290+
});
291+
expect(normalized.category).toBe("retryable");
292+
expect(normalized.retryAfterMs).toBe(2_500);
293+
});
294+
295+
test("known-xAI JSON-bodied high-demand protocol error becomes retryable", () => {
296+
const normalized = normalizeInferenceErrorForRetry({
297+
category: "protocol_mismatch",
298+
message: "malformed JSON in SSE data payload",
299+
providerId: "xai/default",
300+
raw: {
301+
error: {
302+
message: "The service is unavailable due to high demand",
303+
},
304+
},
305+
});
306+
expect(normalized.category).toBe("retryable");
307+
});
308+
309+
test("known-xAI exact temporary-unavailable phrase becomes retryable", () => {
310+
const normalized = normalizeInferenceErrorForRetry({
311+
category: "protocol_mismatch",
312+
message: "Service temporarily unavailable",
313+
providerId: "xai/default",
314+
});
315+
expect(normalized.category).toBe("retryable");
316+
});
317+
318+
test("explicit Grok adapter overload protocol error becomes retryable", () => {
319+
const normalized = normalizeInferenceErrorForRetry({
320+
category: "protocol_mismatch",
321+
message: "The upstream service is overloaded",
322+
providerId: "grok-responses",
323+
});
324+
expect(normalized.category).toBe("retryable");
325+
});
326+
327+
test("unknown provider capacity protocol_mismatch stays unchanged", () => {
328+
const error = {
329+
category: "protocol_mismatch" as const,
330+
message: "The model is currently at capacity",
331+
providerId: "openai",
332+
};
333+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
334+
});
335+
336+
test("OpenCode Go capacity prose stays protocol_mismatch", () => {
337+
const error = {
338+
category: "protocol_mismatch" as const,
339+
message: "The model is currently at capacity",
340+
providerId: "opencode-go/default",
341+
};
342+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
343+
});
344+
345+
test("known-xAI quota exhaustion stays non-retryable despite capacity prose", () => {
346+
const error = {
347+
category: "quota_exhausted" as const,
348+
message: "Service temporarily unavailable: quota exhausted",
349+
statusCode: 429,
350+
providerId: "xai/default",
351+
retryAfterMs: 86_400_000,
352+
};
353+
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
354+
});
355+
284356
test("known-xAI bare 429 reclassifies as retryable", () => {
285357
const bare = {
286358
category: "quota_exhausted" as const,

src/inference-gateway-error.ts

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

241+
/**
242+
* xAI / Grok capacity and overload phrases that arrive as protocol_mismatch
243+
* (message-only or JSON raw) when the stream is not valid SSE. Exact
244+
* "Service temporarily unavailable" is intentional — do not widen to the
245+
* gateway "service unavailable" substring, which would rematch quota copy.
246+
*/
247+
const XAI_CAPACITY_TEXT_MARKERS = [
248+
"currently at capacity",
249+
"overloaded",
250+
"high demand",
251+
] as const;
252+
253+
const XAI_CAPACITY_EXACT_MESSAGES = new Set([
254+
"service temporarily unavailable",
255+
]);
256+
257+
function textSuggestsXaiCapacity(...parts: string[]): boolean {
258+
const combined = parts.join("\n").toLowerCase();
259+
if (XAI_CAPACITY_TEXT_MARKERS.some((marker) => combined.includes(marker))) {
260+
return true;
261+
}
262+
// Exact phrase is message-only; do not substring-match so quota suffixes stay out.
263+
return XAI_CAPACITY_EXACT_MESSAGES.has(parts[0]?.trim().toLowerCase() ?? "");
264+
}
265+
266+
/**
267+
* Remap attributable xAI / Grok capacity protocol_mismatch errors to retryable.
268+
* Unknown providers and OpenCode Go stay terminal. Never remaps quota_exhausted.
269+
*/
270+
export function normalizeXaiCapacityError(
271+
error: InferenceErrorWithGoContext,
272+
): InferenceError {
273+
if (error.category !== "protocol_mismatch") return error;
274+
if (!isKnownXaiProviderId(error.providerId)) return error;
275+
if (!textSuggestsXaiCapacity(error.message ?? "", stringFromRaw(error.raw))) {
276+
return error;
277+
}
278+
279+
return {
280+
category: "retryable",
281+
message: GATEWAY_OVERLOAD_USER_MESSAGE,
282+
statusCode: error.statusCode ?? 503,
283+
...(error.raw !== undefined ? { raw: error.raw } : {}),
284+
...(error.retryAfterMs !== undefined
285+
? { retryAfterMs: error.retryAfterMs }
286+
: {}),
287+
};
288+
}
289+
241290
/**
242291
* True when a known-xAI HTTP 429 looks like a short rate limit rather than a
243292
* usage/quota window. Used by both retry normalization and transcript copy —
@@ -400,8 +449,9 @@ function normalizeCodexUsageLimitError(
400449
* Reclassify gateway overload errors so the default retry policy treats them as
401450
* transient instead of aborting on protocol_mismatch. Also normalizes OpenCode
402451
* 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.
452+
* 429s, attributable xAI capacity protocol_mismatch, Codex usage limits
453+
* (nested detail.error with resets_in_seconds), and known-Codex short 429s that
454+
* are not usage_limit_reached.
405455
*/
406456
export function normalizeInferenceErrorForRetry(
407457
error: InferenceErrorWithGoContext,
@@ -412,6 +462,9 @@ export function normalizeInferenceErrorForRetry(
412462
const xaiNormalized = normalizeXaiRateLimitError(error);
413463
if (xaiNormalized !== error) return xaiNormalized;
414464

465+
const xaiCapacity = normalizeXaiCapacityError(error);
466+
if (xaiCapacity !== error) return xaiCapacity;
467+
415468
const codexNormalized = normalizeCodexUsageLimitError(error);
416469
if (codexNormalized !== error) return codexNormalized;
417470

0 commit comments

Comments
 (0)