33e40c3e15
Fixes #6904
213 lines
8.7 KiB
TypeScript
213 lines
8.7 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";
|
|
const wrappedDnsLookupError =
|
|
"The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)";
|
|
|
|
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.each([
|
|
wrappedDnsLookupError,
|
|
"connect ENOTFOUND api.example.com",
|
|
"EAI_AGAIN api.example.com",
|
|
"getaddrinfo failed for api.example.com",
|
|
])("matches DNS transport failure wording: %s", (errorMessage) => {
|
|
expect(isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage }))).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 onRetryScheduled = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled });
|
|
expect(res.stopReason).toBe("aborted");
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
expect(onRetryScheduled).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 onRetryScheduled = vi.fn();
|
|
const onRetryFinished = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
|
|
expect(res.stopReason).toBe("error");
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
expect(onRetryScheduled).not.toHaveBeenCalled();
|
|
expect(onRetryFinished).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 onRetryScheduled = vi.fn();
|
|
const onRetryFinished = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
|
|
expect(res.stopReason).toBe("error");
|
|
expect(produce).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
|
expect(onRetryScheduled).toHaveBeenCalledTimes(3);
|
|
expect(onRetryFinished).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 onRetryFinished = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished });
|
|
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
|
expect(produce).toHaveBeenCalledTimes(3);
|
|
expect(onRetryFinished).toHaveBeenCalledWith(true, 2);
|
|
});
|
|
|
|
it("reports an aborted retried call as unsuccessful", async () => {
|
|
let n = 0;
|
|
const produce = vi.fn(async () => {
|
|
n++;
|
|
return n === 1
|
|
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
|
: fauxAssistantMessage("", { stopReason: "aborted" });
|
|
});
|
|
const onRetryFinished = vi.fn();
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished });
|
|
expect(res.stopReason).toBe("aborted");
|
|
expect(produce).toHaveBeenCalledTimes(2);
|
|
expect(onRetryFinished).toHaveBeenCalledWith(false, 1);
|
|
});
|
|
|
|
it("does not retry when policy is disabled", async () => {
|
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
|
const onRetryScheduled = vi.fn();
|
|
const onRetryFinished = vi.fn();
|
|
const res = await retryAssistantCall(produce, disabled, undefined, { onRetryScheduled, onRetryFinished });
|
|
expect(res.stopReason).toBe("error");
|
|
expect(produce).toHaveBeenCalledTimes(1);
|
|
expect(onRetryScheduled).not.toHaveBeenCalled();
|
|
expect(onRetryFinished).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 onRetryScheduled = vi.fn((attempt: number) => {
|
|
events.push(`retry:${attempt}`);
|
|
});
|
|
const onRetryAttemptStart = vi.fn(() => {
|
|
events.push("attempt-start");
|
|
});
|
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryAttemptStart });
|
|
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
|
expect(onRetryScheduled).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 onRetryFinished(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 onRetryFinished = vi.fn();
|
|
const p = retryAssistantCall(produce, policy, controller.signal, { onRetryFinished });
|
|
// 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(onRetryFinished).toHaveBeenCalledWith(false, 1, "terminated");
|
|
});
|
|
});
|