8e53e0e49c
fixes #6647 compaction (auto & manual) and branch summarization retry on transient failures. use the same retry policy from settings. emit events for the tui to show indication of retries
187 lines
7.5 KiB
TypeScript
187 lines
7.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { fauxAssistantMessage } from "../src/providers/faux.ts";
|
|
import { isRetryableAssistantError, type RetryPolicy, retryAssistantCall } from "../src/utils/retry.ts";
|
|
|
|
const openAIExplicitRetryMessage =
|
|
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message.";
|
|
const bedrockExplicitRetryMessage =
|
|
'{"message":"The system encountered an unexpected error during processing. Try your request again."}';
|
|
const nvidiaNIMResourceExhaustedMessage = "ResourceExhausted: Worker local total request limit reached (288/48)";
|
|
const bunFetchSocketClosedMessage =
|
|
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()";
|
|
const openAIResponsesEarlyEofMessage = "OpenAI Responses stream ended before a terminal response event";
|
|
|
|
describe("provider retry classification", () => {
|
|
it("matches explicit provider retry guidance", () => {
|
|
expect(
|
|
isRetryableAssistantError(
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: openAIExplicitRetryMessage }),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isRetryableAssistantError(
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: bedrockExplicitRetryMessage }),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isRetryableAssistantError(
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: nvidiaNIMResourceExhaustedMessage }),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("matches Bun fetch socket drop wording", () => {
|
|
expect(
|
|
isRetryableAssistantError(
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: bunFetchSocketClosedMessage }),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("matches OpenAI Responses streams that end before terminal events", () => {
|
|
expect(
|
|
isRetryableAssistantError(
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: openAIResponsesEarlyEofMessage }),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps provider limit errors non-retryable", () => {
|
|
expect(
|
|
isRetryableAssistantError(
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: "429 quota exceeded" }),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("classifies assistant error messages", () => {
|
|
expect(
|
|
isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })),
|
|
).toBe(true);
|
|
expect(
|
|
isRetryableAssistantError(
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: "524 status code (no body)" }),
|
|
),
|
|
).toBe(true);
|
|
expect(isRetryableAssistantError(fauxAssistantMessage("not an error"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("retryAssistantCall", () => {
|
|
const disabled: RetryPolicy = { enabled: false, maxRetries: 3, baseDelayMs: 0 };
|
|
const enabled: RetryPolicy = { enabled: true, maxRetries: 3, baseDelayMs: 0 };
|
|
|
|
it("returns a successful response immediately without retrying", async () => {
|
|
const produce = vi.fn(async () => fauxAssistantMessage("ok"));
|
|
const res = await retryAssistantCall(produce, enabled, undefined);
|
|
expect(res.content).toEqual([{ type: "text", text: "ok" }]);
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not retry an aborted message", async () => {
|
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "aborted" }));
|
|
const onRetry = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry });
|
|
expect(res.stopReason).toBe("aborted");
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
expect(onRetry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not retry a non-retryable error (quota/billing)", async () => {
|
|
const produce = vi.fn(async () =>
|
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
|
|
);
|
|
const onRetry = vi.fn();
|
|
const onRetryEnd = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryEnd });
|
|
expect(res.stopReason).toBe("error");
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
expect(onRetry).not.toHaveBeenCalled();
|
|
expect(onRetryEnd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries a transient error up to maxRetries then returns the final error", async () => {
|
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
|
const onRetry = vi.fn();
|
|
const onRetryEnd = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryEnd });
|
|
expect(res.stopReason).toBe("error");
|
|
expect(produce).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
|
expect(onRetry).toHaveBeenCalledTimes(3);
|
|
expect(onRetryEnd).toHaveBeenCalledWith(false, 3, "terminated");
|
|
});
|
|
|
|
it("stops retrying once a call succeeds", async () => {
|
|
let n = 0;
|
|
const produce = vi.fn(async () => {
|
|
n++;
|
|
return n < 3
|
|
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
|
: fauxAssistantMessage("recovered");
|
|
});
|
|
const onRetryEnd = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryEnd });
|
|
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
|
expect(produce).toHaveBeenCalledTimes(3);
|
|
expect(onRetryEnd).toHaveBeenCalledWith(true, 2);
|
|
});
|
|
|
|
it("does not retry when policy is disabled", async () => {
|
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
|
const onRetry = vi.fn();
|
|
const onRetryEnd = vi.fn();
|
|
const res = await retryAssistantCall(produce, disabled, undefined, { onRetry, onRetryEnd });
|
|
expect(res.stopReason).toBe("error");
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
expect(onRetry).not.toHaveBeenCalled();
|
|
expect(onRetryEnd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("emits onRetryAttemptStart after backoff before each retried call", async () => {
|
|
const events: string[] = [];
|
|
let n = 0;
|
|
const produce = vi.fn(async () => {
|
|
events.push(`produce:${n}`);
|
|
n++;
|
|
return n < 3
|
|
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
|
: fauxAssistantMessage("recovered");
|
|
});
|
|
const onRetry = vi.fn((attempt: number) => {
|
|
events.push(`retry:${attempt}`);
|
|
});
|
|
const onRetryAttemptStart = vi.fn(() => {
|
|
events.push("attempt-start");
|
|
});
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryAttemptStart });
|
|
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
|
expect(onRetry).toHaveBeenCalledTimes(2);
|
|
expect(onRetryAttemptStart).toHaveBeenCalledTimes(2);
|
|
expect(events).toEqual([
|
|
"produce:0",
|
|
"retry:1",
|
|
"attempt-start",
|
|
"produce:1",
|
|
"retry:2",
|
|
"attempt-start",
|
|
"produce:2",
|
|
]);
|
|
});
|
|
|
|
it("aborts backoff sleep via signal, returns an aborted message, and emits onRetryEnd(false)", async () => {
|
|
const controller = new AbortController();
|
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
|
const policy: RetryPolicy = { enabled: true, maxRetries: 5, baseDelayMs: 10_000 };
|
|
const onRetryEnd = vi.fn();
|
|
const p = retryAssistantCall(produce, policy, controller.signal, { onRetryEnd });
|
|
// Let one error call resolve and the first backoff sleep start, then abort.
|
|
await vi.waitFor(() => expect(produce).toHaveBeenCalled());
|
|
controller.abort();
|
|
const res = await p;
|
|
expect(res.stopReason).toBe("aborted");
|
|
expect(res.errorMessage).toBeUndefined();
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
expect(onRetryEnd).toHaveBeenCalledWith(false, 1, "terminated");
|
|
});
|
|
});
|