/** * Rate-limited HTTP client for connector packages: per-host token bucket, * exponential backoff with jitter on 429/5xx, fetch-based. * * Retry classification: only idempotent methods (GET/HEAD/OPTIONS/PUT/DELETE) * are retried automatically. A non-idempotent request (POST/PATCH) that gets * 429/5xx or loses its transport is never resent here — the caller receives a * typed error recording whether delivery was definitely refused or unknown. * Read-only POST endpoints can opt back in with `{ retry: true }`; publishing * mutations can opt out entirely with `{ retry: false }`. */ import { ProviderRejectedError, ProviderServerError, ProviderTransportError, RateLimitError, } from "./errors.js"; interface Bucket { tokens: number; lastRefillMs: number; } export interface HttpClientOptions { /** Requests per second allowed per host (default 1). */ requestsPerSecond?: number; /** Max backoff attempt count (default 4). */ maxRetries?: number; fetchImpl?: typeof fetch; sleep?: (ms: number) => Promise; } export interface HttpRequestOptions { /** * Auto-retry on 429/5xx. Default: true for idempotent methods, false * otherwise. `true` on a POST asserts the endpoint is side-effect free. */ retry?: boolean; } /** Nonsecret request description handed to hooks — never the full URL. */ export interface HttpRequestEvent { provider: string; method: string; origin: string; /** 0 for the first send, then one per automatic retry. */ attempt: number; /** Whether this request may have side effects on the provider. */ mutating: boolean; } export interface HttpResponseEvent extends HttpRequestEvent { status: number; } /** * Per-execution hook, resolved for every request. `beforeRequest` runs after * the rate-limiter wait and immediately before each actual send; throwing * from it blocks the send (zero HTTP requests leave the process). */ export interface HttpRequestHook { aroundMutation?(event: HttpRequestEvent, send: () => Promise): Promise; beforeRequest?(event: HttpRequestEvent): void | Promise; afterResponse?(event: HttpResponseEvent): void; afterTransportError?(event: HttpRequestEvent): void; } let hookProvider: (() => HttpRequestHook | undefined) | undefined; /** * Install the resolver for the current execution's hook (e.g. an * AsyncLocalStorage lookup in the publishing runtime). The resolver is * consulted per request, so concurrent executions never share a hook. * Pass undefined to uninstall. */ export function setHttpRequestHookProvider(provider: (() => HttpRequestHook | undefined) | undefined): void { hookProvider = provider; } const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]); const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); export function isIdempotentMethod(method: string): boolean { return IDEMPOTENT_METHODS.has(method.toUpperCase()); } const defaultSleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); /** Retry-After header: delta-seconds or HTTP-date → ms. */ function headerRetryAfterMs(response: Response): number | undefined { const raw = response.headers.get("retry-after"); if (raw === null || raw.trim().length === 0) return undefined; const seconds = Number(raw); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const at = Date.parse(raw); return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined; } /** `retry_after` (seconds) in a JSON error body, top level or under `parameters`. */ async function bodyRetryAfterMs(response: Response): Promise { try { const body = (await response.json()) as { retry_after?: unknown; parameters?: { retry_after?: unknown } | null; } | null; const value = body?.parameters?.retry_after ?? body?.retry_after; return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value * 1000) : undefined; } catch { return undefined; } } export class RateLimitedHttpClient { private readonly buckets = new Map(); private readonly rps: number; private readonly maxRetries: number; private readonly fetchImpl: typeof fetch; private readonly sleep: (ms: number) => Promise; constructor(opts: HttpClientOptions = {}) { this.rps = opts.requestsPerSecond ?? 1; this.maxRetries = opts.maxRetries ?? 4; this.fetchImpl = opts.fetchImpl ?? fetch; this.sleep = opts.sleep ?? defaultSleep; } private async takeToken(host: string): Promise { for (;;) { const now = Date.now(); const bucket = this.buckets.get(host) ?? { tokens: 1, lastRefillMs: now }; const elapsed = (now - bucket.lastRefillMs) / 1000; bucket.tokens = Math.min(1, bucket.tokens + elapsed * this.rps); bucket.lastRefillMs = now; if (bucket.tokens >= 1) { bucket.tokens -= 1; this.buckets.set(host, bucket); return; } this.buckets.set(host, bucket); const waitMs = Math.ceil(((1 - bucket.tokens) / this.rps) * 1000); await this.sleep(waitMs); } } /** * Never resolves with a 429/5xx: those are retried (when allowed) and * otherwise thrown as RateLimitError / ProviderServerError. */ async request( provider: string, url: string, init: RequestInit = {}, options: HttpRequestOptions = {}, ): Promise { const parsed = new URL(url); const method = (init.method ?? "GET").toUpperCase(); const retry = options.retry ?? isIdempotentMethod(method); // A POST opted into retries is asserted side-effect free. const mutating = !SAFE_METHODS.has(method) && !(options.retry === true && !isIdempotentMethod(method)); let attempt = 0; for (;;) { await this.takeToken(parsed.host); const hook = hookProvider?.(); const event: HttpRequestEvent = { provider, method, origin: parsed.origin, attempt, mutating }; await hook?.beforeRequest?.(event); const send = async (): Promise => { try { return await this.fetchImpl(url, init); } catch (cause) { hook?.afterTransportError?.(event); if (retry) throw cause; throw new ProviderTransportError(provider, method, cause); } }; const response = event.mutating && hook?.aroundMutation !== undefined ? await hook.aroundMutation(event, send) : await send(); hook?.afterResponse?.({ ...event, status: response.status }); const failed = response.status === 429 || response.status >= 500; if (!failed) { return response; } const headerAfter = headerRetryAfterMs(response); if (!retry || attempt >= this.maxRetries) { if (response.status === 429) { throw new RateLimitError(provider, headerAfter ?? (await bodyRetryAfterMs(response))); } const delivery = SAFE_METHODS.has(method) ? "rejected" : "unknown"; throw new ProviderServerError(provider, response.status, method, delivery, headerAfter); } const backoffMs = headerAfter ?? Math.min(30_000, 2 ** attempt * 500 + Math.random() * 250); await this.sleep(backoffMs); attempt += 1; } } async json( provider: string, url: string, init: RequestInit = {}, options: HttpRequestOptions = {}, ): Promise { const response = await this.request( provider, url, { ...init, headers: { accept: "application/json", ...init.headers } }, options, ); if (!response.ok) { throw new ProviderRejectedError( `${provider}: HTTP ${response.status} for ${url}`, provider, response.status, ); } return (await response.json()) as T; } }