Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
7.5 kB · 197 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198import { 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() })})