feat(adapter-utils): add fetchWithRetry helper for adapter HTTP calls

Generic HTTP retry wrapper with exponential backoff and Retry-After
support, intended for adapters that hit external HTTP APIs (e.g. provider
model-discovery or token-introspection endpoints) and need to handle
transient rate-limit / server errors gracefully.

Origin: extracted from #3629 (HearthCore) so the helper can land on its
own merit without waiting on the full copilot_local adapter port.

- Default retryable statuses: 429, 502, 503, 504 (403 NOT included by
  default — opt-in for APIs like Copilot that use 403 for TPM rate limits).
- Honors Retry-After (seconds or HTTP-date) and caps wait at maxDelayMs.
- Combines a per-attempt timeout signal with any caller-provided signal
  via AbortSignal.any() so external cancellation is always respected.
- Returns the final Response; only throws on network/abort errors.

Tests cover happy path, retry-then-success, exhaustion, non-retryable
status, onRetry callback shape, custom retryableStatuses, Retry-After
cap, and per-attempt timeout abort.
This commit is contained in:
Michel Tomas 2026-04-28 13:23:30 +02:00 committed by Michel Tomas
parent 4ffa8de4e2
commit c8838ce3a2
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 287 additions and 1 deletions

View File

@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyPaperclipWorkspaceEnv,
appendWithByteCap,
@ -12,6 +12,7 @@ import {
buildInvocationEnvForLogs,
buildPaperclipEnv,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
fetchWithRetry,
materializePaperclipSkillCopy,
refreshPaperclipWorkspaceEnvForExecution,
renderPaperclipWakePrompt,
@ -2686,5 +2687,170 @@ describe("buildPaperclipEnv", () => {
const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" });
expect(env.PAPERCLIP_API_URL).toBe("http://localhost:3200");
});
describe("fetchWithRetry", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
function makeResponse(status: number, headers: Record<string, string> = {}) {
return new Response("", { status, headers });
}
it("returns the first successful response without retrying", async () => {
const fetchMock = vi.fn().mockResolvedValue(makeResponse(200));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const res = await fetchWithRetry("https://example.test", {});
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("retries on 429 and returns the eventual 200", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(makeResponse(429))
.mockResolvedValueOnce(makeResponse(200));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const res = await fetchWithRetry(
"https://example.test",
{},
{ baseDelayMs: 1, maxDelayMs: 1 },
);
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("returns the last failed response after exhausting retries", async () => {
const fetchMock = vi.fn().mockResolvedValue(makeResponse(503));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const res = await fetchWithRetry(
"https://example.test",
{},
{ maxRetries: 2, baseDelayMs: 1, maxDelayMs: 1 },
);
expect(res.status).toBe(503);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("does not retry on non-retryable status codes", async () => {
const fetchMock = vi.fn().mockResolvedValue(makeResponse(400));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const res = await fetchWithRetry(
"https://example.test",
{},
{ baseDelayMs: 1, maxDelayMs: 1 },
);
expect(res.status).toBe(400);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("invokes onRetry with attempt metadata before each wait", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(makeResponse(429, { "retry-after": "0" }))
.mockResolvedValueOnce(makeResponse(200));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const onRetry = vi.fn();
await fetchWithRetry(
"https://example.test",
{},
{
baseDelayMs: 1,
maxDelayMs: 1,
onRetry,
},
);
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({
attempt: 1,
maxRetries: 3,
status: 429,
retryAfterHeader: "0",
}),
);
});
it("treats custom retryableStatuses as retryable (e.g. 403)", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(makeResponse(403))
.mockResolvedValueOnce(makeResponse(200));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const res = await fetchWithRetry(
"https://example.test",
{},
{ baseDelayMs: 1, maxDelayMs: 1, retryableStatuses: [403, 429] },
);
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("caps Retry-After header value at maxDelayMs", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(makeResponse(429, { "retry-after": "60" }))
.mockResolvedValueOnce(makeResponse(200));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const onRetry = vi.fn();
const start = Date.now();
const res = await fetchWithRetry(
"https://example.test",
{},
{ baseDelayMs: 1, maxDelayMs: 5, onRetry },
);
const elapsed = Date.now() - start;
expect(res.status).toBe(200);
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({ delayMs: 5 }),
);
expect(elapsed).toBeLessThan(1_000);
});
it("aborts the request when the per-attempt timeout fires", async () => {
const fetchMock = vi.fn((_url: string | URL, init?: RequestInit) => {
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal as AbortSignal | undefined;
if (signal) {
signal.addEventListener("abort", () => {
reject(new DOMException("aborted", "AbortError"));
});
}
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(
fetchWithRetry(
"https://example.test",
{},
{ maxRetries: 0, timeoutMs: 5 },
),
).rejects.toMatchObject({ name: "AbortError" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -3529,3 +3529,123 @@ export async function runChildProcess(
.catch(reject);
});
}
/**
* Options for `fetchWithRetry`.
*/
export interface FetchRetryOptions {
/** Maximum number of retry attempts (default: 3). 0 = no retries. */
maxRetries?: number;
/** Base delay in ms before first retry — doubles each attempt (default: 2000). */
baseDelayMs?: number;
/** Maximum delay cap in ms (default: 30000). */
maxDelayMs?: number;
/** HTTP status codes that trigger a retry (default: [429, 502, 503, 504]).
* 403 is NOT included by default pass it explicitly for APIs like Copilot
* that use 403 for TPM rate limits. */
retryableStatuses?: number[];
/** Optional callback for retry logging. Called before each retry wait. */
onRetry?: (info: {
attempt: number;
maxRetries: number;
status: number;
delayMs: number;
retryAfterHeader?: string | null;
}) => void | Promise<void>;
/** Request timeout in ms per attempt (default: no timeout). */
timeoutMs?: number;
}
const DEFAULT_RETRYABLE_STATUSES = [429, 502, 503, 504];
/**
* Fetch with automatic retry on rate-limit / transient server errors.
*
* - Respects `Retry-After` header (seconds or HTTP-date) when present.
* - Falls back to exponential backoff: baseDelay * 2^attempt, capped at maxDelay.
* - Returns the final Response (successful or last failed attempt).
* - Throws only on network/abort errors, never on HTTP status codes.
*
* Adapters that call external HTTP APIs (e.g. provider model-discovery or
* token-introspection endpoints) should use this instead of bare `fetch()`
* to handle transient rate-limit and server errors gracefully.
*/
export async function fetchWithRetry(
url: string | URL,
init: RequestInit,
options: FetchRetryOptions = {},
): Promise<Response> {
const {
maxRetries = 3,
baseDelayMs = 2000,
maxDelayMs = 30_000,
retryableStatuses = DEFAULT_RETRYABLE_STATUSES,
onRetry,
timeoutMs,
} = options;
let lastResponse: Response | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let controller: AbortController | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
if (timeoutMs && timeoutMs > 0) {
controller = new AbortController();
timer = setTimeout(() => controller!.abort(), timeoutMs);
}
const signals: AbortSignal[] = [];
if (controller?.signal) signals.push(controller.signal);
if (init.signal) signals.push(init.signal as AbortSignal);
const signal =
signals.length > 1
? AbortSignal.any(signals)
: signals[0] ?? undefined;
try {
lastResponse = await fetch(url, { ...init, signal });
} finally {
if (timer) clearTimeout(timer);
}
if (lastResponse.ok) return lastResponse;
if (!retryableStatuses.includes(lastResponse.status) || attempt >= maxRetries) {
return lastResponse;
}
const retryAfter = lastResponse.headers.get("retry-after");
let delayMs = baseDelayMs * Math.pow(2, attempt);
if (retryAfter) {
const parsed = Number(retryAfter);
if (!Number.isNaN(parsed) && parsed > 0) {
delayMs = parsed * 1000;
} else {
const date = new Date(retryAfter).getTime();
if (!Number.isNaN(date)) {
delayMs = Math.max(0, date - Date.now());
}
}
}
delayMs = Math.min(delayMs, maxDelayMs);
try { await lastResponse.text(); } catch { /* drain body to free connection */ }
if (onRetry) {
await onRetry({
attempt: attempt + 1,
maxRetries,
status: lastResponse.status,
delayMs,
retryAfterHeader: retryAfter,
});
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return lastResponse!;
}