import { afterEach, describe, expect, it, vi } from 'vitest' import { HttpError, backoffDelay, readCapped, request, requestJson, retryAfterDelay, } from './http' afterEach(() => { vi.unstubAllGlobals() }) // baseDelayMs 0 keeps the retry path instant; the delay maths is covered // separately by the backoffDelay/retryAfterDelay cases below. const fast = { baseDelayMs: 0, maxDelayMs: 0 } function responses(...queue: (Response | Error)[]) { // Typed params so the assertions below can read the RequestInit back off // the recorded calls. const fetchSpy = vi.fn(async (_url: string, _init?: RequestInit) => { const next = queue.shift() if (!next) throw new Error('fetch called more times than the test queued') if (next instanceof Error) throw next return next }) vi.stubGlobal('fetch', fetchSpy) return fetchSpy } describe('request retries', () => { it('returns the first successful response without retrying', async () => { const fetchSpy = responses(new Response('ok')) await expect(request('https://e.test/x', fast)).resolves.toBeInstanceOf(Response) expect(fetchSpy).toHaveBeenCalledTimes(1) }) // The well-known probe 404s on almost every origin on the web. Retrying that // would multiply the extension's traffic for an answer that will not change. it('does not retry a 404, which is an answer rather than a failure', async () => { const fetchSpy = responses(new Response('no', { status: 404, statusText: 'Not Found' })) await expect(request('https://e.test/x', fast)).rejects.toBeInstanceOf(HttpError) expect(fetchSpy).toHaveBeenCalledTimes(1) }) it('does not retry any other 4xx', async () => { const fetchSpy = responses(new Response('no', { status: 403, statusText: 'Forbidden' })) await expect(request('https://e.test/x', fast)).rejects.toThrow('403') expect(fetchSpy).toHaveBeenCalledTimes(1) }) it('retries a 503 and succeeds on a later attempt', async () => { const fetchSpy = responses( new Response('', { status: 503, statusText: 'Unavailable' }), new Response('ok'), ) await expect(request('https://e.test/x', { ...fast, retries: 2 })).resolves.toBeDefined() expect(fetchSpy).toHaveBeenCalledTimes(2) }) it('retries a transport error', async () => { const fetchSpy = responses(new TypeError('network error'), new Response('ok')) await expect(request('https://e.test/x', { ...fast, retries: 2 })).resolves.toBeDefined() expect(fetchSpy).toHaveBeenCalledTimes(2) }) it('gives up after the configured number of retries', async () => { const fetchSpy = responses( new Response('', { status: 500, statusText: 'Server Error' }), new Response('', { status: 500, statusText: 'Server Error' }), new Response('', { status: 500, statusText: 'Server Error' }), ) await expect(request('https://e.test/x', { ...fast, retries: 2 })).rejects.toThrow('500') expect(fetchSpy).toHaveBeenCalledTimes(3) }) it('retries nothing when retries is 0', async () => { const fetchSpy = responses(new Response('', { status: 500, statusText: 'Server Error' })) await expect(request('https://e.test/x', { ...fast, retries: 0 })).rejects.toThrow('500') expect(fetchSpy).toHaveBeenCalledTimes(1) }) it('sends no credentials to third-party origins', async () => { const fetchSpy = responses(new Response('ok')) await request('https://e.test/x', fast) expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ credentials: 'omit' }) }) it('stops retrying once the browser reports it is offline', async () => { vi.stubGlobal('navigator', { onLine: false }) const fetchSpy = responses(new TypeError('network error')) await expect(request('https://e.test/x', { ...fast, retries: 3 })).rejects.toThrow() expect(fetchSpy).toHaveBeenCalledTimes(1) }) it('does not retry when the caller aborts', async () => { const controller = new AbortController() controller.abort() const fetchSpy = responses(new DOMException('aborted', 'AbortError')) await expect( request('https://e.test/x', { ...fast, retries: 3, signal: controller.signal }), ).rejects.toThrow() expect(fetchSpy).toHaveBeenCalledTimes(1) }) it('gives up rather than honouring an unreasonable Retry-After', async () => { const fetchSpy = responses( new Response('', { status: 429, statusText: 'Too Many', headers: { 'retry-after': '3600' } }), ) await expect(request('https://e.test/x', { ...fast, retries: 3 })).rejects.toThrow('429') expect(fetchSpy).toHaveBeenCalledTimes(1) }) }) describe('request timeouts', () => { it('aborts an attempt that outlives the timeout', async () => { vi.stubGlobal( 'fetch', vi.fn( (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { init.signal?.addEventListener('abort', () => reject(new DOMException('timed out', 'TimeoutError')), ) }), ), ) await expect( request('https://e.test/x', { ...fast, timeoutMs: 10, retries: 0 }), ).rejects.toThrow() }) it('passes an abort signal on every attempt', async () => { const fetchSpy = responses(new Response('ok')) await request('https://e.test/x', fast) expect(fetchSpy.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) }) }) describe('readCapped', () => { it('reads a body under the cap', async () => { await expect(readCapped(new Response('hello'), 1000)).resolves.toBe('hello') }) it('refuses a body that streams past the cap', async () => { await expect(readCapped(new Response('x'.repeat(100)), 10)).rejects.toThrow(/cap/) }) it('refuses a body whose declared content-length is over the cap', async () => { const res = new Response('x', { headers: { 'content-length': '999999' } }) await expect(readCapped(res, 10)).rejects.toThrow(/over the/) }) }) describe('retryAfterDelay', () => { it('reads a delay in seconds', () => { expect(retryAfterDelay('2')).toBe(2000) }) it('reads an HTTP date', () => { const now = Date.parse('2026-01-01T00:00:00Z') expect(retryAfterDelay(new Date(now + 5000).toUTCString(), now)).toBe(5000) }) it('ignores an absent, unparseable, negative or excessive value', () => { expect(retryAfterDelay(null)).toBeUndefined() expect(retryAfterDelay('soon')).toBeUndefined() expect(retryAfterDelay('-5')).toBeUndefined() expect(retryAfterDelay('120')).toBeUndefined() }) }) describe('backoffDelay', () => { it('grows with the attempt and stays inside the ceiling', () => { for (const attempt of [0, 1, 2, 5, 10]) { const delay = backoffDelay(attempt, 300, 4000) expect(delay).toBeGreaterThanOrEqual(0) expect(delay).toBeLessThanOrEqual(4000) } // Full jitter is random, so assert the ceiling rather than the value. expect(backoffDelay(0, 300, 4000)).toBeLessThanOrEqual(300) expect(backoffDelay(1, 300, 4000)).toBeLessThanOrEqual(600) }) }) describe('requestJson', () => { it('parses a JSON body', async () => { responses(new Response(JSON.stringify({ did: 'did:plc:abc' }))) await expect(requestJson('https://e.test/x', fast)).resolves.toEqual({ did: 'did:plc:abc' }) }) it('rejects on a body that is not JSON', async () => { responses(new Response('not json')) await expect(requestJson('https://e.test/x', fast)).rejects.toThrow() }) })