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:
parent
a8c5042554
commit
e8815dbb97
2 changed files with 106 additions and 31 deletions
65
src/agent/auth-profiles/error-classification.test.ts
Normal file
65
src/agent/auth-profiles/error-classification.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue