fix(agent): loop-based rotation, timeout rotatable, error classification tests

- Replace single-retry with while loop that exhausts all candidate profiles
- Add "format" detection to classifyError (400, malformed, bad request, schema)
- Make timeout errors rotatable (some providers hang on rate limit)
- Export classifyError and isRotatableError for testing
- Add error-classification.test.ts with coverage for all failure reasons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yushen 2026-02-03 17:26:16 +08:00
parent a8c5042554
commit e8815dbb97
2 changed files with 106 additions and 31 deletions

View file

@ -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);
});
});

View file

@ -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<AgentRunResult> {
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
}
}