From 7eb9032cf2fd421487a36dc91cb7d17862299f5a Mon Sep 17 00:00:00 2001 From: upstream Date: Thu, 27 Aug 2026 23:36:28 +0200 Subject: [PATCH] Upstream sindresorhus/got state of the touched files before 2904797b6d --- source/core/index.ts | 2658 ++++++++++++++++++++++++++++++++++++++++++ test/retry.ts | 1374 ++++++++++++++++++++++ 2 files changed, 4032 insertions(+) create mode 100644 source/core/index.ts create mode 100644 test/retry.ts diff --git a/source/core/index.ts b/source/core/index.ts new file mode 100644 index 0000000..a8a1561 --- /dev/null +++ b/source/core/index.ts @@ -0,0 +1,2658 @@ +import process from 'node:process'; +import {Buffer} from 'node:buffer'; +import {Duplex, type Readable} from 'node:stream'; +import {addAbortListener} from 'node:events'; +import http, {ServerResponse, type ClientRequest, type RequestOptions} from 'node:http'; +import type {Socket} from 'node:net'; +import {byteLength} from 'byte-counter'; +import {chunk} from 'chunk-data'; +import {concatUint8Arrays, stringToBase64, stringToUint8Array} from 'uint8array-extras'; +import CacheableRequest, { + CacheError as CacheableCacheError, + type CacheableRequestFunction, + type CacheableOptions, +} from 'cacheable-request'; +import decompressResponse from 'decompress-response'; +import type {KeyvStoreAdapter} from 'keyv'; +import type KeyvType from 'keyv'; +import is, {isBuffer} from '@sindresorhus/is'; +import type ResponseLike from 'responselike'; +import timer, {type ClientRequestWithTimings, type Timings, type IncomingMessageWithTimings} from './utils/timer.js'; +import getBodySize from './utils/get-body-size.js'; +import proxyEvents from './utils/proxy-events.js'; +import timedOut, {TimeoutError as TimedOutTimeoutError} from './timed-out.js'; +import stripUrlAuth from './utils/strip-url-auth.js'; +import WeakableMap from './utils/weakable-map.js'; +import calculateRetryDelay from './calculate-retry-delay.js'; +import Options, { + assertUrlHasSameOriginAsPrefixUrlIfNeeded, + crossOriginStripHeaders, + getUrlPrefixBoundary, + hasUrlOrPrefixUrlBoundaryChanged, + hasExplicitCredentialInUrlChange, + isBodyUnchanged, + isCrossOriginCredentialChanged, + isSameOrigin, + snapshotCrossOriginState, + type CrossOriginState, + type PromiseCookieJar, + type NativeRequestOptions, + type RetryOptions, + type OptionsError, + type OptionsInit, + type NormalizedOptions, +} from './options.js'; +import { + cacheDecodedBody, + decodeUint8Array, + isResponseOk, + isUtf8Encoding, + type PlainResponse, + type Response, +} from './response.js'; +import isClientRequest from './utils/is-client-request.js'; +import {getUnixSocketPath} from './utils/is-unix-socket-url.js'; +import { + RequestError, + ReadError, + MaxRedirectsError, + HTTPError, + TimeoutError, + UploadError, + CacheError, + AbortError, +} from './errors.js'; +import { + generateRequestId, + publishRequestCreate, + publishRequestStart, + publishResponseStart, + publishResponseEnd, + publishRetry, + publishError, + publishRedirect, +} from './diagnostics-channel.js'; + +type Error = NodeJS.ErrnoException; + +export type Progress = { + percent: number; + transferred: number; + total?: number; +}; + +const supportsBrotli = is.string(process.versions.brotli); +const supportsZstd = is.string(process.versions.zstd); +const methodsWithoutBody: ReadonlySet = new Set(['GET', 'HEAD']); +const singleValueRequestHeaders: ReadonlySet = new Set([ + 'authorization', + 'content-length', + 'proxy-authorization', +]); + +export type GotEventFunction = + /** + `request` event to get the request object of the request. + + __Tip__: You can use `request` event to abort requests. + + @example + ``` + import got from 'got'; + + got.stream('https://github.com') + .on('request', request => setTimeout(() => request.destroy(), 50)); + ``` + */ + ((name: 'request', listener: (request: ClientRequest) => void) => T) + + /** + The `response` event to get the response object of the final request. + */ + & ((name: 'response', listener: (response: R) => void) => T) + + /** + The `redirect` event to get the response object of a redirect. The second argument is options for the next request to the redirect location. + */ + & ((name: 'redirect', listener: (response: R, nextOptions: N) => void) => T) + + /** + Progress events for uploading (sending a request) and downloading (receiving a response). + The `progress` argument is an object like: + + ``` + { + percent: 0.1, + transferred: 1024, + total: 10240 + } + ``` + + If the `content-length` header is missing, `total` will be `undefined`. + + @example + ``` + import got from 'got'; + + const response = await got('https://sindresorhus.com') + .on('downloadProgress', progress => { + // Report download progress + }) + .on('uploadProgress', progress => { + // Report upload progress + }); + + console.log(response); + ``` + */ + & ((name: 'uploadProgress' | 'downloadProgress', listener: (progress: Progress) => void) => T) + /** + To enable retrying on a Got stream, it is required to have a `retry` handler attached. + + When this event is emitted, you should reset the stream you were writing to and prepare the body again. + + See `got.options.retry` for more information. + */ + & ((name: 'retry', listener: (retryCount: number, error: RequestError, createRetryStream: (options?: OptionsInit) => Request) => void) => T); + +export type RequestEvents = { + on: GotEventFunction; + once: GotEventFunction; + off: GotEventFunction; +}; + +type StorageAdapter = KeyvStoreAdapter | KeyvType | Map; + +const cacheableStore = new WeakableMap(); + +const redirectCodes: ReadonlySet = new Set([301, 302, 303, 307, 308]); +export {crossOriginStripHeaders} from './options.js'; +const transientWriteErrorCodes: ReadonlySet = new Set(['EPIPE', 'ECONNRESET']); +const omittedPipedHeaders = new Set([ + 'host', + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +// Track errors that have been processed by beforeError hooks to preserve custom error types +const errorsProcessedByHooks = new WeakSet(); + +const proxiedRequestEvents = [ + 'socket', + 'connect', + 'continue', + 'information', + 'upgrade', +] as const; + +const noop = (): void => {}; + +const createPreRequestErrorTimings = (): Timings => { + const now = Date.now(); + + return { + start: now, + error: now, + phases: { + total: 0, + }, + }; +}; + +type NativeFormDataBodyMetadata = { + form: FormData; + body: ReadableStream; + contentTypeWasGenerated: boolean; +}; + +const serializeNativeFormDataBody = (form: FormData) => { + const response = new globalThis.Response(form); + return { + body: response.body!, + contentType: response.headers.get('content-type') ?? 'multipart/form-data', + }; +}; + +// A body is replayable only if iterating it again restarts from the beginning. +// Node streams, Web `ReadableStream`s, generators, and self-iterating (one-shot) iterators all yield their data only once, so they cannot be replayed on a redirect. +const isNonReplayableBody = (body: unknown): boolean => + is.nodeStream(body) + || (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) + || is.generator(body) + || (is.asyncIterable(body) && (body[Symbol.asyncIterator]() as unknown) === body) + || (is.iterable(body) && (body[Symbol.iterator]() as unknown) === body); + +const isTransientWriteError = (error: Error): boolean => { + const {code} = error; + return typeof code === 'string' && transientWriteErrorCodes.has(code); +}; + +const getConnectionListedHeaders = (headers: Record): Set => { + const connectionListedHeaders = new Set(); + + for (const [header, connectionHeader] of Object.entries(headers)) { + const normalizedHeader = header.toLowerCase(); + + if (normalizedHeader !== 'connection' && normalizedHeader !== 'proxy-connection') { + continue; + } + + const connectionHeaderValues = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader]; + + for (const value of connectionHeaderValues) { + if (typeof value !== 'string') { + continue; + } + + for (const token of value.split(',')) { + const normalizedToken = token.trim().toLowerCase(); + + if (normalizedToken.length > 0) { + connectionListedHeaders.add(normalizedToken); + } + } + } + } + + return connectionListedHeaders; +}; + +export const normalizeError = (error: unknown): Error => { + if (error instanceof globalThis.Error) { + return error; + } + + if (is.object(error)) { + const errorLike = error as Partial; + const message = typeof errorLike.message === 'string' ? errorLike.message : 'Non-error object thrown'; + const normalizedError = new globalThis.Error(message, {cause: error}) as Error & {code?: string; input?: string}; + + if (typeof errorLike.stack === 'string') { + normalizedError.stack = errorLike.stack; + } + + if (typeof errorLike.code === 'string') { + normalizedError.code = errorLike.code; + } + + if (typeof errorLike.input === 'string') { + normalizedError.input = errorLike.input; + } + + return normalizedError; + } + + return new globalThis.Error(String(error)); +}; + +type UrlType = ConstructorParameters[0]; +type OptionsType = ConstructorParameters[1]; +type DefaultsType = ConstructorParameters[2]; +const getSanitizedUrl = (options?: Options): string => options?.url ? stripUrlAuth(options.url) : ''; + +const makeProgress = (transferred: number, total?: number): Progress => { + let percent = 0; + if (total) { + percent = transferred / total; + } else if (total === transferred) { + percent = 1; + } + + return {percent, transferred, total}; +}; + +export default class Request extends Duplex implements RequestEvents { + // @ts-expect-error - Ignoring for now. + override ['constructor']: typeof Request; + + _noPipe?: boolean; + + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/9568 + options: Options; + response?: PlainResponse; + requestUrl?: URL; + redirectUrls: URL[] = []; + retryCount = 0; + _stopReading = false; + + declare private _requestOptions: NativeRequestOptions; + + private _stopRetry?: () => void; + private _downloadedSize = 0; + private _uploadedSize = 0; + private readonly _pipedServerResponses = new Set(); + private _request?: ClientRequest; + private _responseSize?: number; + private _bodySize?: number; + private _nativeFormDataBody?: NativeFormDataBodyMetadata; + private _unproxyEvents?: () => void; + private _triggerRead = false; + private readonly _jobs: Array<() => void> = []; + private _cancelTimeouts?: () => void; + private _abortListenerDisposer?: {[Symbol.dispose](): void}; + private _flushed = false; + private _aborted = false; + private _expectedContentLength?: number; + private _compressedBytesCount?: number; + private _skipRequestEndInFinal = false; + private _hasWrittenBody = false; + private _hasWritableBody = false; + private _discardBodyWrites = false; + private _incrementalDecode?: {decoder: TextDecoder; chunks: string[]}; + private readonly _requestId = generateRequestId(); + + // We need this because `this._request` if `undefined` when using cache + private _requestInitialized = false; + + constructor(url: UrlType, options?: OptionsType, defaults?: DefaultsType) { + super({ + // Don't destroy immediately, as the error may be emitted on unsuccessful retry + autoDestroy: false, + // It needs to be zero because we're just proxying the data to another stream + highWaterMark: 0, + }); + + this.on('pipe', (source: NodeJS.ReadableStream & {headers?: Record}) => { + if (this.options.copyPipedHeaders && source?.headers) { + const connectionListedHeaders = getConnectionListedHeaders(source.headers); + + for (const [header, value] of Object.entries(source.headers)) { + const normalizedHeader = header.toLowerCase(); + + if (omittedPipedHeaders.has(normalizedHeader) || connectionListedHeaders.has(normalizedHeader)) { + continue; + } + + if (!this.options.shouldCopyPipedHeader(normalizedHeader)) { + continue; + } + + this.options.setPipedHeader(normalizedHeader, value); + } + } + }); + + this.on('newListener', event => { + if (event === 'retry' && this.listenerCount('retry') > 0) { + throw new Error('A retry listener has been attached already.'); + } + }); + + try { + this.options = new Options(url, options, defaults); + + if (!this.options.url) { + if (this.options.prefixUrl === '') { + throw new TypeError('Missing `url` property'); + } + + this.options.url = ''; + } + + this.requestUrl = this.options.url as URL; + + // Publish request creation event + publishRequestCreate({ + requestId: this._requestId, + url: getSanitizedUrl(this.options), + method: this.options.method, + }); + } catch (error: unknown) { + const {options} = error as OptionsError; + if (options) { + this.options = options; + } + + this.flush = async () => { + this.flush = async () => {}; + + // Defer error emission to next tick to allow user to attach error handlers + process.nextTick(() => { + // _beforeError requires options to access retry logic and hooks + if (this.options) { + this._beforeError(normalizeError(error)); + } else { + // Options is undefined, skip _beforeError and destroy directly + const normalizedError = normalizeError(error); + const requestError = normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, this); + this.destroy(requestError); + } + }); + }; + + return; + } + + // Important! If you replace `body` in a handler with another stream, make sure it's readable first. + // The below is run only once. + const {body} = this.options; + if (is.nodeStream(body)) { + body.once('error', this._onBodyError); + } + } + + async flush() { + if (this._flushed) { + return; + } + + this._flushed = true; + + try { + this._attachAbortListener(); + + if (this.destroyed) { + return; + } + + await this._finalizeBody(); + + if (this.destroyed) { + return; + } + + this._hasWritableBody = this._canWriteBody(); + + await this._makeRequest(); + + if (this.destroyed) { + this._request?.destroy(); + return; + } + + // Queued writes etc. + for (const job of this._jobs) { + job(); + } + + // Prevent memory leak + this._jobs.length = 0; + + this._requestInitialized = true; + } catch (error: unknown) { + this._beforeError(normalizeError(error)); + } + } + + _beforeError(error: Error): void { + if (this._stopReading) { + return; + } + + const {response, options} = this; + const attemptCount = this.retryCount + (error.name === 'RetryError' ? 0 : 1); + + this._stopReading = true; + + if (error instanceof TimedOutTimeoutError) { + error = new TimeoutError(error, this.timings ?? createPreRequestErrorTimings(), this); + } else if (!(error instanceof RequestError)) { + error = new RequestError(error.message, error, this); + } + + const typedError = error as RequestError; + + void (async () => { + // Node.js parser is really weird. + // It emits post-request Parse Errors on the same instance as previous request. WTF. + // Therefore, we need to check if it has been destroyed as well. + // + // Furthermore, Node.js 16 `response.destroy()` doesn't immediately destroy the socket, + // but makes the response unreadable. So we additionally need to check `response.readable`. + if (response?.readable && !response.rawBody && !this._request?.socket?.destroyed) { + // @types/node has incorrect typings. `setEncoding` accepts `null` as well. + response.setEncoding(this.readableEncoding!); + + const success = await this._setRawBody(response); + + if (success) { + response.body = decodeUint8Array(response.rawBody!); + } + } + + if (this.listenerCount('retry') !== 0) { + let backoff: number; + + try { + let retryAfter; + if (response && 'retry-after' in response.headers) { + retryAfter = Number(response.headers['retry-after']); + if (Number.isNaN(retryAfter)) { + retryAfter = Date.parse(response.headers['retry-after']!) - Date.now(); + + if (retryAfter <= 0) { + retryAfter = 1; + } + } else { + retryAfter *= 1000; + } + } + + const retryOptions = options.retry as RetryOptions; + + const computedValue = calculateRetryDelay({ + attemptCount, + retryOptions, + error: typedError, + retryAfter, + computedValue: retryOptions.maxRetryAfter ?? options.timeout.request ?? Number.POSITIVE_INFINITY, + }); + + // When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes) + // before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed + // based on these rules), skip calling calculateDelay entirely. + // When false, always call calculateDelay, allowing it to override retry decisions. + if (retryOptions.enforceRetryRules && computedValue === 0) { + backoff = 0; + } else { + backoff = await retryOptions.calculateDelay({ + attemptCount, + retryOptions, + error: typedError, + retryAfter, + computedValue, + }); + } + } catch (error_: unknown) { + const normalizedError = normalizeError(error_); + void this._error(new RequestError(normalizedError.message, normalizedError, this)); + return; + } + + if (backoff) { + await new Promise(resolve => { + const timeout = setTimeout(resolve, backoff); + this._stopRetry = () => { + clearTimeout(timeout); + resolve(); + }; + }); + + // Something forced us to abort the retry + if (this.destroyed) { + return; + } + + // Capture body BEFORE hooks run to detect reassignment + const bodyBeforeHooks = this.options.body; + + try { + for (const hook of this.options.hooks.beforeRetry) { + // eslint-disable-next-line no-await-in-loop + await hook(typedError, this.retryCount + 1); + } + } catch (error_: unknown) { + const normalizedError = normalizeError(error_); + void this._error(new RequestError(normalizedError.message, normalizedError, this)); + return; + } + + // Something forced us to abort the retry + if (this.destroyed) { + return; + } + + // Preserve stream body reassigned in beforeRetry hooks. + const bodyAfterHooks = this.options.body; + const bodyWasReassigned = bodyBeforeHooks !== bodyAfterHooks; + + // Resource cleanup and preservation logic for retry with body reassignment. + // The Promise wrapper (as-promise/index.ts) compares body identity to detect consumed streams, + // so we must preserve the body reference across destroy(). However, destroy() calls _destroy() + // which destroys this.options.body, creating a complex dance of clear/restore operations. + // + // Key constraints: + // 1. If body was reassigned, we must NOT destroy the NEW stream (it will be used for retry) + // 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks + // 3. We must restore the body reference after destroy() for identity checks in promise wrapper + // 4. We cannot use the normal setter after destroy() because it validates stream readability + try { + if (bodyWasReassigned) { + const oldBody = bodyBeforeHooks; + // Temporarily clear body to prevent destroy() from destroying the new stream + this.options.body = undefined; + this.destroy(); + + // Clean up the old stream resource if it's a stream and different from new body + // (edge case: if old and new are same stream object, don't destroy it) + if (is.nodeStream(oldBody) && oldBody !== bodyAfterHooks) { + oldBody.destroy(); + } + + // Restore new body for promise wrapper's identity check + if (is.nodeStream(bodyAfterHooks) && (bodyAfterHooks.readableEnded || bodyAfterHooks.destroyed)) { + throw new TypeError('The reassigned stream body must be readable. Ensure you provide a fresh, readable stream in the beforeRetry hook.'); + } + + this.options.body = bodyAfterHooks; + } else { + // Body wasn't reassigned - use normal destroy flow which handles body cleanup + this.destroy(); + // Note: We do NOT restore the body reference here. The stream was destroyed by _destroy() + // and should not be accessed. The promise wrapper will see that body identity hasn't changed + // and will detect it's a consumed stream, which is the correct behavior. + } + } catch (error_: unknown) { + const normalizedError = normalizeError(error_); + void this._error(new RequestError(normalizedError.message, normalizedError, this)); + return; + } + + // Publish retry event + publishRetry({ + requestId: this._requestId, + retryCount: this.retryCount + 1, + error: typedError, + delay: backoff, + }); + + this.emit('retry', this.retryCount + 1, error, (updatedOptions?: OptionsInit) => { + const request = new Request(undefined, updatedOptions, options); + request.retryCount = this.retryCount + 1; + + process.nextTick(() => { + void request.flush(); + }); + + return request; + }); + return; + } + } + + void this._error(typedError); + })(); + } + + override _read(): void { + this._triggerRead = true; + + const {response} = this; + if (response && !this._stopReading) { + // We cannot put this in the `if` above + // because `.read()` also triggers the `end` event + if (response.readableLength) { + this._triggerRead = false; + } + + let data; + + while ((data = response.read()) !== null) { + this._downloadedSize += data.length; // eslint-disable-line @typescript-eslint/restrict-plus-operands + + if (this._incrementalDecode) { + try { + const decodedChunk = typeof data === 'string' ? data : this._incrementalDecode.decoder.decode(data, {stream: true}); + if (decodedChunk.length > 0) { + this._incrementalDecode.chunks.push(decodedChunk); + } + } catch { + this._incrementalDecode = undefined; + } + } + + const progress = this.downloadProgress; + + if (progress.percent < 1) { + this.emit('downloadProgress', progress); + } + + if (this._stopReading) { + return; + } + + this.push(data); + + if (this._stopReading) { + return; + } + } + } + } + + override _write(chunk: unknown, encoding: BufferEncoding | undefined, callback: (error?: Error | null) => void): void { // eslint-disable-line @typescript-eslint/no-restricted-types + const write = (): void => { + if (this._discardBodyWrites) { + callback(); + return; + } + + this._hasWrittenBody = true; + this._writeRequest(chunk, encoding, callback); + }; + + if (this._requestInitialized) { + write(); + } else { + this._jobs.push(write); + } + } + + override _final(callback: (error?: Error | null) => void): void { // eslint-disable-line @typescript-eslint/no-restricted-types + const endRequest = (): void => { + if (this._discardBodyWrites) { + this._hasWritableBody = false; + callback(); + return; + } + + if (this._skipRequestEndInFinal) { + this._skipRequestEndInFinal = false; + callback(); + return; + } + + const request = this._request; + + // We need to check if `this._request` is present, + // because it isn't when we use cache. + if (!request || request.destroyed) { + this._hasWritableBody = false; + callback(); + return; + } + + request.end((error?: Error | null) => { // eslint-disable-line @typescript-eslint/no-restricted-types + // The request has been destroyed before `_final` finished. + // See https://github.com/nodejs/node/issues/39356 + if ((request as any)?._writableState?.errored) { + return; + } + + if (!error) { + this._emitUploadComplete(request); + } + + this._hasWritableBody = false; + callback(error); + }); + }; + + if (this._requestInitialized) { + endRequest(); + } else { + this._jobs.push(endRequest); + } + } + + override _destroy(error: Error | null, callback: (error: Error | null) => void): void { // eslint-disable-line @typescript-eslint/no-restricted-types + this._stopReading = true; + this.flush = async () => {}; + + // Prevent further retries + this._stopRetry?.(); + this._cancelTimeouts?.(); + this._abortListenerDisposer?.[Symbol.dispose](); + this._destroyInFlightAlpnSocket(); + + if (this.options) { + const {body} = this.options; + if (is.nodeStream(body)) { + body.destroy(); + } + } + + if (this._request) { + this._request.destroy(); + } + + // Workaround: http-timer only sets timings.end when the response emits 'end'. + // When a stream is destroyed before completion, the 'end' event may not fire, + // leaving timings.end undefined. This should ideally be fixed in http-timer + // by listening to the 'close' event, but we handle it here for now. + // Only set timings.end if there was no error or abort (to maintain semantic correctness). + const timings = (this._request as ClientRequestWithTimings)?.timings; + if (timings && is.undefined(timings.end) && !is.undefined(timings.response) && is.undefined(timings.error) && is.undefined(timings.abort)) { + timings.end = Date.now(); + if (is.undefined(timings.phases.total)) { + timings.phases.download = timings.end - timings.response; + timings.phases.total = timings.end - timings.start; + } + } + + // Preserve custom errors returned by beforeError hooks. + // For other errors, wrap non-RequestError instances for consistency. + if (error !== null && !is.undefined(error)) { + const processedByHooks = error instanceof Error && errorsProcessedByHooks.has(error); + + if (!processedByHooks && !(error instanceof RequestError)) { + error = error instanceof Error + ? new RequestError(error.message, error, this) + : new RequestError(String(error), {}, this); + } + } + + callback(error); + } + + override pipe(destination: T, options?: {end?: boolean}): T { + if (destination instanceof ServerResponse) { + this._pipedServerResponses.add(destination); + } + + return super.pipe(destination, options); + } + + override unpipe(destination: T): this { + if (destination instanceof ServerResponse) { + this._pipedServerResponses.delete(destination); + } + + super.unpipe(destination); + + return this; + } + + private _attachAbortListener(): void { + if (this._abortListenerDisposer) { + return; + } + + const {signal} = this.options; + if (!signal) { + return; + } + + const abort = () => { + this._destroyInFlightAlpnSocket(); + + // See https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static#return_value + if (signal.reason?.name === 'TimeoutError') { + this.destroy(new TimeoutError(signal.reason, this.timings ?? createPreRequestErrorTimings(), this)); + } else { + this.destroy(new AbortError(this)); + } + }; + + if (signal.aborted) { + abort(); + } else { + this._abortListenerDisposer = addAbortListener(signal, abort); + } + } + + private _destroyInFlightAlpnSocket(): void { + (this._requestOptions as (NativeRequestOptions & {_alpnSocket?: Socket}) | undefined)?._alpnSocket?.destroy(); + } + + private _shouldIncrementallyDecodeBody(): boolean { + const {responseType, encoding} = this.options; + + return Boolean(this._noPipe) + && (responseType === 'text' || responseType === 'json') + && isUtf8Encoding(encoding) + && typeof globalThis.TextDecoder === 'function'; + } + + private _checkContentLengthMismatch(): boolean { + if (this.options.strictContentLength && this._expectedContentLength !== undefined) { + // Use compressed bytes count when available (for compressed responses), + // otherwise use _downloadedSize (for uncompressed responses) + const actualSize = this._compressedBytesCount ?? this._downloadedSize; + if (actualSize !== this._expectedContentLength) { + this._beforeError(new ReadError({ + message: `Content-Length mismatch: expected ${this._expectedContentLength} bytes, received ${actualSize} bytes`, + name: 'Error', + code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH', + }, this)); + return true; + } + } + + return false; + } + + private async _finalizeBody(): Promise { + const {options} = this; + const headers = options.getInternalHeaders(); + + const isForm = !is.undefined(options.form); + // eslint-disable-next-line @typescript-eslint/naming-convention + const isJSON = !is.undefined(options.json); + const isBody = !is.undefined(options.body); + const cannotHaveBody = !this._methodCanHaveBody; + + if (isForm || isJSON || isBody) { + if (cannotHaveBody) { + throw new TypeError(`The \`${options.method}\` method cannot be used with a body`); + } + + // Serialize body + const noContentType = !is.string(headers['content-type']); + + if (isBody) { + // Native FormData + if (options.body instanceof FormData) { + const {body, contentType} = serializeNativeFormDataBody(options.body); + this._nativeFormDataBody = { + form: options.body, + body, + contentTypeWasGenerated: noContentType, + }; + + if (noContentType) { + headers['content-type'] = contentType; + } + + options.body = body; + } else if (Object.prototype.toString.call(options.body) === '[object FormData]') { + throw new TypeError('Non-native FormData is not supported. Use globalThis.FormData instead.'); + } + } else if (isForm) { + if (noContentType) { + headers['content-type'] = 'application/x-www-form-urlencoded'; + } + + const {form} = options; + options.form = undefined; + + options.body = (new URLSearchParams(form)).toString(); + } else { + if (noContentType) { + headers['content-type'] = 'application/json'; + } + + const {json} = options; + options.json = undefined; + + options.body = options.stringifyJson(json); + } + + const uploadBodySize = getBodySize(options.body, headers); + + // See https://tools.ietf.org/html/rfc7230#section-3.3.2 + // A user agent SHOULD send a Content-Length in a request message when + // no Transfer-Encoding is sent and the request method defines a meaning + // for an enclosed payload body. For example, a Content-Length header + // field is normally sent in a POST request even when the value is 0 + // (indicating an empty payload body). A user agent SHOULD NOT send a + // Content-Length header field when the request message does not contain + // a payload body and the method semantics do not anticipate such a + // body. + if (is.undefined(headers['content-length']) && is.undefined(headers['transfer-encoding']) && !cannotHaveBody && !is.undefined(uploadBodySize)) { + headers['content-length'] = String(uploadBodySize); + } + } + + if (options.responseType === 'json' && !('accept' in headers)) { + headers.accept = 'application/json'; + } + + this._bodySize = Number(headers['content-length']) || undefined; + } + + private async _onResponseBase(response: IncomingMessageWithTimings): Promise { + // This will be called e.g. when using cache so we need to check if this request has been aborted. + if (this.isAborted) { + return; + } + + const {options} = this; + const {url} = options; + + const nativeResponse = response as IncomingMessageWithTimings & {fromCache?: boolean}; + const statusCode = response.statusCode!; + const {method} = options; + const redirectLocationHeader = response.headers.location; + const redirectLocation = Array.isArray(redirectLocationHeader) ? redirectLocationHeader[0] : redirectLocationHeader; + const isRedirect = Boolean(redirectLocation && redirectCodes.has(statusCode)); + + // Skip decompression for responses that must not have bodies per RFC 9110: + // - HEAD responses (any status code) + // - 1xx (Informational): 100, 101, 102, 103, etc. + // - 204 (No Content) + // - 205 (Reset Content) + // - 304 (Not Modified) + const hasNoBody = method === 'HEAD' + || (statusCode >= 100 && statusCode < 200) + || statusCode === 204 + || statusCode === 205 + || statusCode === 304; + + const prepareResponse = (response: PlainResponse): PlainResponse => { + if (!Object.hasOwn(response, 'headers')) { + Object.defineProperty(response, 'headers', { + value: response.headers, + enumerable: true, + writable: true, + configurable: true, + }); + } + + response.statusMessage ||= http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty. + response.url = stripUrlAuth(options.url!); + response.requestUrl = this.requestUrl!; + response.redirectUrls = this.redirectUrls; + response.request = this; + response.isFromCache = nativeResponse.fromCache ?? false; + response.ip = this.ip; + response.retryCount = this.retryCount; + response.ok = isResponseOk(response); + + return response; + }; + + let typedResponse = prepareResponse(response as PlainResponse); + // Redirect responses that will be followed are drained raw. Decompressing them can + // turn an irrelevant redirect body into a client-side failure or decompression DoS. + const shouldFollowRedirect = isRedirect && (typeof options.followRedirect === 'function' ? options.followRedirect(typedResponse) : options.followRedirect); + + if (options.decompress && !hasNoBody && !shouldFollowRedirect) { + // When strictContentLength is enabled, track compressed bytes by listening to + // the native response's data events before decompression + if (options.strictContentLength) { + this._compressedBytesCount = 0; + nativeResponse.on('data', (chunk: Uint8Array) => { + this._compressedBytesCount! += byteLength(chunk); + }); + } + + response = decompressResponse(response); + typedResponse = prepareResponse(response as PlainResponse); + } + + // `decompressResponse` wraps the response stream when it decompresses, + // so `response !== nativeResponse` indicates decompression happened. + const wasDecompressed = response !== nativeResponse; + + this._responseSize = Number(response.headers['content-length']) || undefined; + + this.response = typedResponse; + // eslint-disable-next-line @typescript-eslint/naming-convention + this._incrementalDecode = this._shouldIncrementallyDecodeBody() ? {decoder: new globalThis.TextDecoder('utf8', {ignoreBOM: true}), chunks: []} : undefined; + + // Publish response start event + publishResponseStart({ + requestId: this._requestId, + url: typedResponse.url, + statusCode, + headers: response.headers, + isFromCache: typedResponse.isFromCache, + }); + + response.once('error', (error: Error) => { + // Node synthesizes ECONNRESET for close-delimited responses after all body + // bytes have been delivered. Only ignore that late synthetic error on the + // native response. Wrapped decompression streams surface real checksum and + // truncation failures after the underlying response has completed. + if ( + !wasDecompressed + && response.complete + && this._responseSize === undefined + && error.code === 'ECONNRESET' + ) { + return; + } + + this._aborted = true; + + this._beforeError(new ReadError(error, this)); + }); + + response.once('aborted', () => { + // Without Content-Length, connection close is the intended EOF signal (RFC 9110 ยง8.6), + // not a premature abort. For wrapped decompression streams, rely on the native + // response completion state because the wrapper strips `content-length`. + if (this._responseSize === undefined && nativeResponse.complete) { + return; + } + + this._aborted = true; + + // Check if there's a content-length mismatch to provide a more specific error + if (!this._checkContentLengthMismatch()) { + this._beforeError(new ReadError({ + name: 'Error', + message: 'The server aborted pending request', + code: 'ECONNRESET', + }, this)); + } + }); + + let canFinalizeResponse = false; + const handleResponseEnd = () => { + if ( + !canFinalizeResponse + || !response.readableEnded + ) { + return; + } + + canFinalizeResponse = false; + + if (this._stopReading) { + return; + } + + // Validate content-length if it was provided + // Per RFC 9112: "If the sender closes the connection before the indicated number + // of octets are received, the recipient MUST consider the message to be incomplete" + if (this._checkContentLengthMismatch()) { + return; + } + + this._responseSize = this._downloadedSize; + this.emit('downloadProgress', this.downloadProgress); + + // Publish response end event + publishResponseEnd({ + requestId: this._requestId, + url: typedResponse.url, + statusCode, + bodySize: this._downloadedSize, + timings: this.timings, + }); + + this.push(null); + }; + + if (!shouldFollowRedirect) { + // `set-cookie` handling below awaits the cookie jar. A fast response can fully + // end during that await, so we need to observe `end` early without completing + // the outward stream until cookie handling has finished. + response.once('end', handleResponseEnd); + } + + const noPipeCookieJarRawBodyPromise = this._noPipe + && is.object(options.cookieJar) + && !isRedirect + ? this._setRawBody(response) + : undefined; + + const rawCookies = response.headers['set-cookie']; + if (is.object(options.cookieJar) && rawCookies) { + let promises: Array> = rawCookies.map(async (rawCookie: string) => (options.cookieJar as PromiseCookieJar).setCookie(rawCookie, url!.toString())); + + if (options.ignoreInvalidCookies) { + promises = promises.map(async promise => { + try { + await promise; + } catch {} + }); + } + + try { + await Promise.all(promises); + } catch (error: unknown) { + this._beforeError(normalizeError(error)); + return; + } + } + + // The above is running a promise, therefore we need to check if this request has been aborted yet again. + if (this.isAborted) { + return; + } + + if (shouldFollowRedirect) { + // We're being redirected, we don't care about the response. + // It'd be best to abort the request, but we can't because + // we would have to sacrifice the TCP connection. We don't want that. + response.resume(); + + this._cancelTimeouts?.(); + this._unproxyEvents?.(); + + if (this.redirectUrls.length >= options.maxRedirects) { + this._beforeError(new MaxRedirectsError(this)); + return; + } + + this._request = undefined; + + // Reset progress for the new request. + this._downloadedSize = 0; + this._uploadedSize = 0; + + const updatedOptions = new Options(undefined, undefined, this.options); + + try { + // We need this in order to support UTF-8 + const redirectBuffer = Buffer.from(redirectLocation, 'binary').toString(); + const redirectUrl = new URL(redirectBuffer, url); + const currentUnixSocketPath = getUnixSocketPath(url as URL); + const redirectUnixSocketPath = getUnixSocketPath(redirectUrl); + + if (redirectUrl.protocol === 'unix:' && redirectUnixSocketPath === undefined) { + this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this)); + return; + } + + // Relative redirects on the same socket are fine, but a redirect must not switch to a different local socket. + if (redirectUnixSocketPath !== undefined && currentUnixSocketPath !== redirectUnixSocketPath) { + this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this)); + return; + } + + // Redirecting to a different site, clear sensitive data. + // For UNIX sockets, different socket paths are also different origins. + const isDifferentOrigin = redirectUrl.origin !== (url as URL).origin + || currentUnixSocketPath !== redirectUnixSocketPath; + + const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD'; + // Avoid forwarding a POST body to a different origin on historical 301/302 redirects. + const crossOriginRequestedGet = isDifferentOrigin + && (statusCode === 301 || statusCode === 302) + && updatedOptions.method === 'POST'; + const canRewrite = statusCode !== 307 && statusCode !== 308; + const userRequestedGet = updatedOptions.methodRewriting && canRewrite; + const shouldDropBody = serverRequestedGet || crossOriginRequestedGet || userRequestedGet; + + if (shouldDropBody) { + updatedOptions.method = 'GET'; + this._dropBody(updatedOptions); + } else if ( + isDifferentOrigin + && canRewrite + && updatedOptions.method !== 'QUERY' + && this._hasBodyForRedirect(updatedOptions) + ) { + this._dropBody(updatedOptions); + } + + if (isDifferentOrigin) { + // On cross-origin redirects, strip sensitive headers and any credentials + // embedded in the redirect URL itself to prevent a malicious server from + // leaking them to a third party. 307/308 redirects preserve the method and replayable body per RFC; QUERY does the same on 301/302. + updatedOptions.h2session = undefined; + this._stripCrossOriginState(updatedOptions, redirectUrl); + } else { + redirectUrl.username = updatedOptions.username; + redirectUrl.password = updatedOptions.password; + } + + // Redirect URLs are resolved internally. Restore the user option before hooks run so hook mutations still honor it. + const {allowAbsoluteUrls} = updatedOptions; + try { + updatedOptions.allowAbsoluteUrls = true; + updatedOptions.url = redirectUrl; + } finally { + updatedOptions.allowAbsoluteUrls = allowAbsoluteUrls; + } + + this.redirectUrls.push(redirectUrl); + + const boundaryBeforeRedirectHooks = getUrlPrefixBoundary(updatedOptions); + const bodyBeforeRedirectHooks = updatedOptions.body; + const h2sessionBeforeRedirectHooks = updatedOptions.h2session; + const preHookState = isDifferentOrigin + ? undefined + : { + ...snapshotCrossOriginState(updatedOptions), + url: new URL(updatedOptions.url), + }; + + const changedState = await updatedOptions.trackStateMutations(async changedState => { + for (const hook of updatedOptions.hooks.beforeRedirect) { + // eslint-disable-next-line no-await-in-loop + await hook(updatedOptions as NormalizedOptions, typedResponse); + } + + return changedState; + }); + + if (hasUrlOrPrefixUrlBoundaryChanged(updatedOptions, updatedOptions.url, boundaryBeforeRedirectHooks)) { + assertUrlHasSameOriginAsPrefixUrlIfNeeded(updatedOptions, updatedOptions.url); + } + + updatedOptions.clearUnchangedCookieHeader(preHookState, changedState); + + const nativeFormDataBody = this._nativeFormDataBody; + const mustReplayBodyOnRedirect = statusCode === 307 || statusCode === 308 || updatedOptions.method === 'QUERY'; + + if (mustReplayBodyOnRedirect) { + const bodyUnchangedByHooks = updatedOptions.body === bodyBeforeRedirectHooks; + const wasNonReplayable = isNonReplayableBody(bodyBeforeRedirectHooks); + + if (!bodyUnchangedByHooks && wasNonReplayable) { + // A hook supplied a fresh body, so dispose of the original non-replayable one. + this._destroyBody(bodyBeforeRedirectHooks); + } else if ( + bodyUnchangedByHooks + && nativeFormDataBody !== undefined + && updatedOptions.body === nativeFormDataBody.body + ) { + // Native FormData generates a fresh stream and boundary, so re-serialize it to replay the upload. + const {body, contentType} = serializeNativeFormDataBody(nativeFormDataBody.form); + + nativeFormDataBody.body = body; + updatedOptions.body = body; + + if (changedState.has('content-type')) { + nativeFormDataBody.contentTypeWasGenerated = false; + } else if (nativeFormDataBody.contentTypeWasGenerated) { + updatedOptions.setInternalHeader('content-type', contentType); + } + } else if ( + bodyUnchangedByHooks + && (wasNonReplayable || (is.undefined(updatedOptions.body) && (this._hasWrittenBody || this._hasWritableBody))) + ) { + // Body-preserving redirects must replay the body, so follow the HTTP spec and other clients by failing for unchanged non-replayable bodies. Hooks may supply a fresh body. + this._dropBody(updatedOptions); + this._beforeError(new RequestError('Cannot follow redirect with a non-replayable body', {}, this)); + return; + } + } + + // If a beforeRedirect hook changed the URL to a different origin, + // strip sensitive headers that were preserved for the original origin. + // When isDifferentOrigin was already true, headers were already stripped above. + if (!isDifferentOrigin) { + const state = preHookState!; + const hookUrl = updatedOptions.url; + const hookChangedOrigin = !isSameOrigin(state.url, hookUrl); + + if ( + hookChangedOrigin + && (statusCode === 301 || statusCode === 302) + && updatedOptions.method === 'POST' + ) { + updatedOptions.method = 'GET'; + this._dropBody(updatedOptions); + } + + if (hookChangedOrigin) { + if (updatedOptions.h2session === h2sessionBeforeRedirectHooks) { + updatedOptions.h2session = undefined; + } + + if ( + canRewrite + && updatedOptions.method !== 'QUERY' + && this._hasUnchangedBodyForRedirect(updatedOptions, state, changedState) + ) { + this._dropBody(updatedOptions); + } + + this._stripUnchangedCrossOriginState(updatedOptions, hookUrl, { + ...state, + changedState, + preserveUsername: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'username') + || isCrossOriginCredentialChanged(state.url, hookUrl, 'username'), + preservePassword: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'password') + || isCrossOriginCredentialChanged(state.url, hookUrl, 'password'), + }); + } + } + + // Publish redirect event + publishRedirect({ + requestId: this._requestId, + fromUrl: url!.toString(), + toUrl: (updatedOptions.url).toString(), + statusCode, + }); + + this.emit('redirect', updatedOptions, typedResponse); + + this.options = updatedOptions; + + await this._makeRequest(); + } catch (error: unknown) { + this._beforeError(normalizeError(error)); + return; + } + + return; + } + + canFinalizeResponse = true; + handleResponseEnd(); + + // `HTTPError`s always have `error.response.body` defined. + // Therefore, we cannot retry if `options.throwHttpErrors` is false. + // On the last retry, if `options.throwHttpErrors` is false, we would need to return the body, + // but that wouldn't be possible since the body would be already read in `error.response.body`. + if (options.isStream && options.throwHttpErrors && !isResponseOk(typedResponse)) { + this._beforeError(new HTTPError(typedResponse)); + return; + } + + // Store the expected content-length from the native response for validation. + // This is the content-length before decompression, which is what actually gets transferred. + // Skip storing for responses that shouldn't have bodies per RFC 9110. + // When decompression occurs, only store if strictContentLength is enabled. + if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) { + const contentLengthHeader = nativeResponse.headers['content-length']; + if (contentLengthHeader !== undefined) { + const expectedLength = Number(contentLengthHeader); + if (!Number.isNaN(expectedLength) && expectedLength >= 0) { + this._expectedContentLength = expectedLength; + } + } + } + + this.emit('downloadProgress', this.downloadProgress); + + response.on('readable', () => { + if (this._triggerRead) { + this._read(); + } + }); + + this.on('resume', () => { + response.resume(); + }); + + this.on('pause', () => { + response.pause(); + }); + + if (this._noPipe) { + const captureFromResponse = response.readableEnded || noPipeCookieJarRawBodyPromise !== undefined; + const success = noPipeCookieJarRawBodyPromise + ? await noPipeCookieJarRawBodyPromise + : await this._setRawBody(captureFromResponse ? response : this); + + if (captureFromResponse) { + handleResponseEnd(); + } + + if (success) { + this.emit('response', response); + } + + return; + } + + this.emit('response', response); + + for (const destination of this._pipedServerResponses) { + if (destination.headersSent) { + continue; + } + + for (const key in response.headers) { + if (Object.hasOwn(response.headers, key)) { + const value = response.headers[key]; + + // When decompression occurred, skip content-encoding and content-length + // as they refer to the compressed data, not the decompressed stream. + if (wasDecompressed && (key === 'content-encoding' || key === 'content-length')) { + continue; + } + + // Skip if value is undefined + if (value !== undefined) { + destination.setHeader(key, value); + } + } + } + + destination.statusCode = statusCode; + } + } + + private async _setRawBody(from: Readable = this): Promise { + try { + // Errors are emitted via the `error` event + const fromArray = await from.toArray(); + const hasNonStringChunk = fromArray.some(chunk => typeof chunk !== 'string'); + const rawBody = hasNonStringChunk + ? concatUint8Arrays((fromArray as Array).map(chunk => typeof chunk === 'string' ? stringToUint8Array(chunk) : chunk)) + : stringToUint8Array((fromArray as string[]).join('')); + const shouldUseIncrementalDecodedBody = from === this && this._incrementalDecode !== undefined; + + // On retry Request is destroyed with no error, therefore the above will successfully resolve. + // So in order to check if this was really successful, we need to check if it has been properly ended. + if (!this.isAborted && this.response) { + this.response.rawBody = rawBody; + if (from !== this) { + this._downloadedSize = rawBody.byteLength; + } + + if (shouldUseIncrementalDecodedBody) { + try { + const {decoder, chunks} = this._incrementalDecode!; + const finalDecodedChunk = decoder.decode(); + if (finalDecodedChunk.length > 0) { + chunks.push(finalDecodedChunk); + } + + cacheDecodedBody(this.response, chunks.join('')); + } catch {} + } + + return true; + } + } catch {} finally { + this._incrementalDecode = undefined; + } + + return false; + } + + private async _onResponse(response: IncomingMessageWithTimings): Promise { + try { + await this._onResponseBase(response); + } catch (error: unknown) { + /* istanbul ignore next: better safe than sorry */ + this._beforeError(normalizeError(error)); + } + } + + private _onRequest(request: ClientRequest): void { + const {options} = this; + const {timeout, url} = options; + + // Publish request start event + publishRequestStart({ + requestId: this._requestId, + url: getSanitizedUrl(this.options), + method: options.method, + headers: options.headers, + }); + + timer(request); + + const {isGotHttp2Request} = request as ClientRequest & {isGotHttp2Request?: boolean}; + let timeoutDelays = timeout; + if (isGotHttp2Request) { + const {socket: _socket, ...http2TimeoutDelays} = timeout; + timeoutDelays = http2TimeoutDelays; + } + + this._cancelTimeouts = timedOut(request, timeoutDelays, url as URL); + + let lastRequestError: Error | undefined; + const responseEventName = options.cache ? 'cacheableResponse' : 'response'; + + request.once(responseEventName, (response: IncomingMessageWithTimings) => { + void this._onResponse(response); + }); + + const emitRequestError = (error: Error): void => { + this._aborted = true; + + // Force clean-up, because some packages (e.g. nock) don't do this. + request.destroy(); + + const wrappedError = error instanceof TimedOutTimeoutError ? new TimeoutError(error, this.timings ?? createPreRequestErrorTimings(), this) : new RequestError(error.message, error, this); + this._beforeError(wrappedError); + }; + + request.once('error', (error: Error) => { + lastRequestError = error; + + // Ignore errors from requests superseded by a redirect. + if (this._request !== request) { + return; + } + + /* + Transient write errors (EPIPE, ECONNRESET) often fire during redirects when the + server closes the connection after sending the redirect response. Defer by one + microtask to let the response event make the request stale. + */ + if (isTransientWriteError(error)) { + queueMicrotask(() => { + if (this._isRequestStale(request)) { + return; + } + + emitRequestError(error); + }); + + return; + } + + emitRequestError(error); + }); + + if (!options.cache) { + request.once('close', () => { + if (this._request !== request || Boolean((request as ClientRequest & {res?: unknown}).res) || this._stopReading) { + return; + } + + this._beforeError(lastRequestError ?? new ReadError({ + name: 'Error', + message: 'The server aborted pending request', + code: 'ECONNRESET', + }, this)); + }); + } + + this._unproxyEvents = proxyEvents(request, this, proxiedRequestEvents); + this._request = request; + + this.emit('uploadProgress', this.uploadProgress); + + this._sendBody(); + + this.emit('request', request); + } + + private _isRequestStale(request: ClientRequest): boolean { + return this._request !== request || Boolean((request as ClientRequest & {res?: unknown}).res) || request.destroyed || request.writableEnded; + } + + private async _asyncWrite(chunk: any, request: Request | ClientRequest = this): Promise { + return new Promise((resolve, reject) => { + if (request === this) { + super.write(chunk, error => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + return; + } + + this._writeRequest(chunk, undefined, error => { + if (error) { + reject(error); + return; + } + + resolve(); + }, request as ClientRequest); + }); + } + + private _sendBody() { + // Send body + const {body} = this.options; + const currentRequest = this.redirectUrls.length === 0 && !this._discardBodyWrites ? this : this._request ?? this; + + if (is.nodeStream(body)) { + body.pipe(currentRequest); + } else if (is.buffer(body)) { + // Buffer should be sent directly without conversion + this._writeBodyInChunks(body, currentRequest); + } else if (is.typedArray(body)) { + // Typed arrays should be treated like buffers, not iterated over + // Create a Uint8Array view over the data (Node.js streams accept Uint8Array) + const typedArray = body as ArrayBufferView; + const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength); + this._writeBodyInChunks(uint8View, currentRequest); + } else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) { + (async () => { + const isInitialRequest = currentRequest === this; + const bodyOptions = this.options; + + try { + for await (const chunk of body) { + if (this.options !== bodyOptions || this.options.body !== body) { + return; + } + + await this._asyncWrite(chunk, currentRequest); + + if (this.options !== bodyOptions || this.options.body !== body) { + return; + } + } + + if (this.options === bodyOptions && this.options.body === body) { + if (isInitialRequest) { + super.end(); + return; + } + + await this._endWritableRequest(currentRequest as ClientRequest); + } + } catch (error: unknown) { + if (this.options !== bodyOptions || this.options.body !== body) { + return; + } + + this._beforeError(normalizeError(error)); + } + })(); + } else if (is.undefined(body)) { + // No body to send, end the request + if ((this._noPipe ?? false) || !this._methodCanHaveBody || currentRequest !== this) { + currentRequest.end(); + } + } else { + // Handles string bodies (from json/form options). + this._writeBodyInChunks(stringToUint8Array(body as string), currentRequest); + } + } + + /* + Write a body buffer in chunks to enable granular `uploadProgress` events. + + Without chunking, string/Uint8Array/TypedArray bodies are written in a single call, causing `uploadProgress` to only emit 0% and 100% with nothing in between. + + The 64 KB chunk size matches Node.js fs stream defaults. + */ + private _writeBodyInChunks(buffer: Uint8Array, currentRequest: Request | ClientRequest) { + const isInitialRequest = currentRequest === this; + + (async () => { + let request: ClientRequest | undefined; + + try { + request = isInitialRequest ? this._request : currentRequest as ClientRequest; + const activeRequest = request; + + if (!activeRequest) { + if (isInitialRequest) { + super.end(); + } + + return; + } + + if (activeRequest.destroyed) { + return; + } + + await this._writeChunksToRequest(buffer, activeRequest); + + if (this._isRequestStale(activeRequest)) { + this._finalizeStaleChunkedWrite(activeRequest, isInitialRequest); + return; + } + + if (isInitialRequest) { + super.end(); + return; + } + + await this._endWritableRequest(activeRequest); + } catch (error: unknown) { + const normalizedError = normalizeError(error); + + // Transient write errors (EPIPE, ECONNRESET) are handled by the request-level + // error and close handlers. For initial redirected writes, still finalize + // writable state once the stale transition becomes observable. + if (isTransientWriteError(normalizedError)) { + if (isInitialRequest && request) { + const initialRequest = request; + let didFinalize = false; + const finalizeIfStale = () => { + if (didFinalize || !this._isRequestStale(initialRequest)) { + return; + } + + didFinalize = true; + this._finalizeStaleChunkedWrite(initialRequest, true); + }; + + finalizeIfStale(); + + if (!didFinalize) { + initialRequest.once('response', finalizeIfStale); + queueMicrotask(finalizeIfStale); + } + } + + return; + } + + if (!isInitialRequest && this._isRequestStale(currentRequest as ClientRequest)) { + return; + } + + this._beforeError(normalizedError); + } + })(); + } + + private _finalizeStaleChunkedWrite(request: ClientRequest, isInitialRequest: boolean) { + if (!request.destroyed && !request.writableEnded) { + request.destroy(); + } + + if (isInitialRequest) { + // Finalize writable state without ending the active redirected request. + this._skipRequestEndInFinal = true; + super.end(); + } + } + + private _emitUploadComplete(request: ClientRequest) { + this._bodySize = this._uploadedSize; + this.emit('uploadProgress', this.uploadProgress); + request.emit('upload-complete'); + } + + private async _endWritableRequest(request: ClientRequest): Promise { + await new Promise((resolve, reject) => { + request.end((error?: Error | null) => { // eslint-disable-line @typescript-eslint/no-restricted-types + if (error) { + reject(error); + return; + } + + if (this._request === request && !request.destroyed) { + this._emitUploadComplete(request); + } + + resolve(); + }); + }); + } + + private _stripCrossOriginState(options: Options, urlToClear: URL) { + for (const header of crossOriginStripHeaders) { + options.deleteInternalHeader(header); + } + + options.username = ''; + options.password = ''; + + urlToClear.username = ''; + urlToClear.password = ''; + } + + private _stripUnchangedCrossOriginState(options: Options, urlToClear: URL, state: CrossOriginState & { + changedState: Set; + preserveUsername: boolean; + preservePassword: boolean; + }) { + const headers = options.getInternalHeaders(); + + for (const header of crossOriginStripHeaders) { + if (!state.changedState.has(header) && headers[header] === state.headers[header]) { + options.deleteInternalHeader(header); + } + } + + if (!state.preserveUsername) { + options.username = ''; + urlToClear.username = ''; + } + + if (!state.preservePassword) { + options.password = ''; + urlToClear.password = ''; + } + } + + private get _methodCanHaveBody(): boolean { + return !methodsWithoutBody.has(this.options.method) || (this.options.method === 'GET' && this.options.allowGetBody); + } + + private _canWriteBody(): boolean { + return !this._noPipe && !this.isReadonly && this._methodCanHaveBody; + } + + private _hasBodyForRedirect(options: Options): boolean { + return !is.undefined(options.body) || !is.undefined(options.json) || !is.undefined(options.form) || this._hasWrittenBody || this._hasWritableBody; + } + + private _hasUnchangedBodyForRedirect(options: Options, state: CrossOriginState, changedState: Set): boolean { + return !changedState.has('body') + && !changedState.has('json') + && !changedState.has('form') + && this._hasBodyForRedirect(options) + && isBodyUnchanged(options, state); + } + + private _dropBody(updatedOptions: Options) { + const {body} = this.options; + const hadOptionBody = !is.undefined(body) || !is.undefined(this.options.json) || !is.undefined(this.options.form); + + this.options.clearBody(); + + this._destroyBody(body); + + if (!hadOptionBody && !this.writableEnded) { + this._skipRequestEndInFinal = true; + super.end(); + } + + updatedOptions.clearBody(); + + this._bodySize = undefined; + this._hasWrittenBody = false; + this._hasWritableBody = false; + } + + private _destroyBody(body: unknown) { + if (is.nodeStream(body)) { + const bodyStream = body as NodeJS.ReadableStream & {destroy(): void}; + bodyStream.off('error', this._onBodyError); + bodyStream.unpipe(); + bodyStream.on('error', noop); + bodyStream.destroy(); + } else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) { + const iterableBody = body as unknown as Iterator | AsyncIterator; + + // Signal the iterator to clean up, but don't await it: + // the for-await loop in _sendBody exits via the options.body sentinel, + // and awaiting return() would deadlock when next() is pending. + if (typeof iterableBody.return === 'function') { + try { + const result = iterableBody.return(); + if (result instanceof Promise) { + // eslint-disable-next-line promise/prefer-await-to-then + result.catch(noop); + } + } catch {} + } + } + } + + private readonly _onBodyError = (error: Error): void => { + if (this._flushed) { + this._beforeError(new UploadError(error, this)); + } else { + this.flush = async () => { + this.flush = async () => {}; + + this._beforeError(new UploadError(error, this)); + }; + } + }; + + private async _writeChunksToRequest(buffer: Uint8Array, request: ClientRequest): Promise { + const chunkSize = 65_536; // 64 KB + const isStale = () => this._isRequestStale(request); + + for (const part of chunk(buffer, chunkSize)) { + if (isStale()) { + return; + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve, reject) => { + this._writeRequest(part, undefined, error => { + if (isStale()) { + resolve(); + return; + } + + if (error) { + reject(error); + } else { + setImmediate(resolve); + } + }, request); + }); + } + } + + private _prepareCache(cache: string | StorageAdapter) { + if (cacheableStore.has(cache)) { + return; + } + + const cacheableRequest = new CacheableRequest( + ((requestOptions: RequestOptions, handler?: (response: IncomingMessageWithTimings) => void): ClientRequest => { + /** + Wraps the cacheable-request handler to run beforeCache hooks. + These hooks control caching behavior by: + - Directly mutating the response object (changes apply to what gets cached) + - Returning `false` to prevent caching + - Returning `void`/`undefined` to use default caching behavior + + Hooks use direct mutation - they can modify response.headers, response.statusCode, etc. + Mutations take effect immediately and determine what gets cached. + */ + const wrappedHandler = handler + ? (response: IncomingMessageWithTimings) => { + const {beforeCacheHooks, gotRequest} = requestOptions as any; + + // Early return if no hooks - cache the original response + if (!beforeCacheHooks || beforeCacheHooks.length === 0) { + handler(response); + return; + } + + try { + // Call each beforeCache hook with the response + // Hooks can directly mutate the response - mutations take effect immediately + for (const hook of beforeCacheHooks) { + const result = hook(response); + + if (result === false) { + // Prevent caching by adding no-cache headers + // Mutate the response directly to add headers + response.headers['cache-control'] = 'no-cache, no-store, must-revalidate'; + response.headers.pragma = 'no-cache'; + response.headers.expires = '0'; + handler(response); + // Don't call remaining hooks - we've decided not to cache + return; + } + + if (is.promise(result)) { + // BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous + throw new TypeError('beforeCache hooks must be synchronous. The hook returned a Promise, but this hook must return synchronously. If you need async logic, use beforeRequest hook instead.'); + } + + if (result !== undefined) { + // Hooks should return false or undefined only + // Mutations work directly - no need to return the response + throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.'); + } + // Else: void/undefined = continue + } + } catch (error: unknown) { + const normalizedError = normalizeError(error); + // Convert hook errors to RequestError and propagate + // This is consistent with how other hooks handle errors + if (gotRequest) { + gotRequest._beforeError(normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, gotRequest)); + // Don't call handler when error was propagated successfully + return; + } + + // If gotRequest is missing, log the error to aid debugging + // We still call the handler to prevent the request from hanging + console.error('Got: beforeCache hook error (request context unavailable):', normalizedError); + // Call handler with response (potentially partially modified) + handler(response); + return; + } + + // All hooks ran successfully + // Cache the response with any mutations applied + handler(response); + } + : handler; + + const result = (requestOptions as any)._request(requestOptions, wrappedHandler); + + // TODO: remove this when `cacheable-request` supports async request functions. + if (is.promise(result)) { + // We only need to implement the error handler in order to support HTTP/2 caching. + // The result will be a promise anyway. + // @ts-expect-error ignore + result.once = (event: string, handler: (reason: unknown) => void) => { + if (event === 'error') { + (async () => { + try { + await result; + } catch (error) { + handler(error); + } + })(); + } else if (event === 'abort' || event === 'destroy') { + // The empty catch is needed here in case when + // it rejects before it's `await`ed in `_makeRequest`. + (async () => { + try { + const request = (await result) as ClientRequest; + request.once(event, handler); + } catch {} + })(); + } else { + /* istanbul ignore next: safety check */ + throw new Error(`Unknown HTTP/2 promise event: ${event}`); + } + + return result; + }; + } + + return result; + }) as typeof http.request, + cache as StorageAdapter, + ); + cacheableStore.set(cache, cacheableRequest.request()); + } + + private async _createCacheableRequest(url: URL, options: RequestOptions): Promise { + return new Promise((resolve, reject) => { + Object.assign(options, { + protocol: url.protocol, + hostname: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname, + host: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname, + hash: url.hash === '' ? '' : (url.hash ?? null), + search: url.search === '' ? '' : (url.search ?? null), + pathname: url.pathname, + href: url.href, + path: `${url.pathname || ''}${url.search || ''}`, + ...(is.string(url.port) && url.port.length > 0 ? {port: Number(url.port)} : {}), + ...(url.username || url.password ? {auth: `${url.username || ''}:${url.password || ''}`} : {}), + }); + + let request: ClientRequest | Promise; + + // TODO: Fix `cacheable-response`. This is ugly. + const cacheRequest = cacheableStore.get((options as any).cache)!(options as CacheableOptions, (response: any) => { + void (async () => { + response._readableState.autoDestroy = false; + + if (request) { + const fix = () => { + // For ResponseLike objects from cache, set complete to true if not already set. + // For real HTTP responses, copy from the underlying response. + if (response.req) { + response.complete = response.req.res.complete; + } else if (response.complete === undefined) { + // ResponseLike from cache should have complete = true + response.complete = true; + } + }; + + response.prependOnceListener('end', fix); + fix(); + + (await request).emit('cacheableResponse', response); + } + + resolve(response); + })(); + }); + + cacheRequest.once('error', reject); + cacheRequest.once('request', (requestOrPromise: ClientRequest | Promise) => { + request = requestOrPromise; + resolve(request); + }); + }); + } + + private async _makeRequest(): Promise { + const {options} = this; + const shouldDeleteGeneratedHeader = (currentHeader: string | string[] | undefined, generatedHeader: string | undefined) => currentHeader === generatedHeader || is.undefined(currentHeader); + const syncGeneratedHeader = (name: 'authorization' | 'cookie', { + currentHeader, + explicitHeader, + nextHeader, + staleGeneratedHeader, + }: { + currentHeader: string | string[] | undefined; + explicitHeader: string | string[] | undefined; + nextHeader: string | undefined; + staleGeneratedHeader: string | undefined; + }) => { + if (!is.undefined(nextHeader)) { + options.setInternalHeader(name, nextHeader); + } else if (!is.undefined(explicitHeader) && currentHeader === staleGeneratedHeader) { + options.setInternalHeader(name, explicitHeader); + } else if (shouldDeleteGeneratedHeader(currentHeader, staleGeneratedHeader)) { + options.deleteInternalHeader(name); + } + }; + + const getAuthorizationHeader = (username: string, password: string, isExplicitlyOmitted: boolean) => !isExplicitlyOmitted && (username || password) + ? `Basic ${stringToBase64(`${username}:${password}`)}` + : undefined; + const sanitizeHeaders = () => { + const currentHeaders = options.getInternalHeaders(); + + for (const key in currentHeaders) { + if (is.undefined(currentHeaders[key])) { + options.deleteInternalHeader(key); + } else if (is.null(currentHeaders[key])) { + throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`); + } else if (Array.isArray(currentHeaders[key]) && key === 'transfer-encoding') { + // Node serializes request header arrays as repeated field lines. Keep framing + // unambiguous by allowing only one transfer-encoding value here. + if (currentHeaders[key].length !== 1) { + throw new TypeError(`The \`${key}\` header must be a single value`); + } + + options.setInternalHeader(key, currentHeaders[key][0]); + } else if (Array.isArray(currentHeaders[key]) && singleValueRequestHeaders.has(key)) { + // Duplicate credential and content-length lines are not allowed on requests. + // Normalize a single-element array to match the long-supported string path. + if (currentHeaders[key].length !== 1) { + throw new TypeError(`The \`${key}\` header must be a single value`); + } + + options.setInternalHeader(key, currentHeaders[key][0]); + } + } + + return currentHeaders; + }; + + const getCookieHeader = async (cookieJar: PromiseCookieJar | undefined) => { + if (!cookieJar) { + return undefined; + } + + const cookieString = await cookieJar.getCookieString(options.url!.toString()); + return is.nonEmptyString(cookieString) ? cookieString : undefined; + }; + + const headers = sanitizeHeaders(); + const initialHeaders = options.getInternalHeaders(); + const authorizationWasInitiallyExplicit = options.isHeaderExplicitlySet('authorization'); + const explicitAuthorizationHeader = authorizationWasInitiallyExplicit ? initialHeaders.authorization : undefined; + const explicitCookieHeader = options.isHeaderExplicitlySet('cookie') ? initialHeaders.cookie : undefined; + const authorizationWasInitiallyOmitted = options.isHeaderExplicitlySet('authorization') && is.undefined(initialHeaders.authorization); + const cookieWasInitiallyOmitted = options.isHeaderExplicitlySet('cookie') && is.undefined(initialHeaders.cookie); + + if (options.decompress && is.undefined(headers['accept-encoding'])) { + const encodings = ['gzip', 'deflate']; + if (supportsBrotli) { + encodings.push('br'); + } + + if (supportsZstd) { + encodings.push('zstd'); + } + + options.setInternalHeader('accept-encoding', encodings.join(', ')); + } + + const {username, password} = options; + const cookieJar = options.cookieJar as PromiseCookieJar | undefined; + // Preserve an explicit Authorization header over URL-derived Basic auth. This keeps + // normalized single-element arrays aligned with the long-supported string behavior. + const generatedAuthorizationHeader = is.undefined(explicitAuthorizationHeader) + ? getAuthorizationHeader(username, password, authorizationWasInitiallyOmitted) + : undefined; + let generatedCookieHeader: string | undefined; + + if (!is.undefined(generatedAuthorizationHeader)) { + options.setInternalHeader('authorization', generatedAuthorizationHeader); + } + + if (!cookieWasInitiallyOmitted) { + generatedCookieHeader = await getCookieHeader(cookieJar); + + if (!is.undefined(generatedCookieHeader)) { + options.setInternalHeader('cookie', generatedCookieHeader); + } + } + + let request: ReturnType | undefined; + let shouldOmitRequestUrlCredentials = false; + const urlBeforeRequestHooks = options.url instanceof URL ? new URL(options.url) : undefined; + const boundaryBeforeRequestHooks = getUrlPrefixBoundary(options); + const stateBeforeRequestHooks = urlBeforeRequestHooks ? snapshotCrossOriginState(options) : undefined; + const crossOriginHookStrippedHeaders = new Set(); + const changedState = await options.trackStateMutations(async changedState => { + for (const hook of options.hooks.beforeRequest) { + // eslint-disable-next-line no-await-in-loop + const result = await hook(options as NormalizedOptions, {retryCount: this.retryCount}); + + if (!is.undefined(result)) { + // @ts-expect-error Skip the type mismatch to support abstract responses + request = () => result; + break; + } + } + + return changedState; + }); + + if ( + urlBeforeRequestHooks + && options.url instanceof URL + && hasUrlOrPrefixUrlBoundaryChanged(options, options.url, boundaryBeforeRequestHooks) + ) { + assertUrlHasSameOriginAsPrefixUrlIfNeeded(options, options.url); + } + + if ( + urlBeforeRequestHooks + && options.url instanceof URL + && !isSameOrigin(urlBeforeRequestHooks, options.url) + ) { + const hookChangedState = new Set(changedState); + const currentHeaders = options.getInternalHeaders(); + const changedHeaders: Record = {}; + + for (const header of crossOriginStripHeaders) { + if (hookChangedState.has(header)) { + changedHeaders[header] = currentHeaders[header]; + } else { + options.deleteInternalHeader(header); + crossOriginHookStrippedHeaders.add(header); + } + } + + const changedOptions: OptionsInit = {headers: changedHeaders}; + + if (hookChangedState.has('url')) { + changedOptions.url = options.url; + } + + if (hookChangedState.has('prefixUrl')) { + changedOptions.prefixUrl = options.prefixUrl; + } + + if (hookChangedState.has('username')) { + changedOptions.username = options.username; + } + + if (hookChangedState.has('password')) { + changedOptions.password = options.password; + } + + options.stripSensitiveHeaders(urlBeforeRequestHooks, options.url, changedOptions); + + this._discardBodyWrites = true; + this._hasWrittenBody = false; + this._hasWritableBody = false; + + if ( + !hookChangedState.has('body') + && !hookChangedState.has('json') + && !hookChangedState.has('form') + && isBodyUnchanged(options, stateBeforeRequestHooks!) + ) { + options.clearBody(); + this._bodySize = undefined; + } + } + + if (request === undefined) { + const currentHeaders = options.getInternalHeaders(); + // `headers.authorization = undefined` / `headers.cookie = undefined` is an + // explicit opt-out. Respect that instead of regenerating values from URL + // credentials or the cookie jar later in request setup. + const isHeaderExplicitlyOmitted = (header: 'authorization' | 'cookie') => options.isHeaderExplicitlySet(header) + && (Object.hasOwn(currentHeaders, header) || changedState.has(header)) + && is.undefined(currentHeaders[header]); + const currentAuthorizationHeader = currentHeaders.authorization; + const currentCookieHeader = currentHeaders.cookie; + // Authorization follows a small contract: + // - A concrete Authorization header is sent as-is. + // - `authorization = undefined` means omit Authorization entirely, including URL auth. + // - Deleting an Authorization header that started explicit also means omit it. + // - Otherwise, if the request did not start with explicit Authorization, Got may + // generate Basic auth from the current username/password. + const authorizationWasExplicitlyOmitted = isHeaderExplicitlyOmitted('authorization') + || ( + authorizationWasInitiallyExplicit + && !crossOriginHookStrippedHeaders.has('authorization') + && is.undefined(currentAuthorizationHeader) + ); + const cookieWasExplicitlyOmitted = is.undefined(currentCookieHeader) + && (cookieWasInitiallyOmitted || isHeaderExplicitlyOmitted('cookie')); + + sanitizeHeaders(); + + if (!is.undefined(currentHeaders['transfer-encoding']) && !is.undefined(currentHeaders['content-length'])) { + options.deleteInternalHeader('content-length'); + } + + if (authorizationWasExplicitlyOmitted) { + shouldOmitRequestUrlCredentials = true; + options.deleteInternalHeader('authorization'); + + if (changedState.has('authorization') && is.undefined(explicitAuthorizationHeader) && !authorizationWasInitiallyOmitted) { + delete options.headers.authorization; + } + } + + const authorizationHeader = !authorizationWasInitiallyExplicit + && !authorizationWasInitiallyOmitted + && !authorizationWasExplicitlyOmitted + ? getAuthorizationHeader(options.username, options.password, authorizationWasExplicitlyOmitted) + : undefined; + const cookieJar = options.cookieJar as PromiseCookieJar | undefined; + if (changedState.has('authorization') && !is.undefined(currentAuthorizationHeader)) { + // A beforeRequest hook intentionally set the outgoing Authorization header. + } else { + const restorableAuthorizationHeader = crossOriginHookStrippedHeaders.has('authorization') || (changedState.has('authorization') && is.undefined(currentAuthorizationHeader)) + ? undefined + : explicitAuthorizationHeader; + syncGeneratedHeader('authorization', { + currentHeader: currentAuthorizationHeader, + explicitHeader: restorableAuthorizationHeader, + nextHeader: authorizationHeader, + staleGeneratedHeader: generatedAuthorizationHeader, + }); + } + + if (cookieWasExplicitlyOmitted) { + options.deleteInternalHeader('cookie'); + + if (changedState.has('cookie') && is.undefined(explicitCookieHeader) && !cookieWasInitiallyOmitted) { + delete options.headers.cookie; + } + } else if (changedState.has('cookie')) { + // A beforeRequest hook intentionally set the outgoing Cookie header. + } else { + const cookieHeader = !cookieWasInitiallyOmitted && !cookieWasExplicitlyOmitted + ? await getCookieHeader(cookieJar) + : undefined; + const restorableCookieHeader = crossOriginHookStrippedHeaders.has('cookie') + ? undefined + : explicitCookieHeader; + + syncGeneratedHeader('cookie', { + currentHeader: currentCookieHeader, + explicitHeader: restorableCookieHeader, + nextHeader: cookieHeader, + staleGeneratedHeader: generatedCookieHeader, + }); + } + } + + request ??= options.getRequestFunction(); + + const url = shouldOmitRequestUrlCredentials + ? new URL(stripUrlAuth(options.url!)) + : options.url as URL; + + this._requestOptions = options.createNativeRequestOptions() as NativeRequestOptions; + + if (shouldOmitRequestUrlCredentials) { + this._requestOptions.auth = undefined; + } + + if (options.cache) { + (this._requestOptions as any)._request = request; + (this._requestOptions as any).cache = options.cache; + (this._requestOptions as any).body = options.body; + (this._requestOptions as any).beforeCacheHooks = options.hooks.beforeCache; + (this._requestOptions as any).gotRequest = this; + + try { + this._prepareCache(options.cache as StorageAdapter); + } catch (error: unknown) { + throw new CacheError(normalizeError(error), this); + } + } + + // Cache support + const function_ = options.cache ? this._createCacheableRequest : request; + + try { + // We can't do `await fn(...)`, + // because stream `error` event can be emitted before `Promise.resolve()`. + const requestFunctionStartedAt = Date.now(); + const originalRequestTimeout = options.timeout.request; + let shouldRestoreRequestTimeout = false; + let requestOrResponse = function_!(url, this._requestOptions); + + if (is.promise(requestOrResponse)) { + requestOrResponse = await requestOrResponse; + + if (options.timeout.request !== undefined) { + const remainingRequestTimeout = options.timeout.request - (Date.now() - requestFunctionStartedAt); + options.timeout.request = Math.max(0, remainingRequestTimeout); + shouldRestoreRequestTimeout = true; + } + } + + try { + if (isClientRequest(requestOrResponse!)) { + this._onRequest(requestOrResponse); + } else if (this.writableEnded) { + void this._onResponse(requestOrResponse as IncomingMessageWithTimings); + } else { + this.once('finish', () => { + void this._onResponse(requestOrResponse as IncomingMessageWithTimings); + }); + + this._sendBody(); + } + } finally { + if (shouldRestoreRequestTimeout) { + options.timeout.request = originalRequestTimeout; + } + } + } catch (error) { + if (error instanceof CacheableCacheError) { + throw new CacheError(error, this); + } + + throw error; + } + } + + private async _error(error: RequestError): Promise { + try { + // Skip calling hooks for HTTP errors when throwHttpErrors is false (Promise API only). + // See https://github.com/sindresorhus/got/issues/2103 + if (this.options && (!(error instanceof HTTPError) || this.options.throwHttpErrors)) { + const hooks = this.options.hooks.beforeError; + if (hooks.length > 0) { + for (const hook of hooks) { + // eslint-disable-next-line no-await-in-loop + error = await hook(error) as RequestError; + + // Validate hook return value + if (!(error instanceof Error)) { + throw new TypeError(`The \`beforeError\` hook must return an Error instance. Received ${is.string(error) ? 'string' : String(typeof error)}.`); + } + } + + // Mark this error as processed by hooks so _destroy preserves custom error types. + // Only mark non-RequestError errors, since RequestErrors are already preserved + // by the instanceof check in _destroy (line 642). + if (!(error instanceof RequestError)) { + errorsProcessedByHooks.add(error); + } + } + } + } catch (error_: unknown) { + const normalizedError = normalizeError(error_); + error = new RequestError(normalizedError.message, normalizedError, this); + } + + // Publish error event + publishError({ + requestId: this._requestId, + url: getSanitizedUrl(this.options), + error, + timings: this.timings, + }); + + this.destroy(error); + + // Manually emit error for Promise API to ensure it receives it. + // Node.js streams may not re-emit if an error was already emitted during retry attempts. + // Only emit for Promise API (_noPipe = true) to avoid double emissions in stream mode. + // Use process.nextTick to defer emission and allow destroy() to complete first. + // See https://github.com/sindresorhus/got/issues/1995 + if (this._noPipe) { + process.nextTick(() => { + this.emit('error', error); + }); + } + } + + private _writeRequest(chunk: any, encoding: BufferEncoding | undefined, callback: (error?: Error | null) => void, request: ClientRequest | undefined = this._request): void { // eslint-disable-line @typescript-eslint/no-restricted-types + if (!request || request.destroyed) { + // When there's no request (e.g., using cached response from beforeRequest hook), + // we still need to call the callback to allow the stream to finish properly. + callback(); + return; + } + + request.write(chunk, encoding as any, (error?: Error | null) => { // eslint-disable-line @typescript-eslint/no-restricted-types + // The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed. + // The `this._request === request` check prevents stale write callbacks from a pre-redirect request from incrementing `_uploadedSize` after it's been reset. + if (!error && !request.destroyed && this._request === request) { + // For strings, encode them first to measure the actual bytes that will be sent + const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk; + this._uploadedSize += byteLength(bytes); + + const progress = this.uploadProgress; + + if (progress.percent < 1) { + this.emit('uploadProgress', progress); + } + } + + callback(error); + }); + } + + /** + The remote IP address. + */ + get ip(): string | undefined { + return this.socket?.remoteAddress; + } + + /** + Indicates whether the request has been aborted or not. + */ + get isAborted(): boolean { + return this._aborted; + } + + get socket(): Socket | undefined { + return this._request?.socket ?? undefined; + } + + /** + Progress event for downloading (receiving a response). + */ + get downloadProgress(): Progress { + return makeProgress(this._downloadedSize, this._responseSize); + } + + /** + Progress event for uploading (sending a request). + */ + get uploadProgress(): Progress { + return makeProgress(this._uploadedSize, this._bodySize); + } + + /** + The object contains the following properties: + + - `start` - Time when the request started. + - `socket` - Time when a socket was assigned to the request. + - `lookup` - Time when the DNS lookup finished. + - `connect` - Time when the socket successfully connected. + - `secureConnect` - Time when the socket securely connected. + - `upload` - Time when the request finished uploading. + - `response` - Time when the request fired `response` event. + - `end` - Time when the response fired `end` event. + - `error` - Time when the request fired `error` event. + - `abort` - Time when the request fired `abort` event. + - `phases` + - `wait` - `timings.socket - timings.start` + - `dns` - `timings.lookup - timings.socket` + - `tcp` - `timings.connect - timings.lookup` + - `tls` - `timings.secureConnect - timings.connect` + - `request` - `timings.upload - (timings.secureConnect || timings.connect)` + - `firstByte` - `timings.response - timings.upload` + - `download` - `timings.end - timings.response` + - `total` - `(timings.end || timings.error || timings.abort) - timings.start` + + If something has not been measured yet, it will be `undefined`. + + __Note__: The time is a `number` representing the milliseconds elapsed since the UNIX epoch. + */ + get timings(): Timings | undefined { + return (this._request as ClientRequestWithTimings)?.timings; + } + + /** + Whether the response was retrieved from the cache. + */ + get isFromCache(): boolean | undefined { + return this.response?.isFromCache; + } + + get reusedSocket(): boolean | undefined { + return this._request?.reusedSocket; + } + + /** + Whether the stream is read-only. Returns `true` when `body`, `json`, or `form` options are provided. + */ + get isReadonly(): boolean { + return !is.undefined(this.options?.body) || !is.undefined(this.options?.json) || !is.undefined(this.options?.form); + } +} diff --git a/test/retry.ts b/test/retry.ts new file mode 100644 index 0000000..0e50659 --- /dev/null +++ b/test/retry.ts @@ -0,0 +1,1374 @@ +import {EventEmitter} from 'node:events'; +import {PassThrough as PassThroughStream} from 'node:stream'; +import type {Socket} from 'node:net'; +import http from 'node:http'; +import https from 'node:https'; +import test from 'ava'; +import is from '@sindresorhus/is'; +import type {Handler} from 'express'; +import getStream from 'get-stream'; +import {pEvent} from 'p-event'; +import got, {HTTPError, TimeoutError} from '../source/index.js'; +import type Request from '../source/core/index.js'; +import withServer from './helpers/with-server.js'; + +const retryAfterOn413 = 2; +const socketTimeout = 300; + +const handler413: Handler = (_request, response) => { + response.writeHead(413, { + 'Retry-After': retryAfterOn413, + }); + response.end(); +}; + +const createSocketTimeoutStream = (url: string): http.ClientRequest => { + if (url.includes('https:')) { + return https.request(url, { + timeout: 1, + }); + } + + return http.request(url, { + timeout: socketTimeout, + }); +}; + +test('works on timeout', withServer, async (t, server, got) => { + let knocks = 0; + server.get('/', (_request, response) => { + response.end('who`s there?'); + }); + + t.is((await got({ + timeout: { + socket: socketTimeout, + }, + request(...arguments_: [ + string | URL | http.RequestOptions, + (http.RequestOptions | ((response: http.IncomingMessage) => void))?, + ((response: http.IncomingMessage) => void)?, + ]) { + if (knocks === 1) { + // @ts-expect-error Overload error + return http.request(...arguments_); + } + + knocks++; + return createSocketTimeoutStream(server.url); + }, + })).body, 'who`s there?'); +}); + +test('retry function gets iteration count', withServer, async (t, server, got) => { + let knocks = 0; + server.get('/', (_request, response) => { + if (knocks++ === 1) { + response.end('who`s there?'); + return; + } + + response.statusCode = 500; + response.end(); + }); + + await got({ + retry: { + calculateDelay({attemptCount}) { + t.true(is.number(attemptCount)); + return attemptCount < 2 ? 1 : 0; + }, + }, + }); +}); + +test('setting to `0` disables retrying', async t => { + let capturedAttemptCount: number | undefined; + + await t.throwsAsync(got('https://example.com', { + timeout: {socket: socketTimeout}, + retry: { + calculateDelay({attemptCount}) { + capturedAttemptCount = attemptCount; + return 0; + }, + }, + request: () => createSocketTimeoutStream('https://example.com'), + }), { + instanceOf: TimeoutError, + message: `Timeout awaiting 'socket' for ${socketTimeout}ms`, + }); + + t.is(capturedAttemptCount, 1); +}); + +test('custom retries', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.statusCode = 500; + response.end(); + }); + + let hasTried = false; + const error = await t.throwsAsync(got({ + throwHttpErrors: true, + retry: { + calculateDelay({attemptCount}) { + if (attemptCount === 1) { + hasTried = true; + return 1; + } + + return 0; + }, + methods: [ + 'GET', + ], + statusCodes: [ + 500, + ], + }, + })); + t.is(error?.response.statusCode, 500); + t.true(hasTried); +}); + +test('custom retries async', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.statusCode = 500; + response.end(); + }); + + let hasTried = false; + const error = await t.throwsAsync(got({ + throwHttpErrors: true, + retry: { + async calculateDelay({attemptCount}) { + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + + if (attemptCount === 1) { + hasTried = true; + return 1; + } + + return 0; + }, + methods: [ + 'GET', + ], + statusCodes: [ + 500, + ], + }, + })); + t.is(error?.response.statusCode, 500); + t.true(hasTried); +}); + +test('custom error codes', async t => { + const errorCode = 'OH_SNAP'; + let capturedErrorCode: string | undefined; + + const error = await t.throwsAsync(got('https://example.com', { + request() { + const emitter = new EventEmitter() as http.ClientRequest; + (emitter as any).end = () => {}; + (emitter as any).destroy = () => {}; + (emitter as any).writable = true; + (emitter as any).writableEnded = false; + + const error = new Error('Snap!'); + (error as Error & {code: typeof errorCode}).code = errorCode; + setTimeout(() => { + emitter.emit('error', error); + }); + + return emitter; + }, + retry: { + calculateDelay({error}) { + capturedErrorCode = error.code; + return 0; + }, + methods: [ + 'GET', + ], + errorCodes: [ + errorCode, + ], + }, + })); + + t.is(capturedErrorCode, errorCode); + t.is(error?.code, errorCode); +}); + +test('respects 413 Retry-After', withServer, async (t, server, got) => { + let lastTried413access = Date.now(); + server.get('/', (_request, response) => { + response.writeHead(413, { + 'Retry-After': retryAfterOn413, + }); + response.end((Date.now() - lastTried413access).toString()); + + lastTried413access = Date.now(); + }); + + const {statusCode, body} = await got({ + throwHttpErrors: false, + retry: { + limit: 1, + }, + }); + t.is(statusCode, 413); + t.true(Number(body) >= retryAfterOn413 * 1000); +}); + +test('respects 413 Retry-After with RFC-1123 timestamp', withServer, async (t, server, got) => { + let lastTried413TimestampAccess: string; + server.get('/', (_request, response) => { + const date = (new Date(Date.now() + (retryAfterOn413 * 1000))).toUTCString(); + + response.writeHead(413, { + 'Retry-After': date, + }); + response.end(lastTried413TimestampAccess); + lastTried413TimestampAccess = date; + }); + + const {statusCode, body} = await got({ + throwHttpErrors: false, + retry: { + limit: 1, + }, + }); + t.is(statusCode, 413); + t.true(Date.now() >= Date.parse(body)); +}); + +test('doesn\'t retry on 413 with empty statusCodes and methods', withServer, async (t, server, got) => { + server.get('/', handler413); + + const {statusCode, retryCount} = await got({ + throwHttpErrors: false, + retry: { + limit: 1, + statusCodes: [], + methods: [], + }, + }); + t.is(statusCode, 413); + t.is(retryCount, 0); +}); + +test('doesn\'t retry on 413 with empty methods', withServer, async (t, server, got) => { + server.get('/', handler413); + + const {statusCode, retryCount} = await got({ + throwHttpErrors: false, + retry: { + limit: 1, + statusCodes: [413], + methods: [], + }, + }); + t.is(statusCode, 413); + t.is(retryCount, 0); +}); + +test('doesn\'t retry on 413 without Retry-After header', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.statusCode = 413; + response.end(); + }); + + const {retryCount} = await got({ + throwHttpErrors: false, + }); + t.is(retryCount, 0); +}); + +test('retries on 503 without Retry-After header', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.statusCode = 503; + response.end(); + }); + + const {retryCount} = await got({ + throwHttpErrors: false, + retry: { + limit: 1, + }, + }); + t.is(retryCount, 1); +}); + +test('doesn\'t retry on streams', withServer, async (t, server, got) => { + server.get('/', () => {}); + + // @ts-expect-error Error tests + const stream = got.stream({ + timeout: { + request: 1, + }, + retry: { + calculateDelay() { + t.fail('Retries on streams'); + }, + }, + }); + await t.throwsAsync(pEvent(stream, 'response')); +}); + +test('doesn\'t retry if Retry-After header is greater than maxRetryAfter', withServer, async (t, server, got) => { + server.get('/', handler413); + + const {retryCount} = await got({ + retry: {maxRetryAfter: 1000}, + throwHttpErrors: false, + }); + t.is(retryCount, 0); +}); + +test('doesn\'t retry when set to 0', withServer, async (t, server, got) => { + server.get('/', handler413); + + const {statusCode, retryCount} = await got({ + throwHttpErrors: false, + retry: { + limit: 0, + }, + }); + t.is(statusCode, 413); + t.is(retryCount, 0); +}); + +test('works when defaults.options.retry is a number', withServer, async (t, server, got) => { + server.get('/', handler413); + + const instance = got.extend({ + retry: { + limit: 2, + }, + }); + + const {retryCount} = await instance({ + throwHttpErrors: false, + }); + t.is(retryCount, 2); +}); + +test('retry function can throw', withServer, async (t, server, got) => { + server.get('/', handler413); + + const error = 'Simple error'; + await t.throwsAsync(got({ + retry: { + calculateDelay() { + throw new Error(error); + }, + }, + }), {message: error}); +}); + +test('does not retry on POST', withServer, async (t, server, got) => { + server.post('/', () => {}); + + let retried = false; + + await t.throwsAsync(got.post({ + timeout: { + request: 200, + }, + hooks: { + beforeRetry: [ + () => { + retried = true; + }, + ], + }, + }), {instanceOf: TimeoutError}); + + t.false(retried, 'Retries on POST requests'); +}); + +test('retries QUERY with JSON body by default', withServer, async (t, server, got) => { + const attempts: Array<{method: string; body: string; contentType: string | undefined}> = []; + const payload = { + query: true, + }; + + server.all('/', async (request, response) => { + const body = await getStream(request); + attempts.push({ + method: request.method, + body, + contentType: request.headers['content-type'], + }); + + if (attempts.length === 1) { + response.statusCode = 500; + response.end(); + return; + } + + response.end(body); + }); + + const {body, retryCount} = await got.query({ + json: payload, + retry: { + limit: 1, + }, + }); + + t.is(retryCount, 1); + t.deepEqual(JSON.parse(body), payload); + t.deepEqual(attempts, [ + { + method: 'QUERY', + body: '{"query":true}', + contentType: 'application/json', + }, + { + method: 'QUERY', + body: '{"query":true}', + contentType: 'application/json', + }, + ]); +}); + +test('does not break on redirect', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.statusCode = 500; + response.end(); + }); + + let tries = 0; + server.get('/redirect', (_request, response) => { + tries++; + + response.writeHead(302, { + location: '/', + }); + response.end(); + }); + + await t.throwsAsync(got('redirect'), {message: /^Request failed with status code 500 \(Internal Server Error\): GET http:\/\/localhost:\d+\/$/v}); + t.is(tries, 1); +}); + +test('does not destroy the socket on HTTP error', withServer, async (t, server, got) => { + let returnServerError = true; + + server.get('/', (_request, response) => { + if (returnServerError) { + response.statusCode = 500; + returnServerError = false; + } + + response.end(); + }); + + const sockets: Socket[] = []; + + const agent = new http.Agent({ + keepAlive: true, + }); + + await got('', { + agent: { + http: agent, + }, + }).on('request', request => { + sockets.push(request.socket!); + }); + + t.is(sockets.length, 2); + t.is(sockets[0], sockets[1]); + + agent.destroy(); +}); + +test('can retry a Got stream', withServer, async (t, server, got) => { + let returnServerError = true; + + server.get('/', (_request, response) => { + if (returnServerError) { + response.statusCode = 500; + response.end('not ok'); + + returnServerError = false; + return; + } + + response.end('ok'); + }); + + let globalRetryCount = 0; + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + const stream = retryStream ?? got.stream(''); + + globalRetryCount = stream.retryCount; + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + resolve(writeStream); + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const data = await getStream(responseStream); + + t.is(data, 'ok'); + t.is(globalRetryCount, 1); +}); + +test('can retry a Got stream with allowAbsoluteUrls false', withServer, async (t, server, got) => { + let returnServerError = true; + + server.get('/', (_request, response) => { + if (returnServerError) { + response.statusCode = 500; + response.end('not ok'); + + returnServerError = false; + return; + } + + response.end('ok'); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + const stream = retryStream ?? got.stream('', {allowAbsoluteUrls: false}); + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + resolve(writeStream); + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const data = await getStream(responseStream); + + t.is(data, 'ok'); +}); + +test('`allowAbsoluteUrls: false` rejects an absolute URL passed to a Got stream retry', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.statusCode = 500; + response.end('not ok'); + }); + + const error = await new Promise(resolve => { + const stream = got.stream('', {allowAbsoluteUrls: false}); + stream.resume(); + stream.once('error', () => {}); + stream.once('retry', (_retryCount, _error, createRetryStream) => { + const retryStream = createRetryStream({url: `${server.url}/other`}); + retryStream.once('error', resolve); + retryStream.resume(); + }); + }); + + t.is(error.message, 'The `url` option must be relative when `allowAbsoluteUrls` is false and `prefixUrl` is set'); +}); + +test('throws when cannot retry a Got stream', withServer, async (t, server, got) => { + server.get('/', (_request, response) => { + response.statusCode = 500; + response.end('not ok'); + }); + + let globalRetryCount = 0; + + const streamPromise = new Promise((resolve, reject) => { + const function_ = (retryStream?: Request) => { + const stream = retryStream ?? got.stream(''); + + globalRetryCount = stream.retryCount; + + stream.resume(); + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('data', () => { + stream.destroy(new Error('data event has been emitted')); + }); + + stream.once('error', reject); + stream.once('end', resolve); + }; + + function_(); + }); + + const error = await t.throwsAsync(streamPromise, { + instanceOf: HTTPError, + }); + + t.is(error?.response.statusCode, 500); + t.is(error?.response.body, 'not ok'); + t.is(globalRetryCount, 2); +}); + +test('can attach only one retry listener to a stream', withServer, async (t, _server, got) => { + const stream = got.stream(''); + + t.notThrows(() => { + stream.on('retry', () => {}); + }); + + t.throws(() => { + stream.on('retry', () => {}); + }, { + message: 'A retry listener has been attached already.', + }); + + stream.destroy(); +}); + +test('createRetryStream accepts options', withServer, async (t, server, got) => { + let returnServerError = true; + let receivedCustomHeader = false; + + server.get('/', (request, response) => { + if (request.headers['x-custom-header'] === 'custom-value') { + receivedCustomHeader = true; + } + + if (returnServerError) { + response.statusCode = 500; + response.end('not ok'); + returnServerError = false; + return; + } + + response.end('ok'); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + const stream = retryStream ?? got.stream(''); + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + // Pass custom options on retry + function_(createRetryStream({ + headers: { + 'x-custom-header': 'custom-value', + }, + })); + }); + + stream.once('error', reject); + stream.once('end', () => { + resolve(writeStream); + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const data = await getStream(responseStream); + + t.is(data, 'ok'); + t.true(receivedCustomHeader); +}); + +test('createRetryStream re-copies piped headers on retry', withServer, async (t, server, got) => { + let returnServerError = true; + + server.put('/', (request, response) => { + if (returnServerError) { + returnServerError = false; + response.statusCode = 500; + response.end('not ok'); + return; + } + + response.end(JSON.stringify(request.headers)); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + let attempt = 0; + + const function_ = (retryStream?: Request) => { + attempt++; + const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true}); + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record}; + sourceStream.headers = { + 'x-custom-header': attempt === 1 ? 'first-value' : 'second-value', + }; + + sourceStream.pipe(stream); + sourceStream.end('request body'); + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + if (stream.retryCount === 1) { + resolve(writeStream); + } + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const headers = JSON.parse(await getStream(responseStream)) as Record; + + t.is(headers['x-custom-header'], 'second-value'); +}); + +test('createRetryStream preserves explicit header omissions after undefined header pruning', withServer, async (t, server, got) => { + let returnServerError = true; + + server.put('/', (request, response) => { + if (returnServerError) { + returnServerError = false; + response.statusCode = 500; + response.end('not ok'); + return; + } + + response.end(JSON.stringify(request.headers)); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + const stream = retryStream ?? got.stream.put('', { + copyPipedHeaders: true, + headers: { + authorization: undefined, + }, + }); + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record}; + sourceStream.headers = { + authorization: 'Bearer piped-token', + }; + + sourceStream.pipe(stream); + sourceStream.end('request body'); + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + if (stream.retryCount === 1) { + resolve(writeStream); + } + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const headers = JSON.parse(await getStream(responseStream)) as Record; + + t.is(headers.authorization, undefined); +}); + +test('createRetryStream re-copies piped headers after internal header additions on previous attempt', withServer, async (t, server, got) => { + let returnServerError = true; + let attempt = 0; + + server.put('/', (request, response) => { + if (returnServerError) { + returnServerError = false; + response.statusCode = 500; + response.end('not ok'); + return; + } + + response.end(JSON.stringify(request.headers)); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + attempt++; + const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true}); + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record}; + sourceStream.headers = attempt === 1 + ? {} + : {'accept-encoding': 'identity'}; + + sourceStream.pipe(stream); + sourceStream.end('request body'); + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + if (stream.retryCount === 1) { + resolve(writeStream); + } + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const headers = JSON.parse(await getStream(responseStream)) as Record; + + t.is(headers['accept-encoding'], 'identity'); +}); + +test('createRetryStream preserves header omission from direct header mutation', withServer, async (t, server, got) => { + let returnServerError = true; + + server.put('/', (request, response) => { + if (returnServerError) { + returnServerError = false; + response.statusCode = 500; + response.end('not ok'); + return; + } + + response.end(JSON.stringify(request.headers)); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true}); + stream.options.headers.authorization = undefined; + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record}; + sourceStream.headers = { + authorization: 'Bearer piped-token', + }; + + sourceStream.pipe(stream); + sourceStream.end('request body'); + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + if (stream.retryCount === 1) { + resolve(writeStream); + } + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const headers = JSON.parse(await getStream(responseStream)) as Record; + + t.is(headers.authorization, undefined); +}); + +test('createRetryStream preserves mixed-case header omission from direct header mutation', withServer, async (t, server, got) => { + let returnServerError = true; + + server.put('/', (request, response) => { + if (returnServerError) { + returnServerError = false; + response.statusCode = 500; + response.end('not ok'); + return; + } + + response.end(JSON.stringify(request.headers)); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + const stream = retryStream ?? got.stream.put('', {copyPipedHeaders: true}); + stream.options.headers.Authorization = undefined; + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record}; + sourceStream.headers = { + authorization: 'Bearer piped-token', + }; + + sourceStream.pipe(stream); + sourceStream.end('request body'); + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + if (stream.retryCount === 1) { + resolve(writeStream); + } + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const headers = JSON.parse(await getStream(responseStream)) as Record; + + t.is(headers.authorization, undefined); +}); + +test('createRetryStream keeps username/password authorization precedence over piped authorization', withServer, async (t, server, got) => { + let returnServerError = true; + let attempt = 0; + + server.put('/', (request, response) => { + if (returnServerError) { + returnServerError = false; + response.statusCode = 500; + response.end('not ok'); + return; + } + + response.end(JSON.stringify(request.headers)); + }); + + const responseStreamPromise = new Promise((resolve, reject) => { + let writeStream: PassThroughStream; + + const function_ = (retryStream?: Request) => { + attempt++; + const stream = retryStream ?? got.stream.put('', { + copyPipedHeaders: true, + username: 'foo', + password: 'bar', + }); + + if (writeStream) { + writeStream.destroy(); + } + + writeStream = new PassThroughStream(); + const sourceStream = new PassThroughStream() as PassThroughStream & {headers: Record}; + sourceStream.headers = attempt === 1 + ? {} + : {authorization: 'Bearer retry-token'}; + + sourceStream.pipe(stream); + sourceStream.end('request body'); + stream.pipe(writeStream); + + stream.once('retry', (_retryCount, _error, createRetryStream) => { + function_(createRetryStream()); + }); + + stream.once('error', reject); + stream.once('end', () => { + if (stream.retryCount === 1) { + resolve(writeStream); + } + }); + }; + + function_(); + }); + + const responseStream = await responseStreamPromise; + const headers = JSON.parse(await getStream(responseStream)) as Record; + + t.is(headers.authorization, 'Basic Zm9vOmJhcg=='); +}); + +test('promise does not retry when body is a stream', withServer, async (t, server, got) => { + server.post('/', (_request, response) => { + response.statusCode = 500; + response.end('not ok'); + }); + + const body = new PassThroughStream(); + body.end('hello'); + + const response = await got.post({ + retry: { + methods: ['POST'], + }, + body, + throwHttpErrors: false, + }); + + t.is(response.retryCount, 0); +}); + +test('reuses request options on retry', withServer, async (t, server, got) => { + let first = true; + server.get('/', (request, response) => { + if (first) { + first = false; + return; + } + + response.end(JSON.stringify(request.headers)); + }); + + const {body, retryCount} = await got('', {timeout: {request: 1000}, responseType: 'json'}); + t.is(retryCount, 1); + t.is((body as any).accept, 'application/json'); +}); + +test('respects backoffLimit', withServer, async (t, server, got) => { + let count = 0; + let timestamp = Date.now(); + const data: number[] = []; + + server.get('/', (_request, response) => { + count++; + + const now = Date.now(); + data.push(now - timestamp); + timestamp = now; + + if (count === 3) { + response.end(JSON.stringify(data)); + } else { + response.statusCode = 408; + response.end(); + } + }); + + const body = await got('', { + retry: { + backoffLimit: 0, + }, + }).json(); + + t.is(body.length, 3); + t.true(body[0]! < 400); + t.true(body[1]! < 400); + t.true(body[2]! < 400); +}); + +test('enforceRetryRules respects statusCodes with custom calculateDelay', withServer, async (t, server, got) => { + let requestCount = 0; + server.get('/', (_request, response) => { + requestCount++; + // Return 500 on first request, 429 on second, 200 on third + if (requestCount === 1) { + response.statusCode = 500; + } else if (requestCount === 2) { + response.statusCode = 429; + } + + response.end(); + }); + + const {statusCode, retryCount} = await got({ + throwHttpErrors: false, + retry: { + limit: 5, + statusCodes: [429], // Should only retry on 429 + enforceRetryRules: true, + calculateDelay({attemptCount}) { + // Custom delay but should still respect statusCodes + return attemptCount * 100; + }, + }, + }); + + // Should not retry on 500 (not in statusCodes list) + t.is(statusCode, 500); + t.is(retryCount, 0); +}); + +test('enforces retry rules by default with custom calculateDelay', withServer, async (t, server, got) => { + let requestCount = 0; + server.get('/', (_request, response) => { + requestCount++; + if (requestCount === 1) { + response.statusCode = 500; + } else if (requestCount === 2) { + response.statusCode = 429; + } + + response.end(); + }); + + const {statusCode, retryCount} = await got({ + throwHttpErrors: false, + retry: { + limit: 5, + statusCodes: [429], + calculateDelay({attemptCount}) { + return attemptCount * 100; + }, + }, + }); + + t.is(statusCode, 500); + t.is(retryCount, 0); +}); + +test('enforceRetryRules respects limit with custom calculateDelay', withServer, async (t, server, got) => { + let requestCount = 0; + server.get('/', (_request, response) => { + requestCount++; + response.statusCode = 500; + response.end(); + }); + + const {retryCount} = await got({ + throwHttpErrors: false, + retry: { + limit: 2, + enforceRetryRules: true, + calculateDelay({attemptCount}) { + // With enforceRetryRules, limit is enforced automatically + return attemptCount * 100; + }, + }, + }); + + // Should stop at limit even with custom calculateDelay + t.is(retryCount, 2); + t.is(requestCount, 3); // Initial request + 2 retries +}); + +test('retries on stream errors like EPIPE when configured', async t => { + let attemptCount = 0; + let retryCount = 0; + + const error = await t.throwsAsync(got.post('https://example.com', { + retry: { + limit: 2, + methods: ['POST'], + errorCodes: ['EPIPE'], + }, + hooks: { + beforeRetry: [ + () => { + retryCount++; + }, + ], + }, + request() { + attemptCount++; + + const emitter = new EventEmitter() as http.ClientRequest; + (emitter as any).end = (callback: any) => { + // Simulate EPIPE error from Node.js during write/end + // This mimics what happens when a socket is torn down (e.g., AWS Lambda pause) + const error = new Error('write EPIPE'); + (error as NodeJS.ErrnoException).code = 'EPIPE'; + + if (callback) { + setTimeout(() => { + callback(error); + }, 10); + } + }; + + emitter.destroyed = false; + + (emitter as any).destroy = () => { + emitter.destroyed = true; + }; + + (emitter as any).write = () => true; + + (emitter as any).writable = true; + (emitter as any).writableEnded = false; + + return emitter; + }, + }), {code: 'EPIPE'}); + + // Should retry twice (limit: 2) for a total of 3 attempts + t.is(attemptCount, 3); + t.is(retryCount, 2); + t.is(error?.code, 'EPIPE'); +}); + +test('does not retry on stream errors when not in errorCodes', async t => { + let attemptCount = 0; + + const error = await t.throwsAsync(got('https://example.com', { + retry: { + limit: 2, + errorCodes: [], // Empty list means no errors should be retried + }, + request() { + attemptCount++; + + const emitter = new EventEmitter() as http.ClientRequest; + (emitter as any).end = (callback: any) => { + const error = new Error('write ETEST'); + (error as NodeJS.ErrnoException).code = 'ETEST'; + + if (callback) { + setTimeout(() => { + callback(error); + }, 10); + } + }; + + emitter.destroyed = false; + + (emitter as any).destroy = () => { + emitter.destroyed = true; + }; + + (emitter as any).write = () => true; + + (emitter as any).writable = true; + (emitter as any).writableEnded = false; + + return emitter; + }, + }), {code: 'ETEST'}); + + // Should NOT retry since errorCodes is empty + t.is(attemptCount, 1); + t.is(error?.code, 'ETEST'); +}); + +test('does not retry after promise settles (issue #1489)', async t => { + let retryTriggered = false; + + const response = await got('https://example.com', { + retry: {limit: 2, errorCodes: ['ECONNRESET']}, + hooks: { + beforeRetry: [() => { + retryTriggered = true; + }], + }, + request() { + const emitter = new EventEmitter() as http.ClientRequest; + (emitter as any).end = () => {}; + emitter.destroyed = false; + (emitter as any).destroy = () => { + emitter.destroyed = true; + }; + + (emitter as any).write = () => true; + (emitter as any).writable = true; + (emitter as any).writableEnded = false; + + setTimeout(() => { + const incomingMessage = new PassThroughStream() as unknown as http.IncomingMessage; + incomingMessage.statusCode = 200; + incomingMessage.headers = {}; + + emitter.emit('response', incomingMessage); + + setImmediate(() => { + // @ts-expect-error PassThrough method + incomingMessage.end('ok'); + }); + + // Late error after response - should NOT trigger retry + setTimeout(() => { + const error = new Error('read ECONNRESET'); + (error as NodeJS.ErrnoException).code = 'ECONNRESET'; + emitter.emit('error', error); + }, 10); + }); + + return emitter; + }, + throwHttpErrors: false, + }); + + t.is(response.statusCode, 200); + t.false(retryTriggered); +});