import http from 'http'; import { ProxyTracerProvider } from '@opentelemetry/api'; import _ from 'lodash'; import { makeTestWithFixture } from '../../test/utils.js'; import SafeTracer from '../../utils/SafeTracer.js'; import { fetchHTTP } from './index.js'; const { omit } = _; // This isn't every option, but it's the meaningfully-different ones. const requiresFullBodyOptions = ['as-json', 'as-array-buffer'] as const; const doesntRequireFullBodyOptions = ['as-readable-stream', 'discard'] as const; const bodyParsingOptions = [ ...requiresFullBodyOptions, ...doesntRequireFullBodyOptions, ] as const; describe('fetchHTTP', () => { const testWithFakeServer = makeTestWithFixture(async () => { const port = Math.floor(Math.random() * 10000) + 10000; const server = await new Promise((resolve, reject) => { const server = http.createServer((req, res) => { switch (req.url) { case '/never-responds': break; case '/only-sends-headers': res.writeHead(200, { 'Content-Type': 'application/json' }); break; case '/sends-incomplete-body': res.writeHead(200, { 'Content-Type': 'application/json' }); res.write('{'); break; case '/sends-trailing-content-after-json': res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('null{"items":[]}'); break; default: res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('"Hello World"'); break; } }); server.listen(port, () => resolve(server)); server.on('error', reject); }); return { tracer: new SafeTracer(new ProxyTracerProvider().getTracer('noop')), baseURL: `http://localhost:${port}`, async cleanup() { await server.close(); }, }; }); testWithFakeServer( 'should parse fetched json data from a server', async ({ baseURL, tracer }) => { const resp = await fetchHTTP(tracer, { url: new URL('/hello', baseURL).toString(), method: 'get', handleResponseBody: 'as-json', }); expect(omit(resp, ['headers'])).toEqual({ status: 200, ok: true, body: 'Hello World', }); }, ); testWithFakeServer( 'should reject with an error whose message includes the body prefix when the response is not valid JSON', async ({ baseURL, tracer }) => { await expect( fetchHTTP(tracer, { url: new URL( '/sends-trailing-content-after-json', baseURL, ).toString(), method: 'get', handleResponseBody: 'as-json', }), ).rejects.toMatchObject({ message: expect.stringContaining('null{"items":[]}'), cause: expect.any(SyntaxError), }); }, ); describe('timeout handling', () => { describe('if nothing is sent before the timeout', () => { testWithFakeServer( 'should reject returned promise, regardless of body parsing setup', async ({ baseURL, tracer }) => { await Promise.all( bodyParsingOptions.map(async (handleResponseBody) => { try { await fetchHTTP(tracer, { url: new URL('/never-responds', baseURL).toString(), method: 'get', handleResponseBody, timeoutMs: 100, iWillConsumeTheResponseBodyStreamQuicklyToAvoidACrash: true, signWith: undefined, }); throw new Error("should've rejected"); } catch (e) { expect(e).toMatchObject({ name: 'TimeoutError' }); } }), ); }, ); }); describe('if only headers are sent before the timeout', () => { testWithFakeServer( 'should reject returned promise, regardless of body parsing settings', async ({ baseURL, tracer }) => { await Promise.all( bodyParsingOptions.map(async (handleResponseBody) => { try { await fetchHTTP(tracer, { url: new URL('/only-sends-headers', baseURL).toString(), method: 'get', handleResponseBody, timeoutMs: 100, iWillConsumeTheResponseBodyStreamQuicklyToAvoidACrash: true, signWith: undefined, }); throw new Error("should've rejected"); } catch (e) { expect(e).toMatchObject({ name: 'TimeoutError' }); } }), ); }, ); }); describe('if an incomplete body was sent before the timeout', () => { testWithFakeServer( 'should reject returned promise if the caller requested body parsing', async ({ baseURL, tracer }) => { await Promise.all( requiresFullBodyOptions.map(async (handleResponseBody) => { try { await fetchHTTP(tracer, { url: new URL('/sends-incomplete-body', baseURL).toString(), method: 'get', handleResponseBody, timeoutMs: 100, }); throw new Error("should've rejected"); } catch (e) { expect(e).toMatchObject({ name: 'TimeoutError' }); } }), ); }, ); testWithFakeServer( 'should resolve the returned promise, but cancel the stream, if a stream for the body was requested', async ({ baseURL, tracer }) => { const resp = await fetchHTTP(tracer, { url: new URL('/sends-incomplete-body', baseURL).toString(), method: 'get', handleResponseBody: 'as-readable-stream', timeoutMs: 100, iWillConsumeTheResponseBodyStreamQuicklyToAvoidACrash: true, }); try { // Try to consume the stream until the end, which should eventually // throw because the body won't be complete before the timeout. for await (const _x of resp.body!) { } throw new Error("consuming the stream should've thrown"); } catch (e) { expect(e).toMatchObject({ name: 'TimeoutError' }); } }, ); testWithFakeServer( 'should resolve the returned promise and never error if no body was requested', async ({ baseURL, tracer }) => { const resp = await fetchHTTP(tracer, { url: new URL('/sends-incomplete-body', baseURL).toString(), method: 'get', handleResponseBody: 'discard', timeoutMs: 100, }); expect(resp).toMatchObject({ status: 200, ok: true, body: undefined, }); }, ); }); }); });