Q87: provider errors keep status and provider code; text never decides recovery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ecffe84e3
commit
4668396f25
28 files changed
+285
-108
No files matched your search
@@ -13,6 +13,7 @@ import { dispatchAgentTool } from "../../src/main/services/agent-tools";
|
||||
import { claimNextQueuedRun, startTaskQueueWorker } from "../../src/main/app/task-queue-worker";
|
||||
import type { AgentTurnResult, ProviderToolTurnInput, ToolCall } from "../../src/main/services/provider-tool-turn";
|
||||
import { answeringModelCheck } from "../helpers/model-check";
|
||||
import { ProviderHttpError } from "../../src/main/services/provider-http";
|
||||
|
||||
const testVault = {
|
||||
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
|
||||
@@ -154,7 +155,7 @@ describe("interactive execution lane", () => {
|
||||
const script = async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
|
||||
const prompt = input.messages.find((message) => message.role === "user")?.text ?? "";
|
||||
if (prompt.includes("Grind through the migration backlog")) {
|
||||
throw new Error("The engine is currently overloaded, please try again later");
|
||||
throw new ProviderHttpError("The engine is currently overloaded, please try again later", null, 529, false, "overloaded_error");
|
||||
}
|
||||
const threadId = /thread_id="([^"]+)"/.exec(prompt)?.[1];
|
||||
if (prompt.includes("Are we blocked") && threadId && !ceoReplied) {
|
||||
@@ -226,7 +227,7 @@ describe("interactive execution lane", () => {
|
||||
});
|
||||
const chat = new ChatService(connection.db);
|
||||
const alwaysOverloaded = async (): Promise<AgentTurnResult> => {
|
||||
throw new Error("The engine is currently overloaded, please try again later");
|
||||
throw new ProviderHttpError("The engine is currently overloaded, please try again later", null, 529, false, "overloaded_error");
|
||||
};
|
||||
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(alwaysOverloaded));
|
||||
providers.saveCredential({ orgId, providerId, secret: "sk-test-key", model: "gpt-5.4" });
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ensureCeoDirectSpace } from "../../src/main/services/ceo-direct-space";
|
||||
import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker";
|
||||
import type { AgentTurnResult, ProviderToolTurnInput } from "../../src/main/services/provider-tool-turn";
|
||||
import { answeringModelCheck } from "../helpers/model-check";
|
||||
import { ProviderHttpError } from "../../src/main/services/provider-http";
|
||||
|
||||
const testVault = {
|
||||
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
|
||||
@@ -64,16 +65,16 @@ describe("run memory", () => {
|
||||
});
|
||||
|
||||
test("a real failure is remembered; a transient provider failure is not", async () => {
|
||||
let failure = "Workspace build failed: missing package.json";
|
||||
const h = await company(() => { throw new Error(failure); });
|
||||
let failure: Error = new Error("Workspace build failed: missing package.json");
|
||||
const h = await company(() => { throw failure; });
|
||||
const first = claimNextQueuedRun(h.connection)!;
|
||||
await expect(h.execution.executeRun(h.orgId, first.id)).rejects.toThrow(failure);
|
||||
expect(h.memories().map((entry) => [entry.category, entry.content])).toEqual([["failure_cause", failure]]);
|
||||
await expect(h.execution.executeRun(h.orgId, first.id)).rejects.toThrow(failure.message);
|
||||
expect(h.memories().map((entry) => [entry.category, entry.content])).toEqual([["failure_cause", failure.message]]);
|
||||
|
||||
failure = "429 rate limit exceeded";
|
||||
failure = new ProviderHttpError("429 rate limit exceeded", null, 429, false, "rate_limit_exceeded");
|
||||
h.connection.db.prepare("update task_runs set state = 'queued' where id = ?").run(first.id);
|
||||
const second = claimNextQueuedRun(h.connection)!;
|
||||
await expect(h.execution.executeRun(h.orgId, second.id)).rejects.toThrow(failure);
|
||||
await expect(h.execution.executeRun(h.orgId, second.id)).rejects.toThrow(failure.message);
|
||||
expect(h.memories()).toHaveLength(1);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { providerAdapters, type ProviderAdapterRegistry } from "../../src/main/s
|
||||
import { ProviderBackgroundService } from "../../src/main/services/provider-background-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE } from "../../src/shared/domain";
|
||||
import { ProviderHttpError } from "../../src/main/services/provider-http";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-provider-background-${randomUUID()}.db`));
|
||||
@@ -166,13 +167,14 @@ describe("provider background service", () => {
|
||||
await background.poll({ orgId, requestId });
|
||||
|
||||
const request = connection.db.prepare(`
|
||||
select state, error_message, raw_response_json
|
||||
select state, error_message, error_code, raw_response_json
|
||||
from provider_requests
|
||||
where id = ?
|
||||
`).get(requestId) as { state: string; error_message: string; raw_response_json: string };
|
||||
`).get(requestId) as { state: string; error_message: string; error_code: string | null; raw_response_json: string };
|
||||
|
||||
expect(request.state).toBe(PROVIDER_REQUEST_STATE.FAILED);
|
||||
expect(request.error_message).toBe("provider worker failed");
|
||||
expect(request.error_code).toBe("server_error");
|
||||
expect(JSON.parse(request.raw_response_json)).toMatchObject({ id: "resp_bg_1", status: "failed" });
|
||||
connection.close();
|
||||
});
|
||||
@@ -192,7 +194,7 @@ describe("background polling failures", () => {
|
||||
retrieveResponse: async (input) => {
|
||||
if (pollFailures > 0) {
|
||||
pollFailures -= 1;
|
||||
throw new Error("fetch failed: ECONNRESET");
|
||||
throw new ProviderHttpError("fetch failed: ECONNRESET", null, 503, false, "UNAVAILABLE");
|
||||
}
|
||||
const result = await base.openai.retrieveResponse(input);
|
||||
return { ...result, status };
|
||||
@@ -224,8 +226,8 @@ describe("background polling failures", () => {
|
||||
pollFailures = 100;
|
||||
const doomed = await background.create({ orgId, providerId: openAiProviderId, prompt: "Research again", instructions: null, executionMode: PROVIDER_EXECUTION_MODE.BACKGROUND });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) await background.poll({ orgId, requestId: doomed });
|
||||
expect(connection.db.prepare("select state, error_message from provider_requests where id = ?").get(doomed))
|
||||
.toEqual({ state: PROVIDER_REQUEST_STATE.FAILED, error_message: "Gave up after 10 failed status checks: fetch failed: ECONNRESET" });
|
||||
expect(connection.db.prepare("select state, error_message, error_status, error_code from provider_requests where id = ?").get(doomed))
|
||||
.toEqual({ state: PROVIDER_REQUEST_STATE.FAILED, error_message: "Gave up after 10 failed status checks: fetch failed: ECONNRESET", error_status: 503, error_code: "UNAVAILABLE" });
|
||||
|
||||
// An answer that is not a valid result (unknown status) also counts, so it cannot stay pending forever.
|
||||
pollFailures = 0;
|
||||
@@ -256,7 +258,7 @@ function backgroundAdapters(status: string, text: string | null): ProviderAdapte
|
||||
rawResponseJson: JSON.stringify({
|
||||
id: "resp_bg_1",
|
||||
status,
|
||||
error: status === "failed" ? { message: "provider worker failed" } : null,
|
||||
error: status === "failed" ? { message: "provider worker failed", code: "server_error" } : null,
|
||||
output_text: text,
|
||||
}),
|
||||
rateLimitState: null,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { classifyProviderFailure } from "../../src/main/services/provider-failure";
|
||||
import { refreshCodex } from "../../src/main/services/oauth/openai-codex-flow";
|
||||
import { refreshKimiCode } from "../../src/main/services/oauth/kimi-code-flow";
|
||||
import { postJson, ProviderHttpError } from "../../src/main/services/provider-http";
|
||||
import { accumulateResponsesStream } from "../../src/main/services/provider-tool-turns/responses-format";
|
||||
import { accumulateGeminiStream } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { markModelChecked } from "../helpers/model-check";
|
||||
import { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE } from "../../src/shared/domain";
|
||||
import { mapAnthropicMessageStream, mapOpenAiChatCompletionStream, mapOpenAiResponseStream } from "../../src/main/services/provider-streaming";
|
||||
import { PROVIDER_MONTHLY_LIMIT_MESSAGE } from "../../src/shared/provider-runtime-domain";
|
||||
|
||||
const NOW = Date.parse("2026-10-08T12:00:00.000Z");
|
||||
|
||||
async function httpFailure(status: number, body: unknown, headers: Record<string, string> = {}): Promise<ProviderHttpError> {
|
||||
vi.stubGlobal("fetch", async () => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } }));
|
||||
const error = await postJson("https://provider.test/v1", {}, {}).catch((caught: unknown) => caught);
|
||||
if (!(error instanceof ProviderHttpError)) throw new Error("expected a provider HTTP error");
|
||||
return error;
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("provider errors keep their status and machine code", () => {
|
||||
test("each provider's error code is read from where it puts it", async () => {
|
||||
expect(await httpFailure(400, { error: { message: "bad", type: "invalid_request_error", code: "context_length_exceeded" } })).toMatchObject({ status: 400, code: "context_length_exceeded" });
|
||||
expect(await httpFailure(529, { type: "error", error: { type: "overloaded_error", message: "Overloaded" } })).toMatchObject({ status: 529, code: "overloaded_error" });
|
||||
expect(await httpFailure(429, { error: { code: 429, message: "Quota exceeded", status: "RESOURCE_EXHAUSTED" } })).toMatchObject({ status: 429, code: "RESOURCE_EXHAUSTED" });
|
||||
expect(await httpFailure(400, { message: "Rate exceeded" }, { "x-amzn-ErrorType": "ThrottlingException:http://internal.amazon.com/coral/com.amazon.bedrock/" })).toMatchObject({ status: 400, code: "ThrottlingException" });
|
||||
expect(await httpFailure(500, { oops: true })).toMatchObject({ status: 500, code: null });
|
||||
});
|
||||
|
||||
test("a token refresh the provider rate-limits is a structured, transient failure", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: { code: "rate_limit_exceeded", message: "slow down" } }), { status: 429, headers: { "content-type": "application/json", "retry-after": "30" } }));
|
||||
const failure = await refreshCodex("refresh-token").catch((caught: unknown) => caught);
|
||||
expect(failure).toMatchObject({ status: 429, code: "rate_limit_exceeded" });
|
||||
expect(classifyProviderFailure(failure, NOW)).toEqual({ transient: true, retryAfterMs: 30_000 });
|
||||
});
|
||||
|
||||
test("OAuth error tokens are codes, sentences are not; Kimi's refresh keeps status, wait and code even without JSON", async () => {
|
||||
expect(await httpFailure(400, { error: "invalid_grant", error_description: "expired" })).toMatchObject({ status: 400, code: "invalid_grant" });
|
||||
expect(await httpFailure(400, { error: "Rate limit exceeded, slow down" })).toMatchObject({ code: null });
|
||||
vi.stubGlobal("fetch", async () => new Response("<html>busy</html>", { status: 503, headers: { "retry-after": "300" } }));
|
||||
const busy = await refreshKimiCode("refresh-token").catch((caught: unknown) => caught);
|
||||
expect(busy).toMatchObject({ status: 503 });
|
||||
expect(classifyProviderFailure(busy, NOW)).toEqual({ transient: true, retryAfterMs: 300_000 });
|
||||
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "rate_limited" }), { status: 429, headers: { "content-type": "application/json", "retry-after": "30" } }));
|
||||
const limited = await refreshKimiCode("refresh-token").catch((caught: unknown) => caught);
|
||||
expect(limited).toMatchObject({ status: 429, code: "rate_limited" });
|
||||
expect(classifyProviderFailure(limited, NOW).retryAfterMs).toBe(30_000);
|
||||
});
|
||||
|
||||
test("a provider's transient code is transient even under a status that is not", async () => {
|
||||
expect(classifyProviderFailure(await httpFailure(400, { message: "Rate exceeded" }, { "x-amzn-ErrorType": "ThrottlingException:x" }), NOW).transient).toBe(true);
|
||||
expect(classifyProviderFailure(await httpFailure(400, { error: { type: "invalid_request_error", message: "rate limit in prompt" } }), NOW).transient).toBe(false);
|
||||
});
|
||||
|
||||
test("stream errors carry the provider's code, not a status made up for it", async () => {
|
||||
async function* events(data: Record<string, unknown>) { yield { eventName: null, data, rawData: JSON.stringify(data) }; }
|
||||
const codex = await accumulateResponsesStream(events({ type: "error", error: { type: "usage_limit_reached", message: "The usage limit has been reached" } })).catch((caught: unknown) => caught);
|
||||
expect(codex).toMatchObject({ status: null, code: "usage_limit_reached" });
|
||||
expect(classifyProviderFailure(codex, NOW).transient).toBe(true);
|
||||
const gemini = await accumulateGeminiStream(events({ error: { code: 503, message: "busy", status: "UNAVAILABLE" } })).catch((caught: unknown) => caught);
|
||||
expect(gemini).toMatchObject({ status: 503, code: "UNAVAILABLE" });
|
||||
});
|
||||
|
||||
test("a streamed reply's error event keeps the provider's code on the error and the request", async () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-codes-stream-${randomUUID()}.db`));
|
||||
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
|
||||
async function* overloaded() { const data = { type: "error", error: { type: "overloaded_error", message: "Overloaded" } }; yield { eventName: null, data, rawData: JSON.stringify(data) }; }
|
||||
const adapters = { ...providerAdapters, anthropic: { ...providerAdapters.anthropic, streamResponse: async () => ({ events: mapAnthropicMessageStream(overloaded()), rateLimitState: null }) } };
|
||||
const providers = new ProviderService(connection.db, adapters, vault);
|
||||
let orgId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'anthropic'").get(orgId) as { id: string }).id;
|
||||
providers.saveCredential({ orgId, providerId, secret: "sk-ant-test", model: "claude-sonnet-4-5" });
|
||||
});
|
||||
const failure = await providers.streamResponse({ orgId, providerId, prompt: "p", instructions: null, executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND }, () => {}).catch((caught: unknown) => caught);
|
||||
expect(failure).toMatchObject({ message: "Overloaded", code: "overloaded_error" });
|
||||
async function* failedResponse() { const data = { type: "response.failed", response: { error: { code: "server_error", message: "The server had an error." } } }; yield { eventName: null, data, rawData: JSON.stringify(data) }; }
|
||||
const openAi = new ProviderService(connection.db, { ...adapters, openai: { ...providerAdapters.openai, streamResponse: async () => ({ events: mapOpenAiResponseStream(failedResponse()), rateLimitState: null }) } }, vault);
|
||||
const openAiId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
connection.transaction(() => openAi.saveCredential({ orgId, providerId: openAiId, secret: "sk-test", model: "gpt-5.4" }));
|
||||
const serverError = await openAi.streamResponse({ orgId, providerId: openAiId, prompt: "p", instructions: null, executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND }, () => {}).catch((caught: unknown) => caught);
|
||||
expect(serverError).toMatchObject({ code: "server_error" });
|
||||
expect(classifyProviderFailure(serverError, NOW).transient).toBe(true);
|
||||
|
||||
async function* midStream() { const data = { id: "gen-1", error: { code: 503, message: "Provider unavailable" }, choices: [{ delta: { content: "" }, finish_reason: "error" }] }; yield { eventName: null, data, rawData: JSON.stringify(data) }; }
|
||||
const router = new ProviderService(connection.db, { ...adapters, openrouter: { ...providerAdapters.openrouter, streamResponse: async () => ({ events: mapOpenAiChatCompletionStream(midStream()), rateLimitState: null }) } }, vault);
|
||||
const routerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openrouter'").get(orgId) as { id: string }).id;
|
||||
connection.transaction(() => router.saveCredential({ orgId, providerId: routerId, secret: "sk-or-test", model: "openai/gpt-5.4" }));
|
||||
const unavailable = await router.streamResponse({ orgId, providerId: routerId, prompt: "p", instructions: null, executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND }, () => {}).catch((caught: unknown) => caught);
|
||||
expect(unavailable).toMatchObject({ message: "Provider unavailable", status: 503 });
|
||||
expect(classifyProviderFailure(unavailable, NOW).transient).toBe(true);
|
||||
expect(classifyProviderFailure(failure, NOW).transient).toBe(true);
|
||||
expect(connection.db.prepare("select error_code from provider_requests order by rowid").all()).toEqual([{ error_code: "overloaded_error" }, { error_code: "server_error" }, { error_code: null }]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("our own monthly limit is a typed error, and a failed request keeps its status and code", async () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-codes-${randomUUID()}.db`));
|
||||
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
|
||||
const providers = new ProviderService(connection.db, providerAdapters, vault, undefined, async () => { throw new ProviderHttpError("Overloaded", null, 529, false, "overloaded_error"); });
|
||||
let orgId = "";
|
||||
let providerId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
|
||||
providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
providers.saveCredential({ orgId, providerId, secret: "sk-test", model: "gpt-5.4" });
|
||||
markModelChecked(connection.db, providerId, "gpt-5.4");
|
||||
});
|
||||
const call = () => providers.runToolTurn({ orgId, providerId, workspaceId: null, instructions: "i", messages: [{ role: "user", text: "u" }], tools: [] });
|
||||
await expect(call()).rejects.toThrow("Overloaded");
|
||||
expect(connection.db.prepare("select error_status, error_code from provider_requests").get()).toEqual({ error_status: 529, error_code: "overloaded_error" });
|
||||
|
||||
providers.updatePolicy({ orgId, providerId, dataPolicy: PROVIDER_DATA_POLICY.STANDARD, backgroundEnabled: false, monthlyRequestLimit: 1, workspaceScope: "organization", contextWindowTokens: 128_000 });
|
||||
const limited = await call().catch((caught: unknown) => caught);
|
||||
expect((limited as Error).message).toBe(PROVIDER_MONTHLY_LIMIT_MESSAGE);
|
||||
expect(classifyProviderFailure(limited, NOW).transient).toBe(true);
|
||||
expect(classifyProviderFailure(new Error((limited as Error).message), NOW).transient).toBe(false);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,9 @@ describe("provider failure classification", () => {
|
||||
expect(classifyProviderFailure(new ProviderHttpError("{\"type\":\"overloaded_error\"}", null, 529), NOW)).toEqual({ transient: true, retryAfterMs: null });
|
||||
expect(classifyProviderFailure(new ProviderHttpError("Provider request timed out.", null, null, true), NOW)).toEqual({ transient: true, retryAfterMs: null });
|
||||
expect(classifyProviderFailure(new ProviderHttpError("invalid api key", null, 401), NOW)).toEqual({ transient: false, retryAfterMs: null });
|
||||
expect(classifyProviderFailure(new ProviderHttpError("The usage limit has been reached", null, 400), NOW)).toEqual({ transient: true, retryAfterMs: null });
|
||||
expect(classifyProviderFailure(new ProviderHttpError("The usage limit has been reached", null, 400, false, "usage_limit_reached"), NOW)).toEqual({ transient: true, retryAfterMs: null });
|
||||
expect(classifyProviderFailure(new ProviderHttpError("Please do not rate limit me", null, 400, false, "invalid_request_error"), NOW)).toEqual({ transient: false, retryAfterMs: null });
|
||||
expect(classifyProviderFailure(new Error("rate limit reached, too many requests"), NOW)).toEqual({ transient: false, retryAfterMs: null });
|
||||
const reset = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
|
||||
expect(classifyProviderFailure(new TypeError("fetch failed", { cause: reset }), NOW)).toEqual({ transient: true, retryAfterMs: null });
|
||||
expect(classifyProviderFailure(new ProviderHttpError("", null, 522), NOW)).toEqual({ transient: true, retryAfterMs: null });
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isTransientProviderFailure, PROVIDER_MONTHLY_LIMIT_MESSAGE } from "../../src/shared/provider-runtime-domain";
|
||||
|
||||
describe("isTransientProviderFailure", () => {
|
||||
test("classifies provider-side limit and overload verdicts as transient", () => {
|
||||
expect(isTransientProviderFailure(PROVIDER_MONTHLY_LIMIT_MESSAGE)).toBe(true);
|
||||
expect(isTransientProviderFailure("The usage limit has been reached")).toBe(true);
|
||||
expect(isTransientProviderFailure("Our servers are currently overloaded. Please try again later.")).toBe(true);
|
||||
expect(isTransientProviderFailure("Rate limit exceeded, retry after 20s")).toBe(true);
|
||||
expect(isTransientProviderFailure("429 Too Many Requests")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps genuine task failures counting toward the retry budget", () => {
|
||||
expect(isTransientProviderFailure(null)).toBe(false);
|
||||
expect(isTransientProviderFailure(undefined)).toBe(false);
|
||||
expect(isTransientProviderFailure("")).toBe(false);
|
||||
expect(isTransientProviderFailure("provider rejected task")).toBe(false);
|
||||
expect(isTransientProviderFailure("Agent run did not converge within 12 iterations.")).toBe(false);
|
||||
expect(isTransientProviderFailure("Provider returned an empty turn: no text and no tool calls.")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { TaskExecutionService } from "../../src/main/services/task-execution-ser
|
||||
import { APPROVAL_STATE, PROVIDER_DATA_POLICY, RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
|
||||
import { currentProviderUsageWindow, PROVIDER_MONTHLY_LIMIT_MESSAGE } from "../../src/shared/provider-runtime-domain";
|
||||
import { answeringModelCheck } from "../helpers/model-check";
|
||||
import { ProviderHttpError } from "../../src/main/services/provider-http";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-task-execution-${randomUUID()}.db`));
|
||||
@@ -847,7 +848,7 @@ describe("task execution service", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const fakeToolTurn = async (): Promise<AgentTurnResult> => {
|
||||
throw new Error("The usage limit has been reached");
|
||||
throw new ProviderHttpError("The usage limit has been reached", null, 429, false, "usage_limit_reached");
|
||||
};
|
||||
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn));
|
||||
let nowMs = Date.now();
|
||||
|
||||
Reference in new issue
Block a user