Files

112 lines
3.7 KiB
TypeScript

import type { HttpClient } from "./types.ts";
/** Origin + pathname only: query strings may carry API keys or tokens. */
function safeUrl(url: string): string {
try {
const u = new URL(url);
return u.origin + u.pathname;
} catch {
return url.split(/[?#]/)[0] ?? "";
}
}
export class HttpError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string, url = "") {
super(`HTTP ${status}${url ? ` for ${safeUrl(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<void>;
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<void>((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<void> = Promise.resolve();
async function take(): Promise<void> {
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<void> {
if (!perMs) return Promise.resolve();
const p = queue.then(take);
queue = p.catch(() => {});
return p;
}
async function send(url: string, init: RequestInit | undefined): Promise<Response> {
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<T>(url: string, init?: RequestInit & { query?: Record<string, string | number | undefined> }): Promise<T> {
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;
},
};
}