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