import { ReadableStream } from 'node:stream/web'; import { setTimeout } from 'node:timers/promises'; import { SpanKind } from '@opentelemetry/api'; // This library provides a reimplementation of the browser/W3C FormData object // that supports streams as the value for form parts. import { FormDataEncoder } from 'form-data-encoder'; import { FormData as NonWebCompatibleFormData } from 'formdata-node'; import { Agent, fetch, type FormData, type Headers } from 'undici'; import { xml2json } from 'xml-js'; import { b64EncodeArrayBuffer, jsonStringify } from '../../utils/encoding.js'; import { JSON } from '../../utils/json-schema-types.js'; import { assertUnreachable } from '../../utils/misc.js'; import type SafeTracer from '../../utils/SafeTracer.js'; import { MINUTE_MS } from '../../utils/time.js'; import { bodyCancellationReason } from './bodyCancellationReason.js'; /** * This enum determines how we handle and return the response body. If the * response has no body, the result will be undefined. * * - as-json will return the result of parsing the response's body as JSON. * - as-json-from-xml will parse the response body as an XML doc, and then * return the parse tree result in a JSON-serializable object. * - as-array-buffer will return the body's contents loaded into an ArrayBuffer * - discard will ignore the response body and return undefined */ type ResponseBodyMappings = { 'as-json': JSON; 'as-json-from-xml': JSON; 'as-array-buffer': ArrayBuffer; 'as-readable-stream': ReadableStream; 'as-blob': Blob; discard: undefined; }; export type HandleResponseBody = keyof ResponseBodyMappings; // A symbol that we can stick on FormDataLikeWithStreams objects to // unambiguously identify those objects as FormDataLikeWithStreams. const formDataLikeWithStreams = Symbol(); export type FormDataLikeWithStreams = { [formDataLikeWithStreams]: true; [partName: string]: string | FileLikeWithStreams; }; type FileLikeWithStreams = { data: ReadableStream; fileName?: string }; /** * The standards-compatible FormData object doesn't support using streams as the * value of its parts (non-string parts have to be Blob of File objects, both of * which can only be constructed, according to the Fetch spec, from a buffer of * all the underlying bytes). So, this function returns an object that can be * passed to our networking helper and that will get sent a multipart-form-data * body without buffering the part data. */ export function makeFormDataLikeWithStreams(parts: { [partName: string]: string | FileLikeWithStreams; }): FormDataLikeWithStreams { return { ...parts, [formDataLikeWithStreams]: true }; } function isFormDataLikeWithStreams(it: unknown): it is FormDataLikeWithStreams { return Boolean( typeof it === 'object' && it != null && (it as { [formDataLikeWithStreams]?: unknown })[formDataLikeWithStreams], ); } // We don't have code for converting every body type to an ArrayBuffer, which is // needed for signing, so we differentiate which body types can and can't be signed. type SignableBody = string | URLSearchParams | ArrayBuffer; /** * This is the type that should be passed to every query our app runs, and it * mirrors the inputs that fetch takes. */ export type CoopRequestQuery = { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options'; handleResponseBody: T; headers?: Record>; logRequestAndResponseBody?: 'ALWAYS' | 'ON_FAILURE' | 'NEVER'; /** * Amount of time after which to fail the request and try to abort it, * measured from the time that the request starts to be sent. * * No timeout is used by default, but mosts requests should set one. * * The promise returned by {@link fetchHTTP} will _often_ reject with an * `TimeoutError` if this timeout is reached. (E.g., if {@link fetchHTTP}'s * promise doesn't resolve until the whole response body is available for * parsing, and that hasn't happened when the timeout occurs, the promise will * reject.) In other cases, though -- including most of the time when the * caller of {@link fetchHTTP} asks to get the response body as a stream -- * {@link fetchHTTP}'s promise may resolve, but the stream it returns will be * [canceled](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/cancel) * when the timeout is hit and future reads from it will fail. */ timeoutMs?: number; } & ( | { body?: SignableBody | ReadableStream | FormData | FormDataLikeWithStreams; signWith?: undefined; } | { body: SignableBody; signWith: (data: ArrayBuffer) => Promise<{ signature: ArrayBuffer }>; } ) & ( | { handleResponseBody: Exclude; maxResponseSize?: '5mb' | '25mb' | '125mb'; iWillConsumeTheResponseBodyStreamQuicklyToAvoidACrash?: boolean; } | { handleResponseBody: 'as-readable-stream'; maxResponseSize?: '5mb' | '25mb' | '125mb' | 'unlimited'; iWillConsumeTheResponseBodyStreamQuicklyToAvoidACrash: true; } ); /** * This is the minimum amount of information necessary to return from a given * request. In the future, we might add other fields here to handle things like * buffers, GRPC, etc., but for now it only handles JSON parsing and ignoring * the body all together */ export type CoopResponse = { status: number; ok: boolean; headers: Headers; body: ResponseBodyMappings[T] | undefined; }; // This agent is used to configure various global undici settings, including the // max number of open connections in the backing tcp connection pool. We set a // limit to avoid running out of memory and, more generally, cuz it doesn't seem // like a good idea for the process' stability to let undici open an unlimited // number of connections. // We added a 15 minute headers timeout because when we fetch samples from the // external services, it can take a long time (because we're running 10s of // thousands of items through a model), and we don't want the request to time out. const bytesFromMegabytes = (megabytes: number) => megabytes * 1024 ** 2; const agentMapping = { '5mb': new Agent({ connections: 2_000, maxResponseSize: bytesFromMegabytes(5), headersTimeout: 15 * MINUTE_MS, }), '25mb': new Agent({ connections: 2_000, maxResponseSize: bytesFromMegabytes(25), headersTimeout: 15 * MINUTE_MS, }), '125mb': new Agent({ connections: 2_000, maxResponseSize: bytesFromMegabytes(125), headersTimeout: 15 * MINUTE_MS, }), unlimited: new Agent({ connections: 2_000, maxResponseSize: -1, headersTimeout: 15 * MINUTE_MS, }), } as const; /** * This function performs an HTTP request and, based on the `handleResponseBody` * parameter, decides how and whether to return the response body. */ export async function fetchHTTP( tracer: SafeTracer, query: CoopRequestQuery, ): Promise> { const url = new URL(query.url); return tracer.addActiveSpan( { resource: `${query.method.toUpperCase()} ${url.protocol}//${url.host}`, operation: 'http.request', kind: SpanKind.CLIENT, attributes: { 'url_details.path': url.pathname }, }, async (span) => { const { headers, method, body, handleResponseBody, timeoutMs = Infinity, } = query; const signature = await (async () => { if (!('signWith' in query) || query.signWith === undefined) { return undefined; } if (!body) { throw new Error('Cannot sign an HTTP request with no body'); } // Cast to only those body types that are allowed when `signWith` is // given; we know we have `signWith` from the check above. const castBody = body as (CoopRequestQuery & { signWith: (...args: unknown[]) => never; })['body']; try { // If the body isn't already an ArrayBuffer, we need to encode the body // as an ArrayBuffer, so we first coerce it to a string from a `string | // URLSearchParams` type, and then encode it with TextEncoder const bodyBuffer = castBody instanceof ArrayBuffer ? castBody : new TextEncoder().encode( // `satisfies` ensures that new body types, on which we can't // necessarily just call toString, won't get accidentally // handled incorrectly (castBody satisfies string | URLSearchParams).toString(), ); const { signature } = await query.signWith(bodyBuffer); return b64EncodeArrayBuffer(signature); } catch (e) { // Swallow exception and don't attach a signature header. One likely // explanation is that this org has no signing keys if (e instanceof Error) { span.recordException(e); } return undefined; } })(); const [finalBody, extraHeaders] = (() => { if (!isFormDataLikeWithStreams(body)) { return [body, {}]; } // Build the input for the body into a legacy node stream that // represents the data encoded with `application/multipart-form-data`. const formData = new NonWebCompatibleFormData(); for (const [partName, partData] of Object.entries(body)) { if (typeof partData === 'string') { formData.append(partName, partData); } else { formData.set(partName, { ...(partData.fileName ? { name: partData.fileName } : {}), [Symbol.toStringTag]: 'File', stream() { return partData.data; }, }); } } const toReadableStream = (encoder: FormDataEncoder) => { const iterator = encoder.encode(); return new ReadableStream({ async pull(controller) { const { value, done } = await iterator.next(); if (done) { return controller.close(); } controller.enqueue(value); }, }); }; const encoder = new FormDataEncoder(formData); return [toReadableStream(encoder), encoder.headers]; })(); const signal = timeoutMs > 0 && timeoutMs !== Infinity ? (() => { const controller = new AbortController(); // eslint-disable-next-line @typescript-eslint/no-floating-promises setTimeout(timeoutMs).then(() => { controller.abort( new DOMException( `"Timeout limit reached ${timeoutMs}ms`, 'TimeoutError', ), ); }); return controller.signal; })() : undefined; // NB: use of fetch here is okay, since this is our wrapper function that // should be used instead of fetch everywhere else in the codebase // eslint-disable-next-line no-restricted-syntax const response = await fetch(url, { method, headers: { ...headers, ...extraHeaders, ...(signature ? { 'coop-signature': signature } : {}), }, body: finalBody, duplex: finalBody instanceof ReadableStream ? 'half' : undefined, dispatcher: agentMapping[query.maxResponseSize ?? '5mb'], signal, }); span.setAttribute('http.status_code', response.status); const [responseBody, bodyByteLength] = (await (async () => { // If the response has no body, bail early, and with a result that's // consistent across all handling strategies. // // TODO: Idk if HTTP responses can be sent in a way where the body gets // treated as 'present but empty', or how best to handle those (as it's // tricky to handle them uniformly in the as-readable-stream case, where // we presumably can't detect this 'empty body' situation without // starting to read the stream). if (response.body === null) { return [undefined, null]; } switch (handleResponseBody) { case 'as-json': { const tempArrayBuffer = await response.arrayBuffer(); try { const parsedJson = // eslint-disable-next-line no-restricted-syntax JSON.parse( utf8DecodeBytes(new Uint8Array(tempArrayBuffer)), ) as JSON; return [parsedJson, tempArrayBuffer.byteLength]; } catch (e) { const decodedBody = utf8DecodeBytes( new Uint8Array(tempArrayBuffer), ); const bodyPrefix = sanitizedBodyPrefix(decodedBody); span.setAttribute( 'responseBodyByteLength', tempArrayBuffer.byteLength, ); span.setAttribute('responseBodyPrefix', bodyPrefix); throw new Error( `Failed to parse response body as JSON (${tempArrayBuffer.byteLength} bytes): ${ e instanceof Error ? e.message : String(e) }. Response body started with: ${bodyPrefix}`, { cause: e }, ); } } case 'as-json-from-xml': { const tempArrayBuffer = await response.arrayBuffer(); const parsedXML = // eslint-disable-next-line no-restricted-syntax JSON.parse( xml2json(utf8DecodeBytes(new Uint8Array(tempArrayBuffer)), { compact: true, }), ); return [parsedXML, tempArrayBuffer.byteLength]; } case 'as-array-buffer': const arrayBuffer = await response.arrayBuffer(); return [arrayBuffer, arrayBuffer.byteLength]; case 'as-readable-stream': return [ response.body, typeof response.headers.get('content-length') === 'string' ? parseInt(response.headers.get('content-length')!) : null, ]; case 'as-blob': const blob = await response.blob(); return [blob, blob.size]; case 'discard': await response.body.cancel(bodyCancellationReason); return [undefined, null]; default: assertUnreachable( handleResponseBody, 'This should never be reached.', ); } })()) satisfies [ ResponseBodyMappings[HandleResponseBody], number | null, ] as [ResponseBodyMappings[T], number | null]; if (bodyByteLength !== null) { span.setAttribute('response.bodyLength', bodyByteLength); } if ( (query.logRequestAndResponseBody === 'ON_FAILURE' && response.status >= 400) || query.logRequestAndResponseBody === 'ALWAYS' ) { span.setAttribute( 'http.request.body', body === undefined ? 'undefined' : jsonStringify(body), ); span.setAttribute('http.response.body', jsonStringify(response.body)); } return { status: response.status, ok: response.ok, body: responseBody, headers: response.headers, }; }, ); } // Bounded so unbounded customer response bodies don't end up in log // aggregators verbatim when JSON parsing fails. const JSON_PARSE_ERROR_BODY_PREFIX_LENGTH = 96; function sanitizedBodyPrefix(body: string): string { const prefix = body.slice(0, JSON_PARSE_ERROR_BODY_PREFIX_LENGTH); const printableOnly = prefix.replace(/[^\x20-\x7E]/g, '.'); const ellipsis = body.length > JSON_PARSE_ERROR_BODY_PREFIX_LENGTH ? '…' : ''; return `"${printableOnly}${ellipsis}"`; } // TextDecoder reused by each utf8DecodeBytes call, // so we don't have to create a new one for every fetchHttp request. const textDecoder = new TextDecoder(); /** * Stolen from the undici source code for handling a response as a buffer. * Undici, in turn, is implementing this as defined in the WHATWG fetch spec. * See: https://github.com/nodejs/undici/blob/9a56796b3e61713f7f2e44ea8c9622b158697c45/lib/web/fetch/util.js#L1437 */ function utf8DecodeBytes(buffer: Uint8Array) { if (buffer.length === 0) { return ''; } // 1. Let buffer be the result of peeking three bytes from // ioQueue, converted to a byte sequence. // 2. If buffer is 0xEF 0xBB 0xBF, then read three // bytes from ioQueue. (Do nothing with those bytes.) if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { buffer = buffer.subarray(3); } // 3. Process a queue with an instance of UTF-8’s // decoder, ioQueue, output, and "replacement". const output = textDecoder.decode(buffer); // 4. Return output. return output; } export type FetchHTTP = ( query: CoopRequestQuery, ) => Promise>;