mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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
81 lines
2.9 KiB
TypeScript
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);
|
|
});
|
|
});
|