got 14.4.9 → 14.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,12 +18,14 @@ export default function asPromise(firstRequest) {
18
18
  let globalResponse;
19
19
  let normalizedOptions;
20
20
  const emitter = new EventEmitter();
21
+ let promiseSettled = false;
21
22
  const promise = new PCancelable((resolve, reject, onCancel) => {
22
23
  onCancel(() => {
23
24
  globalRequest.destroy();
24
25
  });
25
26
  onCancel.shouldReject = false;
26
27
  onCancel(() => {
28
+ promiseSettled = true;
27
29
  reject(new CancelError(globalRequest));
28
30
  });
29
31
  const makeRequest = (retryCount) => {
@@ -68,6 +70,7 @@ export default function asPromise(firstRequest) {
68
70
  // @ts-expect-error TS doesn't notice that CancelableRequest is a Promise
69
71
  // eslint-disable-next-line no-await-in-loop
70
72
  response = await hook(response, async (updatedOptions) => {
73
+ const preserveHooks = updatedOptions.preserveHooks ?? false;
71
74
  options.merge(updatedOptions);
72
75
  options.prefixUrl = '';
73
76
  if (updatedOptions.url) {
@@ -75,10 +78,13 @@ export default function asPromise(firstRequest) {
75
78
  }
76
79
  // Remove any further hooks for that request, because we'll call them anyway.
77
80
  // The loop continues. We don't want duplicates (asPromise recursion).
78
- options.hooks.afterResponse = options.hooks.afterResponse.slice(0, index);
81
+ // Unless preserveHooks is true, in which case we keep the remaining hooks.
82
+ if (!preserveHooks) {
83
+ options.hooks.afterResponse = options.hooks.afterResponse.slice(0, index);
84
+ }
79
85
  throw new RetryError(request);
80
86
  });
81
- if (!(is.object(response) && is.number(response.statusCode) && !is.nullOrUndefined(response.body))) {
87
+ if (!(is.object(response) && is.number(response.statusCode) && 'body' in response)) {
82
88
  throw new TypeError('The `afterResponse` hook returned an invalid value');
83
89
  }
84
90
  }
@@ -93,12 +99,27 @@ export default function asPromise(firstRequest) {
93
99
  return;
94
100
  }
95
101
  request.destroy();
102
+ promiseSettled = true;
96
103
  resolve(request.options.resolveBodyOnly ? response.body : response);
97
104
  });
105
+ let handledFinalError = false;
98
106
  const onError = (error) => {
99
107
  if (promise.isCanceled) {
100
108
  return;
101
109
  }
110
+ // Route errors emitted directly on the stream (e.g., EPIPE from Node.js)
111
+ // through retry logic first, then handle them here after retries are exhausted.
112
+ // See https://github.com/sindresorhus/got/issues/1995
113
+ if (!request._stopReading) {
114
+ request._beforeError(error);
115
+ return;
116
+ }
117
+ // Allow the manual re-emission from Request to land only once.
118
+ if (handledFinalError) {
119
+ return;
120
+ }
121
+ handledFinalError = true;
122
+ promiseSettled = true;
102
123
  const { options } = request;
103
124
  if (error instanceof HTTPError && !options.throwHttpErrors) {
104
125
  const { response } = error;
@@ -108,10 +129,21 @@ export default function asPromise(firstRequest) {
108
129
  }
109
130
  reject(error);
110
131
  };
111
- request.once('error', onError);
132
+ // Use .on() instead of .once() to keep the listener active across retries.
133
+ // When _stopReading is false, we return early and the error gets re-emitted
134
+ // after retry logic completes, so we need this listener to remain active.
135
+ // See https://github.com/sindresorhus/got/issues/1995
136
+ request.on('error', onError);
112
137
  const previousBody = request.options?.body;
113
138
  request.once('retry', (newRetryCount, error) => {
114
139
  firstRequest = undefined;
140
+ // If promise already settled, don't retry
141
+ // This prevents the race condition in #1489 where a late error
142
+ // (e.g., ECONNRESET after successful response) triggers retry
143
+ // after the promise has already resolved/rejected
144
+ if (promiseSettled) {
145
+ return;
146
+ }
115
147
  const newBody = request.options.body;
116
148
  if (previousBody === newBody && is.nodeStream(newBody)) {
117
149
  error.message = 'Cannot retry with consumed body stream';
@@ -2,7 +2,7 @@ import { Duplex } from 'node:stream';
2
2
  import { type ClientRequest } from 'node:http';
3
3
  import type { Socket } from 'node:net';
4
4
  import { type Timings } from '@szmarczak/http-timer';
5
- import Options from './options.js';
5
+ import Options, { type OptionsInit } from './options.js';
6
6
  import { type PlainResponse, type Response } from './response.js';
7
7
  import { RequestError } from './errors.js';
8
8
  type Error = NodeJS.ErrnoException;
@@ -71,7 +71,7 @@ When this event is emitted, you should reset the stream you were writing to and
71
71
 
72
72
  See `got.options.retry` for more information.
73
73
  */
74
- & ((name: 'retry', listener: (retryCount: number, error: RequestError) => void) => T);
74
+ & ((name: 'retry', listener: (retryCount: number, error: RequestError, createRetryStream: (options?: OptionsInit) => Request) => void) => T);
75
75
  export type RequestEvents<T> = {
76
76
  on: GotEventFunction<T>;
77
77
  once: GotEventFunction<T>;
@@ -88,18 +88,17 @@ export default class Request extends Duplex implements RequestEvents<Request> {
88
88
  requestUrl?: URL;
89
89
  redirectUrls: URL[];
90
90
  retryCount: number;
91
+ _stopReading: boolean;
91
92
  private _requestOptions;
92
93
  private _stopRetry;
93
94
  private _downloadedSize;
94
95
  private _uploadedSize;
95
- private _stopReading;
96
96
  private readonly _pipedServerResponses;
97
97
  private _request?;
98
98
  private _responseSize?;
99
99
  private _bodySize?;
100
100
  private _unproxyEvents;
101
101
  private _isFromCache?;
102
- private _cannotHaveBody;
103
102
  private _triggerRead;
104
103
  private readonly _jobs;
105
104
  private _cancelTimeouts;
@@ -107,6 +106,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
107
106
  private _nativeResponse?;
108
107
  private _flushed;
109
108
  private _aborted;
109
+ private _expectedContentLength?;
110
+ private _byteCounter?;
110
111
  private _requestInitialized;
111
112
  constructor(url: UrlType, options?: OptionsType, defaults?: DefaultsType);
112
113
  flush(): Promise<void>;
@@ -119,6 +120,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
119
120
  end?: boolean;
120
121
  }): T;
121
122
  unpipe<T extends NodeJS.WritableStream>(destination: T): this;
123
+ private _checkContentLengthMismatch;
122
124
  private _finalizeBody;
123
125
  private _onResponseBase;
124
126
  private _setRawBody;
@@ -1,6 +1,6 @@
1
1
  import process from 'node:process';
2
2
  import { Buffer } from 'node:buffer';
3
- import { Duplex } from 'node:stream';
3
+ import { Duplex, Transform } from 'node:stream';
4
4
  import http, { ServerResponse } from 'node:http';
5
5
  import timer from '@szmarczak/http-timer';
6
6
  import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request';
@@ -17,10 +17,12 @@ import calculateRetryDelay from './calculate-retry-delay.js';
17
17
  import Options from './options.js';
18
18
  import { isResponseOk } from './response.js';
19
19
  import isClientRequest from './utils/is-client-request.js';
20
- import isUnixSocketURL from './utils/is-unix-socket-url.js';
20
+ import isUnixSocketURL, { getUnixSocketPath } from './utils/is-unix-socket-url.js';
21
21
  import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
22
22
  const supportsBrotli = is.string(process.versions.brotli);
23
23
  const methodsWithoutBody = new Set(['GET', 'HEAD']);
24
+ // Methods that should auto-end streams when no body is provided
25
+ const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
24
26
  const cacheableStore = new WeakableMap();
25
27
  const redirectCodes = new Set([300, 301, 302, 303, 304, 307, 308]);
26
28
  const proxiedRequestEvents = [
@@ -31,6 +33,17 @@ const proxiedRequestEvents = [
31
33
  'upgrade',
32
34
  ];
33
35
  const noop = () => { };
36
+ /**
37
+ Stream transform that counts bytes passing through.
38
+ Used to track compressed bytes before decompression for content-length validation.
39
+ */
40
+ class ByteCounter extends Transform {
41
+ count = 0;
42
+ _transform(chunk, _encoding, callback) {
43
+ this.count += chunk.length;
44
+ callback(null, chunk);
45
+ }
46
+ }
34
47
  export default class Request extends Duplex {
35
48
  // @ts-expect-error - Ignoring for now.
36
49
  ['constructor'];
@@ -41,23 +54,24 @@ export default class Request extends Duplex {
41
54
  requestUrl;
42
55
  redirectUrls;
43
56
  retryCount;
57
+ _stopReading;
44
58
  _stopRetry;
45
59
  _downloadedSize;
46
60
  _uploadedSize;
47
- _stopReading;
48
61
  _pipedServerResponses;
49
62
  _request;
50
63
  _responseSize;
51
64
  _bodySize;
52
65
  _unproxyEvents;
53
66
  _isFromCache;
54
- _cannotHaveBody;
55
67
  _triggerRead;
56
68
  _cancelTimeouts;
57
69
  _removeListeners;
58
70
  _nativeResponse;
59
71
  _flushed;
60
72
  _aborted;
73
+ _expectedContentLength;
74
+ _byteCounter;
61
75
  // We need this because `this._request` if `undefined` when using cache
62
76
  _requestInitialized;
63
77
  constructor(url, options, defaults) {
@@ -71,7 +85,6 @@ export default class Request extends Duplex {
71
85
  this._uploadedSize = 0;
72
86
  this._stopReading = false;
73
87
  this._pipedServerResponses = new Set();
74
- this._cannotHaveBody = false;
75
88
  this._unproxyEvents = noop;
76
89
  this._triggerRead = false;
77
90
  this._cancelTimeouts = noop;
@@ -110,7 +123,18 @@ export default class Request extends Duplex {
110
123
  }
111
124
  this.flush = async () => {
112
125
  this.flush = async () => { };
113
- this.destroy(error);
126
+ // Defer error emission to next tick to allow user to attach error handlers
127
+ process.nextTick(() => {
128
+ // _beforeError requires options to access retry logic and hooks
129
+ if (this.options) {
130
+ this._beforeError(error);
131
+ }
132
+ else {
133
+ // Options is undefined, skip _beforeError and destroy directly
134
+ const requestError = error instanceof RequestError ? error : new RequestError(error.message, error, this);
135
+ this.destroy(requestError);
136
+ }
137
+ });
114
138
  };
115
139
  return;
116
140
  }
@@ -221,19 +245,29 @@ export default class Request extends Duplex {
221
245
  }
222
246
  }
223
247
  const retryOptions = options.retry;
224
- backoff = await retryOptions.calculateDelay({
248
+ const computedValue = calculateRetryDelay({
225
249
  attemptCount,
226
250
  retryOptions,
227
251
  error: typedError,
228
252
  retryAfter,
229
- computedValue: calculateRetryDelay({
253
+ computedValue: retryOptions.maxRetryAfter ?? options.timeout.request ?? Number.POSITIVE_INFINITY,
254
+ });
255
+ // When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
256
+ // before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
257
+ // based on these rules), skip calling calculateDelay entirely.
258
+ // When false (default), always call calculateDelay, allowing it to override retry decisions.
259
+ if (retryOptions.enforceRetryRules && computedValue === 0) {
260
+ backoff = 0;
261
+ }
262
+ else {
263
+ backoff = await retryOptions.calculateDelay({
230
264
  attemptCount,
231
265
  retryOptions,
232
266
  error: typedError,
233
267
  retryAfter,
234
- computedValue: retryOptions.maxRetryAfter ?? options.timeout.request ?? Number.POSITIVE_INFINITY,
235
- }),
236
- });
268
+ computedValue,
269
+ });
270
+ }
237
271
  }
238
272
  catch (error_) {
239
273
  void this._error(new RequestError(error_.message, error_, this));
@@ -356,6 +390,19 @@ export default class Request extends Duplex {
356
390
  if (this._request) {
357
391
  this._request.destroy();
358
392
  }
393
+ // Workaround: http-timer only sets timings.end when the response emits 'end'.
394
+ // When a stream is destroyed before completion, the 'end' event may not fire,
395
+ // leaving timings.end undefined. This should ideally be fixed in http-timer
396
+ // by listening to the 'close' event, but we handle it here for now.
397
+ // Only set timings.end if there was no error or abort (to maintain semantic correctness).
398
+ const timings = this._request?.timings;
399
+ if (timings && is.undefined(timings.end) && !is.undefined(timings.response) && is.undefined(timings.error) && is.undefined(timings.abort)) {
400
+ timings.end = Date.now();
401
+ if (is.undefined(timings.phases.total)) {
402
+ timings.phases.download = timings.end - timings.response;
403
+ timings.phases.total = timings.end - timings.start;
404
+ }
405
+ }
359
406
  if (error !== null && !is.undefined(error) && !(error instanceof RequestError)) {
360
407
  error = new RequestError(error.message, error, this);
361
408
  }
@@ -374,6 +421,22 @@ export default class Request extends Duplex {
374
421
  super.unpipe(destination);
375
422
  return this;
376
423
  }
424
+ _checkContentLengthMismatch() {
425
+ if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
426
+ // Use ByteCounter's count when available (for compressed responses),
427
+ // otherwise use _downloadedSize (for uncompressed responses)
428
+ const actualSize = this._byteCounter?.count ?? this._downloadedSize;
429
+ if (actualSize !== this._expectedContentLength) {
430
+ this._beforeError(new ReadError({
431
+ message: `Content-Length mismatch: expected ${this._expectedContentLength} bytes, received ${actualSize} bytes`,
432
+ name: 'Error',
433
+ code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH',
434
+ }, this));
435
+ return true;
436
+ }
437
+ }
438
+ return false;
439
+ }
377
440
  async _finalizeBody() {
378
441
  const { options } = this;
379
442
  const { headers } = options;
@@ -382,7 +445,6 @@ export default class Request extends Duplex {
382
445
  const isJSON = !is.undefined(options.json);
383
446
  const isBody = !is.undefined(options.body);
384
447
  const cannotHaveBody = methodsWithoutBody.has(options.method) && !(options.method === 'GET' && options.allowGetBody);
385
- this._cannotHaveBody = cannotHaveBody;
386
448
  if (isForm || isJSON || isBody) {
387
449
  if (cannotHaveBody) {
388
450
  throw new TypeError(`The \`${options.method}\` method cannot be used with a body`);
@@ -463,10 +525,18 @@ export default class Request extends Duplex {
463
525
  || statusCode === 205
464
526
  || statusCode === 304;
465
527
  if (options.decompress && !hasNoBody) {
528
+ // When strictContentLength is enabled, track compressed bytes by listening to
529
+ // the native response's data events before decompression
530
+ if (options.strictContentLength) {
531
+ this._byteCounter = new ByteCounter();
532
+ this._nativeResponse.on('data', (chunk) => {
533
+ this._byteCounter.count += chunk.length;
534
+ });
535
+ }
466
536
  response = decompressResponse(response);
467
537
  }
468
538
  const typedResponse = response;
469
- typedResponse.statusMessage = typedResponse.statusMessage ?? http.STATUS_CODES[statusCode];
539
+ typedResponse.statusMessage = typedResponse.statusMessage || http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
470
540
  typedResponse.url = options.url.toString();
471
541
  typedResponse.requestUrl = this.requestUrl;
472
542
  typedResponse.redirectUrls = this.redirectUrls;
@@ -478,10 +548,18 @@ export default class Request extends Duplex {
478
548
  this._isFromCache = typedResponse.isFromCache;
479
549
  this._responseSize = Number(response.headers['content-length']) || undefined;
480
550
  this.response = typedResponse;
481
- response.once('end', () => {
482
- this._responseSize = this._downloadedSize;
483
- this.emit('downloadProgress', this.downloadProgress);
484
- });
551
+ // Workaround for http-timer bug: when connecting to an IP address (no DNS lookup),
552
+ // http-timer sets lookup = connect instead of lookup = socket, resulting in
553
+ // dns = lookup - socket being a small positive number instead of 0.
554
+ // See https://github.com/sindresorhus/got/issues/2279
555
+ const { timings } = response;
556
+ if (timings?.lookup !== undefined && timings.socket !== undefined && timings.connect !== undefined && timings.lookup === timings.connect && timings.phases.dns !== 0) {
557
+ // Fix the DNS phase to be 0 and set lookup to socket time
558
+ timings.phases.dns = 0;
559
+ timings.lookup = timings.socket;
560
+ // Recalculate TCP time to be the full time from socket to connect
561
+ timings.phases.tcp = timings.connect - timings.socket;
562
+ }
485
563
  response.once('error', (error) => {
486
564
  this._aborted = true;
487
565
  // Force clean-up, because some packages don't do this.
@@ -491,13 +569,15 @@ export default class Request extends Duplex {
491
569
  });
492
570
  response.once('aborted', () => {
493
571
  this._aborted = true;
494
- this._beforeError(new ReadError({
495
- name: 'Error',
496
- message: 'The server aborted pending request',
497
- code: 'ECONNRESET',
498
- }, this));
572
+ // Check if there's a content-length mismatch to provide a more specific error
573
+ if (!this._checkContentLengthMismatch()) {
574
+ this._beforeError(new ReadError({
575
+ name: 'Error',
576
+ message: 'The server aborted pending request',
577
+ code: 'ECONNRESET',
578
+ }, this));
579
+ }
499
580
  });
500
- this.emit('downloadProgress', this.downloadProgress);
501
581
  const rawCookies = response.headers['set-cookie'];
502
582
  if (is.object(options.cookieJar) && rawCookies) {
503
583
  let promises = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString()));
@@ -536,6 +616,8 @@ export default class Request extends Duplex {
536
616
  return;
537
617
  }
538
618
  this._request = undefined;
619
+ // Reset download progress for the new request
620
+ this._downloadedSize = 0;
539
621
  const updatedOptions = new Options(undefined, undefined, this.options);
540
622
  const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD';
541
623
  const canRewrite = statusCode !== 307 && statusCode !== 308;
@@ -556,7 +638,11 @@ export default class Request extends Duplex {
556
638
  return;
557
639
  }
558
640
  // Redirecting to a different site, clear sensitive data.
559
- if (redirectUrl.hostname !== url.hostname || redirectUrl.port !== url.port) {
641
+ // For UNIX sockets, different socket paths are also different origins.
642
+ const isDifferentOrigin = redirectUrl.hostname !== url.hostname
643
+ || redirectUrl.port !== url.port
644
+ || getUnixSocketPath(url) !== getUnixSocketPath(redirectUrl);
645
+ if (isDifferentOrigin) {
560
646
  if ('host' in updatedOptions.headers) {
561
647
  delete updatedOptions.headers.host;
562
648
  }
@@ -601,6 +687,33 @@ export default class Request extends Duplex {
601
687
  this._beforeError(new HTTPError(typedResponse));
602
688
  return;
603
689
  }
690
+ // Store the expected content-length from the native response for validation.
691
+ // This is the content-length before decompression, which is what actually gets transferred.
692
+ // Skip storing for responses that shouldn't have bodies per RFC 9110.
693
+ // When decompression occurs, only store if strictContentLength is enabled.
694
+ const wasDecompressed = response !== this._nativeResponse;
695
+ if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
696
+ const contentLengthHeader = this._nativeResponse.headers['content-length'];
697
+ if (contentLengthHeader !== undefined) {
698
+ const expectedLength = Number(contentLengthHeader);
699
+ if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
700
+ this._expectedContentLength = expectedLength;
701
+ }
702
+ }
703
+ }
704
+ // Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
705
+ response.once('end', () => {
706
+ // Validate content-length if it was provided
707
+ // Per RFC 9112: "If the sender closes the connection before the indicated number
708
+ // of octets are received, the recipient MUST consider the message to be incomplete"
709
+ if (this._checkContentLengthMismatch()) {
710
+ return;
711
+ }
712
+ this._responseSize = this._downloadedSize;
713
+ this.emit('downloadProgress', this.downloadProgress);
714
+ this.push(null);
715
+ });
716
+ this.emit('downloadProgress', this.downloadProgress);
604
717
  response.on('readable', () => {
605
718
  if (this._triggerRead) {
606
719
  this._read();
@@ -612,9 +725,6 @@ export default class Request extends Duplex {
612
725
  this.on('pause', () => {
613
726
  response.pause();
614
727
  });
615
- response.once('end', () => {
616
- this.push(null);
617
- });
618
728
  if (this._noPipe) {
619
729
  const success = await this._setRawBody();
620
730
  if (success) {
@@ -627,12 +737,22 @@ export default class Request extends Duplex {
627
737
  if (destination.headersSent) {
628
738
  continue;
629
739
  }
630
- // eslint-disable-next-line guard-for-in
740
+ // Check if decompression actually occurred by comparing stream objects.
741
+ // decompressResponse wraps the response stream when it decompresses,
742
+ // so response !== this._nativeResponse indicates decompression happened.
743
+ const wasDecompressed = response !== this._nativeResponse;
631
744
  for (const key in response.headers) {
632
- const isAllowed = options.decompress ? key !== 'content-encoding' : true;
633
- const value = response.headers[key];
634
- if (isAllowed) {
635
- destination.setHeader(key, value);
745
+ if (Object.hasOwn(response.headers, key)) {
746
+ const value = response.headers[key];
747
+ // When decompression occurred, skip content-encoding and content-length
748
+ // as they refer to the compressed data, not the decompressed stream.
749
+ if (wasDecompressed && (key === 'content-encoding' || key === 'content-length')) {
750
+ continue;
751
+ }
752
+ // Skip if value is undefined
753
+ if (value !== undefined) {
754
+ destination.setHeader(key, value);
755
+ }
636
756
  }
637
757
  }
638
758
  destination.statusCode = statusCode;
@@ -669,11 +789,19 @@ export default class Request extends Duplex {
669
789
  const { options } = this;
670
790
  const { timeout, url } = options;
671
791
  timer(request);
792
+ this._cancelTimeouts = timedOut(request, timeout, url);
672
793
  if (this.options.http2) {
673
794
  // Unset stream timeout, as the `timeout` option was used only for connection timeout.
674
- request.setTimeout(0);
795
+ // We remove all 'timeout' listeners instead of calling setTimeout(0) because:
796
+ // 1. setTimeout(0) causes a memory leak (see https://github.com/sindresorhus/got/issues/690)
797
+ // 2. With HTTP/2 connection reuse, setTimeout(0) accumulates listeners on the socket
798
+ // 3. removeAllListeners('timeout') properly cleans up without the memory leak
799
+ request.removeAllListeners('timeout');
800
+ // For HTTP/2, wait for socket and remove timeout listeners from it
801
+ request.once('socket', (socket) => {
802
+ socket.removeAllListeners('timeout');
803
+ });
675
804
  }
676
- this._cancelTimeouts = timedOut(request, timeout, url);
677
805
  const responseEventName = options.cache ? 'cacheableResponse' : 'response';
678
806
  request.once(responseEventName, (response) => {
679
807
  void this._onResponse(response);
@@ -709,7 +837,20 @@ export default class Request extends Duplex {
709
837
  if (is.nodeStream(body)) {
710
838
  body.pipe(currentRequest);
711
839
  }
712
- else if (is.generator(body) || is.asyncGenerator(body)) {
840
+ else if (is.buffer(body)) {
841
+ // Buffer should be sent directly without conversion
842
+ this._writeRequest(body, undefined, () => { });
843
+ currentRequest.end();
844
+ }
845
+ else if (is.typedArray(body)) {
846
+ // Typed arrays should be treated like buffers, not iterated over
847
+ // Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
848
+ const typedArray = body;
849
+ const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
850
+ this._writeRequest(uint8View, undefined, () => { });
851
+ currentRequest.end();
852
+ }
853
+ else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
713
854
  (async () => {
714
855
  try {
715
856
  for await (const chunk of body) {
@@ -722,11 +863,16 @@ export default class Request extends Duplex {
722
863
  }
723
864
  })();
724
865
  }
725
- else if (!is.undefined(body)) {
726
- this._writeRequest(body, undefined, () => { });
727
- currentRequest.end();
866
+ else if (is.undefined(body)) {
867
+ // No body to send, end the request
868
+ const cannotHaveBody = methodsWithoutBody.has(this.options.method) && !(this.options.method === 'GET' && this.options.allowGetBody);
869
+ const shouldAutoEndStream = methodsWithoutBodyStream.has(this.options.method);
870
+ if ((this._noPipe ?? false) || cannotHaveBody || currentRequest !== this || shouldAutoEndStream) {
871
+ currentRequest.end();
872
+ }
728
873
  }
729
- else if (this._cannotHaveBody || this._noPipe) {
874
+ else {
875
+ this._writeRequest(body, undefined, () => { });
730
876
  currentRequest.end();
731
877
  }
732
878
  }
@@ -783,9 +929,15 @@ export default class Request extends Duplex {
783
929
  response._readableState.autoDestroy = false;
784
930
  if (request) {
785
931
  const fix = () => {
932
+ // For ResponseLike objects from cache, set complete to true if not already set.
933
+ // For real HTTP responses, copy from the underlying response.
786
934
  if (response.req) {
787
935
  response.complete = response.req.res.complete;
788
936
  }
937
+ else if (response.complete === undefined) {
938
+ // ResponseLike from cache should have complete = true
939
+ response.complete = true;
940
+ }
789
941
  };
790
942
  response.prependOnceListener('end', fix);
791
943
  fix();
@@ -832,7 +984,7 @@ export default class Request extends Duplex {
832
984
  let request;
833
985
  for (const hook of options.hooks.beforeRequest) {
834
986
  // eslint-disable-next-line no-await-in-loop
835
- const result = await hook(options);
987
+ const result = await hook(options, { retryCount: this.retryCount });
836
988
  if (!is.undefined(result)) {
837
989
  // @ts-expect-error Skip the type mismatch to support abstract responses
838
990
  request = () => result;
@@ -846,7 +998,12 @@ export default class Request extends Duplex {
846
998
  this._requestOptions._request = request;
847
999
  this._requestOptions.cache = options.cache;
848
1000
  this._requestOptions.body = options.body;
849
- this._prepareCache(options.cache);
1001
+ try {
1002
+ this._prepareCache(options.cache);
1003
+ }
1004
+ catch (error) {
1005
+ throw new CacheError(error, this);
1006
+ }
850
1007
  }
851
1008
  // Cache support
852
1009
  const function_ = options.cache ? this._createCacheableRequest : request;
@@ -886,12 +1043,12 @@ export default class Request extends Duplex {
886
1043
  }
887
1044
  async _error(error) {
888
1045
  try {
889
- if (error instanceof HTTPError && !this.options.throwHttpErrors) {
1046
+ if (this.options && error instanceof HTTPError && !this.options.throwHttpErrors) {
890
1047
  // This branch can be reached only when using the Promise API
891
1048
  // Skip calling the hooks on purpose.
892
1049
  // See https://github.com/sindresorhus/got/issues/2103
893
1050
  }
894
- else {
1051
+ else if (this.options) {
895
1052
  for (const hook of this.options.hooks.beforeError) {
896
1053
  // eslint-disable-next-line no-await-in-loop
897
1054
  error = await hook(error);
@@ -902,10 +1059,22 @@ export default class Request extends Duplex {
902
1059
  error = new RequestError(error_.message, error_, this);
903
1060
  }
904
1061
  this.destroy(error);
1062
+ // Manually emit error for Promise API to ensure it receives it.
1063
+ // Node.js streams may not re-emit if an error was already emitted during retry attempts.
1064
+ // Only emit for Promise API (_noPipe = true) to avoid double emissions in stream mode.
1065
+ // Use process.nextTick to defer emission and allow destroy() to complete first.
1066
+ // See https://github.com/sindresorhus/got/issues/1995
1067
+ if (this._noPipe) {
1068
+ process.nextTick(() => {
1069
+ this.emit('error', error);
1070
+ });
1071
+ }
905
1072
  }
906
1073
  _writeRequest(chunk, encoding, callback) {
907
1074
  if (!this._request || this._request.destroyed) {
908
- // Probably the `ClientRequest` instance will throw
1075
+ // When there's no request (e.g., using cached response from beforeRequest hook),
1076
+ // we still need to call the callback to allow the stream to finish properly.
1077
+ callback();
909
1078
  return;
910
1079
  }
911
1080
  this._request.write(chunk, encoding, (error) => {