Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 150 additions & 1 deletion src/llm/provider/openai/openai-http.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import {
OpenAiHttpError,
Expand Down Expand Up @@ -115,6 +115,155 @@ describe("openAiPostJson", () => {
expect(fetchImpl).toHaveBeenCalledTimes(2);
});

describe("structured RetryInfo metadata", () => {
// Gemini's OpenAI-compatible endpoint sends its cooldown only in
// the error JSON — google.rpc.RetryInfo with a protobuf Duration
// string — and no `retry-after` header. Fake timers plus a pinned
// Math.random (0.5 zeroes the ±20% jitter) make every wait exact.
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

function retryInfoBody(retryDelay: unknown, status = 429): string {
return JSON.stringify({
error: {
code: status,
details: [
{ "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay },
],
},
});
}

async function expectSecondFetchAfter(
pending: Promise<unknown>,
fetchImpl: ReturnType<typeof vi.fn>,
waitMs: number,
): Promise<void> {
await vi.advanceTimersByTimeAsync(waitMs - 1);
expect(fetchImpl).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(pending).resolves.toEqual({ ok: true });
expect(fetchImpl).toHaveBeenCalledTimes(2);
}

it.each([
{ status: 429, retryDelay: "1.5s", expectedMs: 1_500 },
{ status: 503, retryDelay: "2s", expectedMs: 2_000 },
])(
"honors error.details[].retryDelay on a headerless $status",
async ({ status, retryDelay, expectedMs }) => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0.5);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(errorResponse(status, retryInfoBody(retryDelay, status)))
.mockResolvedValueOnce(jsonResponse({ ok: true }));
const pending = openAiPostJson(
depsWith(fetchImpl as unknown as typeof fetch),
"/x",
{},
{},
);
await expectSecondFetchAfter(pending, fetchImpl, expectedMs);
},
);

it.each(["not-a-duration", "-1s", 39])(
"ignores unusable retryDelay %j and keeps the plain backoff",
async (retryDelay) => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0.5);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(errorResponse(429, retryInfoBody(retryDelay)))
.mockResolvedValueOnce(jsonResponse({ ok: true }));
const pending = openAiPostJson(
depsWith(fetchImpl as unknown as typeof fetch),
"/x",
{},
{},
);
await expectSecondFetchAfter(pending, fetchImpl, 150);
},
);

it("prefers a valid retry-after header over the structured delay", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0.5);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(
errorResponse(429, retryInfoBody("4s"), { "retry-after": "0.5" }),
)
.mockResolvedValueOnce(jsonResponse({ ok: true }));
const pending = openAiPostJson(
depsWith(fetchImpl as unknown as typeof fetch),
"/x",
{},
{},
);
await expectSecondFetchAfter(pending, fetchImpl, 500);
});

it("caps a long structured delay like a header-declared one", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0.5);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(errorResponse(429, retryInfoBody("39s")))
.mockResolvedValueOnce(jsonResponse({ ok: true }));
const pending = openAiPostJson(
depsWith(fetchImpl as unknown as typeof fetch),
"/x",
{},
{},
);
await expectSecondFetchAfter(pending, fetchImpl, 5_000);
});

it("reads the metadata only on throttling statuses", async () => {
// A 500 carrying RetryInfo-shaped JSON keeps the plain backoff.
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0.5);
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(errorResponse(500, retryInfoBody("4s", 500)))
.mockResolvedValueOnce(jsonResponse({ ok: true }));
const pending = openAiPostJson(
depsWith(fetchImpl as unknown as typeof fetch),
"/x",
{},
{},
);
await expectSecondFetchAfter(pending, fetchImpl, 150);
});

it("lets caller cancellation interrupt the structured wait", async () => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0.5);
const controller = new AbortController();
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(errorResponse(429, retryInfoBody("39s")));
const pending = openAiPostJson(
depsWith(fetchImpl as unknown as typeof fetch),
"/x",
{},
{ signal: controller.signal },
);
await vi.advanceTimersByTimeAsync(0);
expect(fetchImpl).toHaveBeenCalledTimes(1);
controller.abort();
await expect(pending).rejects.toMatchObject({
status: null,
message: "completion aborted by caller",
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
});

it("wraps network failures as status null and retries them", async () => {
const fetchImpl = vi
.fn()
Expand Down
50 changes: 48 additions & 2 deletions src/llm/provider/openai/openai-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export type OpenAiHttpDeps = {
* surface as aborts, but a timeout is "the provider is slower than
* the budget" — replaying it just burns another full timeout.
* - `retryAfterMs` is populated from a `retry-after` header when the
* provider sent one (429/503), so the retry loop can honor it.
* provider sent one (429/503), falling back to structured
* `RetryInfo` metadata in the error body, so the retry loop can
* honor the provider's cooldown either way.
*/
export class OpenAiHttpError extends Error {
constructor(
Expand Down Expand Up @@ -280,12 +282,21 @@ async function httpErrorFromResponse(
res: Response,
): Promise<OpenAiHttpError> {
const text = await res.text().catch(() => "");
// The standard `retry-after` header wins; some providers advertise
// their cooldown only inside the error JSON (Gemini's OpenAI-compat
// endpoint sends `google.rpc.RetryInfo` and no header), so fall back
// to that for the throttling statuses the retry loop honors.
const retryAfterMs =
parseRetryAfterMs(res.headers.get("retry-after")) ??
(res.status === 429 || res.status === 503
? parseRetryInfoDelayMs(text)
: null);
return new OpenAiHttpError(
`openai provider ${res.status}: ${text.slice(0, OPENAI_ERROR_DETAIL_MAX_LEN)}`,
res.status,
`${deps.baseUrl}${path}`,
false,
parseRetryAfterMs(res.headers.get("retry-after")),
retryAfterMs,
deps.label,
);
}
Expand Down Expand Up @@ -351,6 +362,41 @@ function resolveWaitMs(err: unknown, attemptNumber: number): number {
return Math.max(backoff, retryAfter);
}

/**
* Cooldown from structured error JSON, for providers that never send a
* `retry-after` header. Gemini answers 429/503 with an
* `error.details[]` entry of `@type google.rpc.RetryInfo` whose
* `retryDelay` is a protobuf Duration string ("39s", "1.5s"). The
* duration grammar admits only non-negative seconds, so anything else —
* malformed, negative, non-string — is ignored and the caller keeps the
* plain exponential backoff. The result flows through the same
* `retryAfterMs` field as the header, so `resolveWaitMs` caps it at
* `OPENAI_RETRY_AFTER_CAP_MS` exactly like a header-declared wait.
*/
function parseRetryInfoDelayMs(body: string): number | null {
let parsed: unknown;
try {
parsed = JSON.parse(body) as unknown;
} catch {
return null;
}
if (!isRecord(parsed) || !isRecord(parsed.error)) return null;
const details = parsed.error.details;
if (!Array.isArray(details)) return null;
for (const detail of details) {
if (!isRecord(detail) || typeof detail.retryDelay !== "string") continue;
const match = /^(\d+(?:\.\d+)?)s$/.exec(detail.retryDelay);
if (!match) continue;
const ms = Math.round(Number(match[1]) * 1000);
if (Number.isFinite(ms)) return ms;
}
return null;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseRetryAfterMs(header: string | null): number | null {
if (!header) return null;
const seconds = Number(header);
Expand Down
Loading