import { type LimitFunction, pLimit } from "./pLimit" /** * The default number of milliseconds to wait before retrying a rate-limited `fetch()` request (429 * response code). The default value is only used if the response does not include a `retry-after` * header. */ export const DEFAULT_RETRY_AFTER = 1500 // ms /** A record of URLs mapped to throttled task runners. */ const THROTTLED_RUNNERS: Partial> = {} /** A record of URLs mapped to active deduplicated jobs. Jobs are keyed by their optional signal. */ const DEDUPLICATED_JOBS: Partial< Record>> > = {} /** * A universal API to make network requests. A subset of the `fetch()` API. * * {@link https://developer.mozilla.org/en-US/docs/Web/API/fetch} */ export type FetchLike = (input: string, init?: RequestInitLike) => Promise /** * An object that allows you to abort a `fetch()` request if needed via an `AbortController` object * * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal} */ // `any` is used often here to ensure this type is universally valid among // different AbortSignal implementations. The types of each property are not // important to validate since it is blindly passed to a given `fetch()` // function. // oxlint-disable-next-line no-explicit-any export type AbortSignalLike = any /** A subset of RequestInit properties to configure a `fetch()` request. */ // Only options relevant to the client are included. Extending from the full // RequestInit would cause issues, such as accepting Header objects. // // An interface is used to allow other libraries to augment the type with // environment-specific types. export interface RequestInitLike extends Pick { /** The HTTP method to use for the request. */ method?: string /** The request body to send with the request. */ // We want to keep the body type as compatible as possible, so // we only declare the type we need and accept anything else. // oxlint-disable-next-line no-explicit-any body?: any | FormData | string /** An object literal to set the `fetch()` request's headers. */ headers?: Record /** * An AbortSignal to set the `fetch()` request's signal. * * See: * [https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) */ // NOTE: `AbortSignalLike` is `any`! It is left as `AbortSignalLike` // for backwards compatibility (the type is exported) and to signal to // other readers that this should be an AbortSignal-like object. signal?: AbortSignalLike } /** The minimum required properties from Response. */ export interface ResponseLike { ok: boolean status: number headers: HeadersLike url: string // oxlint-disable-next-line no-explicit-any json(): Promise text(): Promise arrayBuffer(): Promise blob(): Promise clone(): ResponseLike } /** The minimum required properties from Headers. */ export interface HeadersLike { get(name: string): string | null } async function memoizeResponse(response: ResponseLike): Promise { // Deduplicated responses are shared across multiple callers. Calling // response.clone() on a shared response can cause backpressure hangs // in Node.js, so we buffer the body as an ArrayBuffer upfront instead. const buffer = await response.arrayBuffer() const memoized: ResponseLike = { ok: response.ok, status: response.status, headers: response.headers, url: response.url, text: async () => new TextDecoder().decode(buffer), json: async () => JSON.parse(new TextDecoder().decode(buffer)), arrayBuffer: async () => buffer, blob: async () => new Blob([buffer]), clone: () => memoized, } return memoized } /** * Makes an HTTP request with automatic retry for rate limits and request deduplication. * * @param url - The URL to request. * @param init - Fetch options. * @param fetchFn - The fetch function to use. * @returns The response from the fetch request. */ export async function request( url: URL, init: RequestInitLike | undefined, fetchFn: FetchLike, ): Promise { const stringURL = url.toString() let job: Promise // Throttle requests with a body. if (init?.body) { // Rate limiting is done per hostname. const runner = (THROTTLED_RUNNERS[url.hostname] ||= pLimit({ interval: DEFAULT_RETRY_AFTER, })) job = runner(() => fetchFn(stringURL, init)) } else { // Deduplicate all other requests. const existingJob = DEDUPLICATED_JOBS[stringURL]?.get(init?.signal) if (existingJob) { job = existingJob } else { job = fetchFn(stringURL, init) .then(memoizeResponse) .finally(() => { DEDUPLICATED_JOBS[stringURL]?.delete(init?.signal) if (DEDUPLICATED_JOBS[stringURL]?.size === 0) { delete DEDUPLICATED_JOBS[stringURL] } }) const map = (DEDUPLICATED_JOBS[stringURL] ||= new Map()) map.set(init?.signal, job) } } const response = await job // Retry rate limited requests. if (response.status === 429) { const retryAfter = Number(response.headers.get("retry-after")) const resolvedRetryAfter = Number.isNaN(retryAfter) ? DEFAULT_RETRY_AFTER : retryAfter * 1000 await new Promise((resolve) => setTimeout(resolve, resolvedRetryAfter)) return request(url, init, fetchFn) } return response }