import type { HttpClient } from "./types.ts"; export class HttpError extends Error { readonly status: number; readonly body: string; constructor(status: number, body: string, url = "") { super(`HTTP ${status}${url ? ` for ${url}` : ""}`); this.name = "HttpError"; this.status = status; this.body = body; } } export interface HttpClientOptions { ratePerMinute?: number; burst?: number; maxRetries?: number; userAgent: string; fetchImpl?: typeof fetch; sleep?: (ms: number) => Promise; now?: () => number; } const retryable = (status: number) => status === 429 || status >= 500; function retryDelayMs(res: Response, attempt: number, now: number): number { const ra = res.headers.get("retry-after"); if (ra !== null && ra.trim() !== "") { const secs = Number(ra); if (Number.isFinite(secs)) return Math.max(0, secs * 1000); const at = Date.parse(ra); if (!Number.isNaN(at)) return Math.max(0, at - now); } const reset = res.headers.get("x-ratelimit-reset-in"); if (reset !== null) { const secs = Number(reset); if (Number.isFinite(secs)) return Math.max(0, secs * 1000); } return 500 * 2 ** attempt; } export function createHttpClient(opts: HttpClientOptions): HttpClient { const doFetch = opts.fetchImpl ?? fetch; const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); const now = opts.now ?? Date.now; const maxRetries = opts.maxRetries ?? 3; // Token bucket: capacity `burst`, refilling at ratePerMinute / 60000 tokens per ms. const perMs = opts.ratePerMinute ? opts.ratePerMinute / 60_000 : 0; const capacity = Math.max(1, opts.burst ?? 1); let tokens = capacity; let last = now(); let queue: Promise = Promise.resolve(); async function take(): Promise { for (;;) { const t = now(); tokens = Math.min(capacity, tokens + (t - last) * perMs); last = t; if (tokens >= 1 - 1e-9) { tokens -= 1; return; } await sleep(Math.ceil((1 - tokens) / perMs)); } } function acquire(): Promise { if (!perMs) return Promise.resolve(); const p = queue.then(take); queue = p.catch(() => {}); return p; } async function send(url: string, init: RequestInit | undefined): Promise { const headers = new Headers(init?.headers); if (!headers.has("user-agent")) headers.set("user-agent", opts.userAgent); for (let attempt = 0; ; attempt++) { await acquire(); const res = await doFetch(url, { ...init, headers }); if (!retryable(res.status)) return res; if (attempt >= maxRetries) throw new HttpError(res.status, await res.text(), url); const delay = retryDelayMs(res, attempt, now()); await res.body?.cancel(); await sleep(delay); } } return { raw: (url, init) => send(url, init), async json(url: string, init?: RequestInit & { query?: Record }): Promise { const { query, ...rest } = init ?? {}; const u = new URL(url); for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) u.searchParams.set(k, String(v)); const headers = new Headers(rest.headers); if (!headers.has("accept")) headers.set("accept", "application/json"); const res = await send(u.toString(), { ...rest, headers }); if (!res.ok) throw new HttpError(res.status, await res.text(), u.toString()); return (await res.json()) as T; }, }; }