// The one place outbound requests are made. Everything the extension fetches // belongs to somebody else — the page's origin, a stranger's PDS, plc.directory // — so no request may hang forever, retry something that will never succeed, or // read an unbounded body into a service worker that cannot be restarted cleanly. // // Retries are deliberately narrow. A 404 from a well-known probe is the answer, // not a failure, and that is the response almost every origin gives: retrying it // would multiply the extension's traffic across the whole web for nothing. Only // timeouts, transport errors and the statuses that explicitly mean "later" // (408, 425, 429, 5xx) are worth a second attempt. /** A response that arrived and said no. Never retried past the policy below. */ export class HttpError extends Error { constructor( readonly status: number, readonly statusText: string, readonly url: string, ) { super(`${status} ${statusText} for ${url}`) this.name = 'HttpError' } } export interface RequestOptions { /** Per attempt, not for the whole call; each retry gets a fresh budget. */ timeoutMs?: number /** Extra attempts after the first. 0 disables retrying. */ retries?: number baseDelayMs?: number maxDelayMs?: number /** Hard cap on the body we will read. */ maxBytes?: number /** Caller cancellation; distinct from our own timeout and never retried. */ signal?: AbortSignal } const DEFAULTS = { timeoutMs: 8_000, retries: 2, baseDelayMs: 300, maxDelayMs: 4_000, maxBytes: 1_000_000, } satisfies Required> /** * Cheap, stable-by-default profile for the well-known probe, which runs against * every origin the user visits: one short attempt and a single retry, because * the overwhelming majority of these are 404s that we want to spend as little * of the user's bandwidth and the site's attention on as possible. */ export const PROBE_OPTIONS: RequestOptions = { timeoutMs: 5_000, retries: 1, maxBytes: 8_192 } function transientStatus(status: number): boolean { return status === 408 || status === 425 || status === 429 || status >= 500 } /** Full-jitter exponential backoff: spreads retries instead of synchronising them. */ export function backoffDelay(attempt: number, base: number, max: number): number { return Math.random() * Math.min(max, base * 2 ** attempt) } /** `Retry-After` in seconds or as an HTTP date; undefined when absent or absurd. */ export function retryAfterDelay(header: string | null, now = Date.now()): number | undefined { if (!header) return undefined const seconds = Number(header.trim()) const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - now if (!Number.isFinite(ms) || ms < 0) return undefined // A server asking us to wait a minute has effectively said no; treat // anything beyond that as non-retryable rather than stalling a page check. return ms <= 60_000 ? ms : undefined } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) /** True once the browser is sure it is offline; retries cannot help then. */ function definitelyOffline(): boolean { return typeof navigator !== 'undefined' && navigator.onLine === false } /** * Read at most `maxBytes` of a response body. A hostile or broken origin can * otherwise stream indefinitely into the worker; content-length is checked * first as a courtesy and the stream is counted regardless, since the header * is a claim rather than a guarantee. */ export async function readCapped(res: Response, maxBytes: number): Promise { const declared = Number(res.headers.get('content-length')) if (Number.isFinite(declared) && declared > maxBytes) { throw new Error(`response from ${res.url} declares ${declared} bytes, over the ${maxBytes} cap`) } if (!res.body) return (await res.text()).slice(0, maxBytes) const reader = res.body.getReader() const chunks: Uint8Array[] = [] let total = 0 try { for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength if (total > maxBytes) { throw new Error(`response from ${res.url} exceeded the ${maxBytes} byte cap`) } chunks.push(value) } } finally { await reader.cancel().catch(() => {}) } return new TextDecoder().decode( chunks.reduce((acc, c) => { const out = new Uint8Array(acc.length + c.length) out.set(acc) out.set(c, acc.length) return out }, new Uint8Array()), ) } /** * Fetch with a per-attempt timeout and narrow retries. `credentials: 'omit'` is * explicit rather than inherited: these requests go to third parties, and the * privacy policy's claim that we send nothing of the user's should be visible * in the code that sends them. */ export async function request(url: string, options: RequestOptions = {}): Promise { const { timeoutMs, retries, baseDelayMs, maxDelayMs } = { ...DEFAULTS, ...options } let lastError: unknown for (let attempt = 0; ; attempt++) { // AbortSignal.timeout is Chrome 103; AbortSignal.any is Chrome 116, above // the manifest's minimum_chrome_version. Nothing passes `signal` today, so // that branch is unreachable and the floor stays where it is — but the // first caller to pass one makes 116 the real minimum. Raise // minimum_chrome_version then, or combine the signals by hand here. const timeout = AbortSignal.timeout(timeoutMs) const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout // Decided rather than thrown: a `throw` here would be caught by this same // try's own handler, which cannot tell giving up from failing. let delay: number | undefined let retryable: boolean try { const res = await fetch(url, { credentials: 'omit', redirect: 'follow', signal }) if (res.ok) return res lastError = new HttpError(res.status, res.statusText, url) const retryAfter = res.headers.get('retry-after') delay = retryAfterDelay(retryAfter) // A Retry-After we are unwilling to honour ends the call: the server // named its terms and they are worse for the user than failing now. retryable = transientStatus(res.status) && !(retryAfter !== null && delay === undefined) } catch (err) { // The caller gave up, or asked us to; never retry either. if (options.signal?.aborted) throw err lastError = err retryable = !definitelyOffline() } if (!retryable || attempt >= retries) break await sleep(delay ?? backoffDelay(attempt, baseDelayMs, maxDelayMs)) console.debug('[substandard] retrying', url, `attempt ${attempt + 2}/${retries + 1}`) } throw lastError } export async function requestText(url: string, options: RequestOptions = {}): Promise { const res = await request(url, options) return readCapped(res, options.maxBytes ?? DEFAULTS.maxBytes) } export async function requestJson(url: string, options: RequestOptions = {}): Promise { return JSON.parse(await requestText(url, options)) as T }