diff --git a/src/agent/auth-profiles/error-classification.test.ts b/src/agent/auth-profiles/error-classification.test.ts new file mode 100644 index 00000000..c771c6b4 --- /dev/null +++ b/src/agent/auth-profiles/error-classification.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { classifyError, isRotatableError } from "../runner.js"; + +// ============================================================ +// classifyError +// ============================================================ + +describe("classifyError", () => { + it("classifies 401/403/unauthorized as auth", () => { + expect(classifyError(new Error("HTTP 401 Unauthorized"))).toBe("auth"); + expect(classifyError(new Error("403 Forbidden"))).toBe("auth"); + expect(classifyError(new Error("Invalid API key provided"))).toBe("auth"); + expect(classifyError(new Error("Authentication failed"))).toBe("auth"); + }); + + it("classifies 400/malformed as format", () => { + expect(classifyError(new Error("400 Bad Request"))).toBe("format"); + expect(classifyError(new Error("Invalid request body"))).toBe("format"); + expect(classifyError(new Error("Malformed JSON in request"))).toBe("format"); + expect(classifyError(new Error("Schema validation failed"))).toBe("format"); + }); + + it("classifies 429/rate limit as rate_limit", () => { + expect(classifyError(new Error("429 Too Many Requests"))).toBe("rate_limit"); + expect(classifyError(new Error("Rate limit exceeded"))).toBe("rate_limit"); + expect(classifyError(new Error("rate_limit_error"))).toBe("rate_limit"); + }); + + it("classifies billing/quota as billing", () => { + expect(classifyError(new Error("Billing quota exceeded"))).toBe("billing"); + expect(classifyError(new Error("Insufficient credits"))).toBe("billing"); + expect(classifyError(new Error("Payment required"))).toBe("billing"); + }); + + it("classifies timeout/connection errors as timeout", () => { + expect(classifyError(new Error("Request timed out"))).toBe("timeout"); + expect(classifyError(new Error("ETIMEDOUT"))).toBe("timeout"); + expect(classifyError(new Error("ECONNRESET"))).toBe("timeout"); + expect(classifyError(new Error("Connection timeout"))).toBe("timeout"); + }); + + it("classifies unknown errors as unknown", () => { + expect(classifyError(new Error("Something went wrong"))).toBe("unknown"); + expect(classifyError("string error")).toBe("unknown"); + expect(classifyError(42)).toBe("unknown"); + }); +}); + +// ============================================================ +// isRotatableError +// ============================================================ + +describe("isRotatableError", () => { + it("considers auth, rate_limit, billing, timeout as rotatable", () => { + expect(isRotatableError("auth")).toBe(true); + expect(isRotatableError("rate_limit")).toBe(true); + expect(isRotatableError("billing")).toBe(true); + expect(isRotatableError("timeout")).toBe(true); + }); + + it("does not rotate on format or unknown errors", () => { + expect(isRotatableError("format")).toBe(false); + expect(isRotatableError("unknown")).toBe(false); + }); +}); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 695a0467..7605b27a 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -34,12 +34,16 @@ import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; // Error classification for auth profile rotation // ============================================================ -function classifyError(error: unknown): AuthProfileFailureReason { +/** Classify an error into an auth profile failure reason */ +export function classifyError(error: unknown): AuthProfileFailureReason { const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); if (msg.includes("401") || msg.includes("403") || msg.includes("unauthorized") || msg.includes("invalid api key") || msg.includes("authentication")) { return "auth"; } + if (msg.includes("400") || msg.includes("invalid request") || msg.includes("malformed") || msg.includes("bad request") || msg.includes("schema")) { + return "format"; + } if (msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests")) { return "rate_limit"; } @@ -53,8 +57,9 @@ function classifyError(error: unknown): AuthProfileFailureReason { } /** Check if an error is potentially retryable via profile rotation */ -function isRotatableError(reason: AuthProfileFailureReason): boolean { - return reason === "auth" || reason === "rate_limit" || reason === "billing"; +export function isRotatableError(reason: AuthProfileFailureReason): boolean { + // timeout is rotatable because some providers hang on rate limit instead of returning 429 + return reason === "auth" || reason === "rate_limit" || reason === "billing" || reason === "timeout"; } export class Agent { @@ -307,38 +312,43 @@ export class Agent { async run(prompt: string): Promise { this.output.state.lastAssistantText = ""; - try { - await this.agent.prompt(prompt); - } catch (error) { - // Attempt auth profile rotation on retryable errors - if (!this.pinnedProfile && this.profileCandidates.length > 1 && this.currentProfileId) { + const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; + let lastError: unknown; + + // Loop to exhaust all candidate profiles on rotatable errors + while (true) { + try { + await this.agent.prompt(prompt); + break; // success — exit loop + } catch (error) { + lastError = error; + + if (!canRotate || !this.currentProfileId) throw error; + const reason = classifyError(error); - if (isRotatableError(reason)) { - markAuthProfileFailure(this.currentProfileId, reason); + if (!isRotatableError(reason)) throw error; - if (this.debug) { - this.stderr.write( - `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, - ); - } + markAuthProfileFailure(this.currentProfileId, reason); - if (this.advanceAuthProfile()) { - if (this.debug) { - this.stderr.write( - `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, - ); - } - // Retry with new profile - this.output.state.lastAssistantText = ""; - await this.agent.prompt(prompt); - } else { - throw error; // No more profiles to try - } - } else { - throw error; // Non-rotatable error + if (this.debug) { + this.stderr.write( + `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, + ); } - } else { - throw error; // Pinned profile or single profile + + if (!this.advanceAuthProfile()) { + throw lastError; // All profiles exhausted + } + + if (this.debug) { + this.stderr.write( + `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, + ); + } + + // Reset output for retry + this.output.state.lastAssistantText = ""; + // continue loop with new profile } }