diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index e367461ea31..205f12ee18b 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -238,10 +238,9 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n ) { return "timeout"; } - if (isTimeoutError(err)) { - return "timeout"; - } - // Walk into error cause chain (e.g. AbortError wrapping a rate-limit cause) + // Walk into error cause chain *before* timeout heuristics so that a specific + // cause (e.g. RESOURCE_EXHAUSTED wrapped in AbortError) overrides a parent + // message-based "timeout" guess from isTimeoutError. const cause = getErrorCause(err); if (cause && cause !== err) { const causeReason = resolveFailoverReasonFromError(cause); @@ -249,6 +248,9 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n return causeReason; } } + if (isTimeoutError(err)) { + return "timeout"; + } if (!message) { return null; } diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 4795bdb4c65..7b7435b1bcc 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -394,6 +394,15 @@ describe("runWithModelFallback – probe logic", () => { // All three candidates must be attempted — the abort must not short-circuit expect(run).toHaveBeenCalledTimes(3); + + // Verify the primary error is classified as rate_limit, not timeout — the + // cause chain (RESOURCE_EXHAUSTED) must override the parent AbortError message. + try { + await runWithModelFallback({ cfg, provider: "google", model: "gemini-3-flash-preview", run }); + } catch (err) { + expect(String(err)).toContain("(rate_limit)"); + expect(String(err)).not.toMatch(/gemini.*\(timeout\)/); + } expect(run).toHaveBeenNthCalledWith(1, "google", "gemini-3-flash-preview", { allowTransientCooldownProbe: true, });