pi-mono/packages/ai/test/provider-retry.test.ts
Petr Houška 7af8533c6f
fix(ai): make provider retries abortable (#6980)
Replace OpenAI and Anthropic SDK retries with local request retries that
preserve configured retry counts, enforce maxRetryDelayMs, and make retry
backoff interruptible through AbortSignal.

Alternatives considered: Racing SDK requests against AbortSignal would make
cancellation observable but would not let Pi enforce maxRetryDelayMs before
the SDK enters its retry sleep. Patching private SDK internals would be brittle,
while disabling provider retries entirely would ignore explicitly configured
maxRetries.

Also, align the custom OpenAI Codex retry loop with the documented
maxRetryDelayMs behavior. Oversized server-requested delays now fail immediately
instead of being clamped, allowing the visible, abortable agent-level retry
policy to handle the failure.

Fixes #6911
2026-07-23 12:25:37 +02:00

81 lines
2.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { retryProviderRequest } from "../src/utils/provider-retry.ts";
function providerError(status: number | undefined, headers?: Record<string, string>): Error {
return Object.assign(new Error(`Provider error: ${status}`), {
status,
headers: new Headers(headers),
});
}
describe("provider request retries", () => {
afterEach(() => {
vi.useRealTimers();
});
it("retries retryable provider errors", async () => {
vi.useFakeTimers();
const request = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(providerError(429, { "retry-after-ms": "1000" }))
.mockResolvedValue("ok");
const result = retryProviderRequest(request, { maxRetries: 1 });
await vi.advanceTimersByTimeAsync(999);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(result).resolves.toBe("ok");
expect(request).toHaveBeenCalledTimes(2);
});
it("does not retry errors the provider marks as non-retryable", async () => {
const error = providerError(429, { "x-should-retry": "false" });
const request = vi.fn<() => Promise<string>>().mockRejectedValue(error);
await expect(retryProviderRequest(request, { maxRetries: 2 })).rejects.toBe(error);
expect(request).toHaveBeenCalledTimes(1);
});
it("rejects a provider-requested retry delay above the limit", async () => {
const request = vi.fn<() => Promise<string>>().mockRejectedValue(providerError(429, { "retry-after": "277403" }));
await expect(retryProviderRequest(request, { maxRetries: 1, maxRetryDelayMs: 1000 })).rejects.toThrow(
"Server requested 277403s retry delay (max: 1s)",
);
expect(request).toHaveBeenCalledTimes(1);
});
it("allows disabling the provider-requested retry delay cap", async () => {
vi.useFakeTimers();
const request = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(providerError(429, { "retry-after": "2" }))
.mockResolvedValue("ok");
const result = retryProviderRequest(request, { maxRetries: 1, maxRetryDelayMs: 0 });
await vi.advanceTimersByTimeAsync(1999);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(result).resolves.toBe("ok");
expect(request).toHaveBeenCalledTimes(2);
});
it("aborts a provider-requested retry delay", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const request = vi.fn<() => Promise<string>>().mockRejectedValue(providerError(429, { "retry-after": "277403" }));
const result = retryProviderRequest(request, { maxRetries: 2, maxRetryDelayMs: 0, signal: controller.signal });
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(1);
controller.abort();
await expect(result).rejects.toMatchObject({ name: "AbortError" });
expect(request).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
});