@naturalcycles/js-lib 15.81.0 → 15.83.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.
@@ -4,7 +4,7 @@
4
4
  import type { ReadableStream as WebReadableStream } from 'node:stream/web';
5
5
  import { HttpRequestError } from '../error/error.util.js';
6
6
  import type { ErrorDataTuple } from '../types.js';
7
- import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherGraphQLOptions, FetcherNormalizedCfg, FetcherOnErrorHook, FetcherOptions, FetcherResponse, FetchFunction, RequestInitNormalized } from './fetcher.model.js';
7
+ import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherGraphQLOptions, FetcherInitHook, FetcherNormalizedCfg, FetcherOnErrorHook, FetcherOptions, FetcherResponse, FetcherSuccessResponse, FetchFunction, RequestInitNormalized } from './fetcher.model.js';
8
8
  /**
9
9
  * Experimental wrapper around Fetch.
10
10
  * Works in both Browser and Node, using `globalThis.fetch`.
@@ -16,7 +16,7 @@ export declare class Fetcher {
16
16
  *
17
17
  * Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
18
18
  */
19
- static readonly VERSION = 4;
19
+ static readonly VERSION = 5;
20
20
  /**
21
21
  * userAgent is statically exposed as Fetcher.userAgent.
22
22
  * It can be modified globally, and will be used (read) at the start of every request.
@@ -30,7 +30,20 @@ export declare class Fetcher {
30
30
  onAfterResponse(hook: FetcherAfterResponseHook): this;
31
31
  onBeforeRetry(hook: FetcherBeforeRetryHook): this;
32
32
  onError(hook: FetcherOnErrorHook): this;
33
+ /**
34
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
35
+ * See FetcherInitHook docs.
36
+ */
37
+ onInit(hook: FetcherInitHook): this;
38
+ /**
39
+ * Clears the cached result of the init hooks, so they re-run before the next request.
40
+ * Useful when the state acquired during init (e.g an auth token) becomes stale.
41
+ * See also `cfg.hooks.shouldReinit`, which calls this automatically.
42
+ */
43
+ resetInit(): void;
33
44
  cfg: FetcherNormalizedCfg;
45
+ private initPromise?;
46
+ private initGeneration;
34
47
  static create(cfg?: FetcherCfg & FetcherOptions): Fetcher;
35
48
  get: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
36
49
  post: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
@@ -56,10 +69,12 @@ export declare class Fetcher {
56
69
  * - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
57
70
  *
58
71
  * Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
59
- *
60
- * @experimental
61
72
  */
62
73
  queryGraphQL<T = unknown>(opt: FetcherGraphQLOptions): Promise<T>;
74
+ /**
75
+ * Returns response body as Uint8Array.
76
+ */
77
+ getBytes(url: string, opt?: FetcherOptions): Promise<Uint8Array>;
63
78
  /**
64
79
  * Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
65
80
  *
@@ -68,6 +83,16 @@ export declare class Fetcher {
68
83
  */
69
84
  getReadableStream(url: string, opt?: FetcherOptions): Promise<WebReadableStream<Uint8Array>>;
70
85
  fetch<T = unknown>(opt: FetcherOptions): Promise<T>;
86
+ /**
87
+ * Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
88
+ * Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
89
+ * while still throwing on errors (unlike `doFetch`).
90
+ *
91
+ * Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
92
+ * otherwise the returned FetcherSuccessResponse type would lie.
93
+ * Use `doFetch` if you don't want throwing.
94
+ */
95
+ fetchWithMeta<T = unknown>(opt: FetcherOptions): Promise<FetcherSuccessResponse<T>>;
71
96
  /**
72
97
  * Execute fetch and expect/assert it to return an Error (which will be wrapped in
73
98
  * HttpRequestError as it normally would).
@@ -86,11 +111,19 @@ export declare class Fetcher {
86
111
  /**
87
112
  * Returns FetcherResponse.
88
113
  * Never throws, returns `err` property in the response instead.
114
+ * (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
89
115
  * Use this method instead of `throwHttpErrors: false` or try-catching.
90
116
  *
91
- * Note: responseType defaults to `void`, so, override it if you expect different.
117
+ * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
92
118
  */
93
119
  doFetch<T = unknown>(opt: FetcherOptions): Promise<FetcherResponse<T>>;
120
+ /**
121
+ * Consults cfg.hooks.shouldReinit to detect "stale init" (e.g expired auth token).
122
+ * If detected - resets the init (so it re-runs) and returns true,
123
+ * telling the caller to retry the request. At most once per request.
124
+ */
125
+ private detectStaleInit;
126
+ private runInitHooks;
94
127
  private onOkResponse;
95
128
  /**
96
129
  * This method exists to be able to easily mock it.
@@ -99,6 +132,11 @@ export declare class Fetcher {
99
132
  static callNativeFetch(url: string, init: RequestInitNormalized, fetchFn?: FetchFunction): Promise<Response>;
100
133
  private onNotOkResponse;
101
134
  private processRetry;
135
+ /**
136
+ * Returns the delay before the next retry attempt.
137
+ * Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
138
+ * meaning the retry should not be attempted at all.
139
+ */
102
140
  private getRetryTimeout;
103
141
  /**
104
142
  * Default is yes,
@@ -8,7 +8,7 @@ import { _assertErrorClassOrRethrow, _assertIsError } from '../error/assert.js';
8
8
  import { _anyToError, _anyToErrorObject, _errorDataAppend, _errorLikeToErrorObject, HttpRequestError, UnexpectedPassError, } from '../error/error.util.js';
9
9
  import { _clamp } from '../number/number.util.js';
10
10
  import { _filterFalsyValues, _filterNullishValues, _filterUndefinedValues, _mapKeys, _merge, _omit, _pick, } from '../object/object.util.js';
11
- import { pDelay } from '../promise/pDelay.js';
11
+ import { pDelaySignal } from '../promise/pDelay.js';
12
12
  import { pTimeout } from '../promise/pTimeout.js';
13
13
  import { _toUrlOrNull } from '../string/index.js';
14
14
  import { _jsonParse, _jsonParseIfPossible } from '../string/json.util.js';
@@ -25,7 +25,7 @@ export class Fetcher {
25
25
  *
26
26
  * Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
27
27
  */
28
- static VERSION = 4;
28
+ static VERSION = 5;
29
29
  /**
30
30
  * userAgent is statically exposed as Fetcher.userAgent.
31
31
  * It can be modified globally, and will be used (read) at the start of every request.
@@ -47,9 +47,10 @@ export class Fetcher {
47
47
  ...opt,
48
48
  });
49
49
  };
50
- if (method === 'HEAD')
51
- return // responseType=text
52
- ;
50
+ if (method === 'HEAD') {
51
+ continue; // responseType=text
52
+ }
53
+ ;
53
54
  this[`${m}Text`] = async (url, opt) => {
54
55
  return await this.fetch({
55
56
  url,
@@ -91,7 +92,27 @@ export class Fetcher {
91
92
  (this.cfg.hooks.onError ||= []).push(hook);
92
93
  return this;
93
94
  }
95
+ /**
96
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
97
+ * See FetcherInitHook docs.
98
+ */
99
+ onInit(hook) {
100
+ ;
101
+ (this.cfg.hooks.init ||= []).push(hook);
102
+ return this;
103
+ }
104
+ /**
105
+ * Clears the cached result of the init hooks, so they re-run before the next request.
106
+ * Useful when the state acquired during init (e.g an auth token) becomes stale.
107
+ * See also `cfg.hooks.shouldReinit`, which calls this automatically.
108
+ */
109
+ resetInit() {
110
+ this.initPromise = undefined;
111
+ this.initGeneration++;
112
+ }
94
113
  cfg;
114
+ initPromise;
115
+ initGeneration = 0;
95
116
  static create(cfg = {}) {
96
117
  return new Fetcher(cfg);
97
118
  }
@@ -123,10 +144,9 @@ export class Fetcher {
123
144
  * - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
124
145
  *
125
146
  * Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
126
- *
127
- * @experimental
128
147
  */
129
148
  async queryGraphQL(opt) {
149
+ opt = { ...opt }; // avoid mutating the input options
130
150
  opt.method ||= this.cfg.init.method; // defaults to GET
131
151
  const payload = _filterFalsyValues({
132
152
  query: opt.query,
@@ -173,6 +193,17 @@ export class Fetcher {
173
193
  }
174
194
  return data;
175
195
  }
196
+ // responseType=bytes
197
+ /**
198
+ * Returns response body as Uint8Array.
199
+ */
200
+ async getBytes(url, opt) {
201
+ return await this.fetch({
202
+ url,
203
+ responseType: 'bytes',
204
+ ...opt,
205
+ });
206
+ }
176
207
  // responseType=readableStream
177
208
  /**
178
209
  * Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
@@ -189,11 +220,29 @@ export class Fetcher {
189
220
  }
190
221
  async fetch(opt) {
191
222
  const res = await this.doFetch(opt);
192
- if (res.err) {
223
+ if (res.err && (res.req.throwHttpErrors || res.fetchResponse?.ok !== false)) {
224
+ // With throwHttpErrors=false only http errors (response received, but !ok) are returned instead of thrown.
225
+ // Other errors (network failure, timeout, body parsing) are still thrown.
193
226
  throw res.err;
194
227
  }
195
228
  return res.body;
196
229
  }
230
+ /**
231
+ * Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
232
+ * Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
233
+ * while still throwing on errors (unlike `doFetch`).
234
+ *
235
+ * Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
236
+ * otherwise the returned FetcherSuccessResponse type would lie.
237
+ * Use `doFetch` if you don't want throwing.
238
+ */
239
+ async fetchWithMeta(opt) {
240
+ const res = await this.doFetch(opt);
241
+ if (res.err) {
242
+ throw res.err;
243
+ }
244
+ return res;
245
+ }
197
246
  /**
198
247
  * Execute fetch and expect/assert it to return an Error (which will be wrapped in
199
248
  * HttpRequestError as it normally would).
@@ -226,11 +275,24 @@ export class Fetcher {
226
275
  /**
227
276
  * Returns FetcherResponse.
228
277
  * Never throws, returns `err` property in the response instead.
278
+ * (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
229
279
  * Use this method instead of `throwHttpErrors: false` or try-catching.
230
280
  *
231
- * Note: responseType defaults to `void`, so, override it if you expect different.
281
+ * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
232
282
  */
233
283
  async doFetch(opt) {
284
+ let initGeneration = 0;
285
+ if (this.cfg.hooks.init) {
286
+ try {
287
+ await (this.initPromise ??= this.runInitHooks());
288
+ }
289
+ catch (err) {
290
+ // Reset, so init hooks are re-attempted on the next request
291
+ this.resetInit();
292
+ throw err;
293
+ }
294
+ initGeneration = this.initGeneration;
295
+ }
234
296
  const req = this.normalizeOptions(opt);
235
297
  const { logger } = this.cfg;
236
298
  const { init: { method }, } = req;
@@ -253,6 +315,7 @@ export class Fetcher {
253
315
  };
254
316
  while (!res.retryStatus.retryStopped) {
255
317
  req.started = Date.now();
318
+ res.body = undefined;
256
319
  req.init.signal = abortSignalAnyOrUndefined([
257
320
  abortSignalTimeoutOrUndefined(timeoutMillis),
258
321
  opt.signal,
@@ -267,7 +330,7 @@ export class Fetcher {
267
330
  }
268
331
  }
269
332
  try {
270
- res.fetchResponse = await (this.cfg.overrideFetchFn || Fetcher.callNativeFetch)(req.fullUrl, req.init, this.cfg.fetchFn);
333
+ res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(req.fullUrl, req.init, req.fetchFn);
271
334
  res.ok = res.fetchResponse.ok;
272
335
  // important to set it to undefined, otherwise it can keep the previous value (from previous try)
273
336
  res.err = undefined;
@@ -283,11 +346,12 @@ export class Fetcher {
283
346
  }
284
347
  res.statusFamily = this.getStatusFamily(res);
285
348
  res.statusCode = res.fetchResponse?.status;
286
- if (res.fetchResponse?.ok || !req.throwHttpErrors) {
349
+ if (res.fetchResponse?.ok) {
287
350
  try {
288
351
  // We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
289
352
  await pTimeout(async () => await this.onOkResponse(res), {
290
- timeout: timeoutMillis || Number.POSITIVE_INFINITY,
353
+ // 0 means "no timeout" for pTimeout
354
+ timeout: timeoutMillis ?? 0,
291
355
  name: 'Fetcher.downloadBody',
292
356
  });
293
357
  }
@@ -316,8 +380,36 @@ export class Fetcher {
316
380
  for (const hook of this.cfg.hooks.afterResponse || []) {
317
381
  await hook(res);
318
382
  }
383
+ if (res.err && (await this.detectStaleInit(opt, res, initGeneration))) {
384
+ // Stale init (e.g expired auth token) was detected and reset - retry the request once
385
+ return await this.doFetch({ ...opt, [reinitAttempted]: true });
386
+ }
319
387
  return res;
320
388
  }
389
+ /**
390
+ * Consults cfg.hooks.shouldReinit to detect "stale init" (e.g expired auth token).
391
+ * If detected - resets the init (so it re-runs) and returns true,
392
+ * telling the caller to retry the request. At most once per request.
393
+ */
394
+ async detectStaleInit(opt, res, initGeneration) {
395
+ const { shouldReinit, init } = this.cfg.hooks;
396
+ if (!shouldReinit || !init)
397
+ return false;
398
+ if (opt[reinitAttempted])
399
+ return false;
400
+ if (!(await shouldReinit(res)))
401
+ return false;
402
+ // Generation check prevents concurrent stale requests from resetting the already-fresh init
403
+ if (initGeneration === this.initGeneration) {
404
+ this.resetInit();
405
+ }
406
+ return true;
407
+ }
408
+ async runInitHooks() {
409
+ for (const hook of this.cfg.hooks.init || []) {
410
+ await hook(this.cfg);
411
+ }
412
+ }
321
413
  async onOkResponse(res) {
322
414
  const { req } = res;
323
415
  const { responseType } = res.req;
@@ -345,10 +437,20 @@ export class Fetcher {
345
437
  res.body = res.fetchResponse.body ? await res.fetchResponse.text() : '';
346
438
  }
347
439
  else if (responseType === 'arrayBuffer') {
348
- res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : {};
440
+ res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : new ArrayBuffer(0);
441
+ }
442
+ else if (responseType === 'bytes') {
443
+ // Not using fetchResponse.bytes(), as it's unavailable in older browsers (e.g Safari <18.4)
444
+ res.body = res.fetchResponse.body
445
+ ? new Uint8Array(await res.fetchResponse.arrayBuffer())
446
+ : new Uint8Array();
349
447
  }
350
448
  else if (responseType === 'blob') {
351
- res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : {};
449
+ res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : new Blob();
450
+ }
451
+ else if (responseType === 'void') {
452
+ // Cancel the body (without downloading it), to free the underlying connection for reuse
453
+ await res.fetchResponse.body?.cancel();
352
454
  }
353
455
  else if (responseType === 'readableStream') {
354
456
  res.body = res.fetchResponse.body;
@@ -358,8 +460,7 @@ export class Fetcher {
358
460
  }
359
461
  }
360
462
  res.retryStatus.retryStopped = true;
361
- // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
362
- if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
463
+ if (req.logResponse) {
363
464
  const { retryAttempt } = res.retryStatus;
364
465
  const { logger } = this.cfg;
365
466
  logger.log([
@@ -474,13 +575,28 @@ export class Fetcher {
474
575
  if (retryStatus.retryStopped)
475
576
  return;
476
577
  retryStatus.retryAttempt++;
477
- retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
478
578
  const timeout = this.getRetryTimeout(res);
579
+ if (timeout === null) {
580
+ this.cfg.logger.warn(`${res.signature} server-indicated delay exceeds maxRetryAfter, will not retry`);
581
+ retryStatus.retryStopped = true;
582
+ return;
583
+ }
584
+ // Increase the timeout for the next possible retry
585
+ retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
479
586
  if (res.req.debug) {
480
587
  this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`);
481
588
  }
482
- await pDelay(timeout);
589
+ await pDelaySignal(timeout, res.req.signal);
590
+ if (res.req.signal?.aborted) {
591
+ // Aborted while waiting for the retry - stop, without issuing another request
592
+ retryStatus.retryStopped = true;
593
+ }
483
594
  }
595
+ /**
596
+ * Returns the delay before the next retry attempt.
597
+ * Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
598
+ * meaning the retry should not be attempted at all.
599
+ */
484
600
  getRetryTimeout(res) {
485
601
  let timeout = 0;
486
602
  // Handling http 429 with specific retry headers
@@ -489,13 +605,21 @@ export class Fetcher {
489
605
  const retryAfterStr = res.fetchResponse.headers.get('retry-after') ??
490
606
  res.fetchResponse.headers.get('x-ratelimit-reset');
491
607
  if (retryAfterStr) {
492
- if (Number(retryAfterStr)) {
493
- timeout = Number(retryAfterStr) * 1000;
608
+ const retryAfterNumber = Number(retryAfterStr);
609
+ if (retryAfterNumber) {
610
+ if (retryAfterNumber > 10 ** 9) {
611
+ // Value is too large to be a "seconds from now" delay,
612
+ // treat it as a UnixTimestamp instead (e.g GitHub sends `x-ratelimit-reset` like that)
613
+ timeout = retryAfterNumber * 1000 - Date.now();
614
+ }
615
+ else {
616
+ timeout = retryAfterNumber * 1000;
617
+ }
494
618
  }
495
619
  else {
496
620
  const date = new Date(retryAfterStr);
497
- if (!Number.isNaN(date)) {
498
- timeout = Number(date) - Date.now();
621
+ if (!Number.isNaN(date.getTime())) {
622
+ timeout = date.getTime() - Date.now();
499
623
  }
500
624
  }
501
625
  this.cfg.logger.log(`retry-after: ${retryAfterStr}`);
@@ -504,11 +628,15 @@ export class Fetcher {
504
628
  }
505
629
  }
506
630
  }
507
- if (!timeout) {
508
- const noise = Math.random() * 500;
509
- timeout = res.retryStatus.retryTimeout + noise;
631
+ if (timeout) {
632
+ if (timeout > res.req.retry.maxRetryAfter) {
633
+ return null;
634
+ }
635
+ // Server-indicated delay is honored as-is (a negative value means "retry now")
636
+ return Math.max(0, timeout);
510
637
  }
511
- return timeout;
638
+ const noise = Math.random() * 500;
639
+ return res.retryStatus.retryTimeout + noise;
512
640
  }
513
641
  /**
514
642
  * Default is yes,
@@ -635,6 +763,7 @@ export class Fetcher {
635
763
  ..._pick(this.cfg, [
636
764
  'timeoutSeconds',
637
765
  'retryPost',
766
+ 'retry3xx',
638
767
  'retry4xx',
639
768
  'retry5xx',
640
769
  'responseType',
@@ -646,6 +775,8 @@ export class Fetcher {
646
775
  'debug',
647
776
  'throwHttpErrors',
648
777
  'errorData',
778
+ 'fetchFn',
779
+ 'overrideFetchFn',
649
780
  ]),
650
781
  started: Date.now(),
651
782
  ..._omit(opt, ['method', 'headers', 'credentials']),
@@ -691,13 +822,14 @@ export class Fetcher {
691
822
  }
692
823
  // setup request body
693
824
  // Unless it's a well-defined input type (json, text) - content-type is set automatically by the native fetch
825
+ // Explicitly passed `content-type` header always wins
694
826
  if (opt.json !== undefined) {
695
827
  req.init.body = JSON.stringify(opt.json);
696
- req.init.headers['content-type'] = 'application/json';
828
+ req.init.headers['content-type'] ||= 'application/json';
697
829
  }
698
830
  else if (opt.text !== undefined) {
699
831
  req.init.body = opt.text;
700
- req.init.headers['content-type'] = 'text/plain';
832
+ req.init.headers['content-type'] ||= 'text/plain';
701
833
  }
702
834
  else if (opt.form) {
703
835
  if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
@@ -705,7 +837,7 @@ export class Fetcher {
705
837
  }
706
838
  else {
707
839
  req.init.body = new URLSearchParams(opt.form);
708
- req.init.headers['content-type'] = 'application/x-www-form-urlencoded';
840
+ req.init.headers['content-type'] ||= 'application/x-www-form-urlencoded';
709
841
  }
710
842
  }
711
843
  else if (opt.body !== undefined) {
@@ -719,12 +851,16 @@ export class Fetcher {
719
851
  export function getFetcher(cfg = {}) {
720
852
  return Fetcher.create(cfg);
721
853
  }
854
+ // Marks FetcherOptions of a request that was already retried due to a reinit,
855
+ // to guarantee at most one reinit per request
856
+ const reinitAttempted = Symbol('reinitAttempted');
722
857
  const acceptByResponseType = {
723
858
  text: 'text/plain',
724
859
  json: 'application/json',
725
860
  void: '*/*',
726
861
  readableStream: 'application/octet-stream',
727
862
  arrayBuffer: 'application/octet-stream',
863
+ bytes: 'application/octet-stream',
728
864
  blob: 'application/octet-stream',
729
865
  };
730
866
  const defaultRetryOptions = {
@@ -732,4 +868,5 @@ const defaultRetryOptions = {
732
868
  timeout: 1000,
733
869
  timeoutMax: 30_000,
734
870
  timeoutMultiplier: 2,
871
+ maxRetryAfter: 600_000, // 10 minutes
735
872
  };
@@ -18,6 +18,27 @@ export type FetcherBeforeRetryHook = <BODY = unknown>(res: FetcherResponse<BODY>
18
18
  * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
19
19
  */
20
20
  export type FetcherOnErrorHook = (err: Error) => Promisable<void>;
21
+ /**
22
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
23
+ * All requests wait for the init hooks to complete, their result is cached,
24
+ * so they run at most once.
25
+ * If a hook throws - the error is re-thrown to the caller of the request,
26
+ * and init hooks are re-attempted on the next request.
27
+ *
28
+ * Receives the Fetcher's normalized cfg, which is allowed (and expected)
29
+ * to be mutated. Allows to do async initialization, e.g fetch an auth token
30
+ * and set `cfg.init.headers` based on it.
31
+ */
32
+ export type FetcherInitHook = (cfg: FetcherNormalizedCfg) => Promisable<void>;
33
+ /**
34
+ * Decides, based on the error response, if the Fetcher init state is "stale",
35
+ * e.g the auth token acquired in the init hook has expired.
36
+ * Returning true triggers `resetInit()`, so the init hooks re-run,
37
+ * and the request is then retried (at most once per request).
38
+ *
39
+ * Only consulted when init hooks are defined and the request resulted in an error.
40
+ */
41
+ export type FetcherShouldReinitFn = (res: FetcherErrorResponse) => Promisable<boolean>;
21
42
  /**
22
43
  * FetcherCfg: configuration of the Fetcher instance. One per instance.
23
44
  * FetcherOptions: options for a single request. One per request.
@@ -39,6 +60,18 @@ export interface FetcherCfg {
39
60
  * If you throw an error from the hook - it will be re-thrown as-is.
40
61
  */
41
62
  hooks?: {
63
+ /**
64
+ * Runs lazily, once per Fetcher instance, before the first request.
65
+ * See FetcherInitHook docs.
66
+ */
67
+ init?: FetcherInitHook[];
68
+ /**
69
+ * Allows to detect "stale init" (e.g the auth token acquired in the init hook
70
+ * has expired) based on the error response, and trigger reinit+retry.
71
+ * Unlike other hooks, it's a single decision predicate, not an array.
72
+ * See FetcherShouldReinitFn docs.
73
+ */
74
+ shouldReinit?: FetcherShouldReinitFn;
42
75
  /**
43
76
  * Allows to mutate req.
44
77
  */
@@ -113,6 +146,16 @@ export interface FetcherRetryOptions {
113
146
  timeout: NumberOfMilliseconds;
114
147
  timeoutMax: NumberOfMilliseconds;
115
148
  timeoutMultiplier: number;
149
+ /**
150
+ * Upper limit for the server-indicated delay (`retry-after` and similar headers).
151
+ * Server-indicated delays within the limit are honored as-is (not affected by `timeoutMax`,
152
+ * which only limits the exponential backoff).
153
+ * If the server indicates a delay larger than this - the retry is NOT attempted,
154
+ * and the error is returned/thrown right away.
155
+ *
156
+ * Defaults to 10 minutes.
157
+ */
158
+ maxRetryAfter: NumberOfMilliseconds;
116
159
  }
117
160
  export interface FetcherRequest extends Omit<FetcherOptions, 'method' | 'headers' | 'baseUrl' | 'url'> {
118
161
  /**
@@ -265,7 +308,9 @@ export interface FetcherOptions {
265
308
  overrideFetchFn?: FetchFunction;
266
309
  /**
267
310
  * Default to true.
268
- * Set to false to not throw on `!Response.ok`, but simply return `Response.body` as-is (json parsed, etc).
311
+ * Set to false to not throw on http errors (`!Response.ok`), but simply return the response body as-is (json parsed, etc).
312
+ * Retries still apply as usual.
313
+ * Non-http errors (network failure, timeout, body parse error) are still thrown.
269
314
  */
270
315
  throwHttpErrors?: boolean;
271
316
  /**
@@ -318,7 +363,7 @@ export interface FetcherErrorResponse<BODY = unknown> {
318
363
  signature: string;
319
364
  }
320
365
  export type FetcherResponse<BODY = unknown> = FetcherSuccessResponse<BODY> | FetcherErrorResponse<BODY>;
321
- export type FetcherResponseType = 'json' | 'text' | 'void' | 'arrayBuffer' | 'blob' | 'readableStream';
366
+ export type FetcherResponseType = 'json' | 'text' | 'void' | 'arrayBuffer' | 'bytes' | 'blob' | 'readableStream';
322
367
  /**
323
368
  * Signature for the `fetch` function.
324
369
  * Used to be able to override and provide a different implementation,
@@ -7,6 +7,11 @@ import type { DeferredPromise } from './pDefer.js';
7
7
  * If value is instanceof Error - rejects the Promise instead of resolving.
8
8
  */
9
9
  export declare function pDelay<T>(ms?: NumberOfMilliseconds, value?: T): Promise<T>;
10
+ /**
11
+ * Like pDelay, but also resolves early (without an error) as soon as
12
+ * the passed AbortSignal aborts.
13
+ */
14
+ export declare function pDelaySignal(ms: NumberOfMilliseconds, signal?: AbortSignal): Promise<void>;
10
15
  /**
11
16
  * Promisified version of setTimeout.
12
17
  *
@@ -8,6 +8,23 @@ import { pDefer } from './pDefer.js';
8
8
  export async function pDelay(ms = 0, value) {
9
9
  return await new Promise((resolve, reject) => setTimeout(value instanceof Error ? reject : resolve, ms, value));
10
10
  }
11
+ /**
12
+ * Like pDelay, but also resolves early (without an error) as soon as
13
+ * the passed AbortSignal aborts.
14
+ */
15
+ export async function pDelaySignal(ms, signal) {
16
+ if (signal?.aborted)
17
+ return;
18
+ await new Promise(resolve => {
19
+ const timer = setTimeout(done, ms);
20
+ function done() {
21
+ clearTimeout(timer);
22
+ signal?.removeEventListener('abort', done);
23
+ resolve();
24
+ }
25
+ signal?.addEventListener('abort', done);
26
+ });
27
+ }
11
28
  /* oxlint-disable @typescript-eslint/promise-function-async */
12
29
  /**
13
30
  * Promisified version of setTimeout.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
3
  "type": "module",
4
- "version": "15.81.0",
4
+ "version": "15.83.0",
5
5
  "dependencies": {
6
6
  "tslib": "^2"
7
7
  },
@@ -17,7 +17,7 @@
17
17
  "@types/crypto-js": "^4",
18
18
  "@types/node": "^26",
19
19
  "@types/semver": "^7",
20
- "typescript": "rc",
20
+ "typescript": "^7",
21
21
  "crypto-js": "^4",
22
22
  "dayjs": "^1",
23
23
  "@naturalcycles/dev-lib": "0.0.0"
@@ -47,6 +47,27 @@ export type FetcherBeforeRetryHook = <BODY = unknown>(
47
47
  * Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
48
48
  */
49
49
  export type FetcherOnErrorHook = (err: Error) => Promisable<void>
50
+ /**
51
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
52
+ * All requests wait for the init hooks to complete, their result is cached,
53
+ * so they run at most once.
54
+ * If a hook throws - the error is re-thrown to the caller of the request,
55
+ * and init hooks are re-attempted on the next request.
56
+ *
57
+ * Receives the Fetcher's normalized cfg, which is allowed (and expected)
58
+ * to be mutated. Allows to do async initialization, e.g fetch an auth token
59
+ * and set `cfg.init.headers` based on it.
60
+ */
61
+ export type FetcherInitHook = (cfg: FetcherNormalizedCfg) => Promisable<void>
62
+ /**
63
+ * Decides, based on the error response, if the Fetcher init state is "stale",
64
+ * e.g the auth token acquired in the init hook has expired.
65
+ * Returning true triggers `resetInit()`, so the init hooks re-run,
66
+ * and the request is then retried (at most once per request).
67
+ *
68
+ * Only consulted when init hooks are defined and the request resulted in an error.
69
+ */
70
+ export type FetcherShouldReinitFn = (res: FetcherErrorResponse) => Promisable<boolean>
50
71
 
51
72
  /**
52
73
  * FetcherCfg: configuration of the Fetcher instance. One per instance.
@@ -71,6 +92,18 @@ export interface FetcherCfg {
71
92
  * If you throw an error from the hook - it will be re-thrown as-is.
72
93
  */
73
94
  hooks?: {
95
+ /**
96
+ * Runs lazily, once per Fetcher instance, before the first request.
97
+ * See FetcherInitHook docs.
98
+ */
99
+ init?: FetcherInitHook[]
100
+ /**
101
+ * Allows to detect "stale init" (e.g the auth token acquired in the init hook
102
+ * has expired) based on the error response, and trigger reinit+retry.
103
+ * Unlike other hooks, it's a single decision predicate, not an array.
104
+ * See FetcherShouldReinitFn docs.
105
+ */
106
+ shouldReinit?: FetcherShouldReinitFn
74
107
  /**
75
108
  * Allows to mutate req.
76
109
  */
@@ -155,6 +188,16 @@ export interface FetcherRetryOptions {
155
188
  timeout: NumberOfMilliseconds
156
189
  timeoutMax: NumberOfMilliseconds
157
190
  timeoutMultiplier: number
191
+ /**
192
+ * Upper limit for the server-indicated delay (`retry-after` and similar headers).
193
+ * Server-indicated delays within the limit are honored as-is (not affected by `timeoutMax`,
194
+ * which only limits the exponential backoff).
195
+ * If the server indicates a delay larger than this - the retry is NOT attempted,
196
+ * and the error is returned/thrown right away.
197
+ *
198
+ * Defaults to 10 minutes.
199
+ */
200
+ maxRetryAfter: NumberOfMilliseconds
158
201
  }
159
202
 
160
203
  export interface FetcherRequest extends Omit<
@@ -336,7 +379,9 @@ export interface FetcherOptions {
336
379
 
337
380
  /**
338
381
  * Default to true.
339
- * Set to false to not throw on `!Response.ok`, but simply return `Response.body` as-is (json parsed, etc).
382
+ * Set to false to not throw on http errors (`!Response.ok`), but simply return the response body as-is (json parsed, etc).
383
+ * Retries still apply as usual.
384
+ * Non-http errors (network failure, timeout, body parse error) are still thrown.
340
385
  */
341
386
  throwHttpErrors?: boolean
342
387
 
@@ -404,6 +449,7 @@ export type FetcherResponseType =
404
449
  | 'text'
405
450
  | 'void'
406
451
  | 'arrayBuffer'
452
+ | 'bytes'
407
453
  | 'blob'
408
454
  | 'readableStream'
409
455
 
@@ -26,7 +26,7 @@ import {
26
26
  _omit,
27
27
  _pick,
28
28
  } from '../object/object.util.js'
29
- import { pDelay } from '../promise/pDelay.js'
29
+ import { pDelaySignal } from '../promise/pDelay.js'
30
30
  import { pTimeout } from '../promise/pTimeout.js'
31
31
  import { _toUrlOrNull } from '../string/index.js'
32
32
  import { _jsonParse, _jsonParseIfPossible } from '../string/json.util.js'
@@ -42,7 +42,9 @@ import type {
42
42
  FetcherBeforeRequestHook,
43
43
  FetcherBeforeRetryHook,
44
44
  FetcherCfg,
45
+ FetcherErrorResponse,
45
46
  FetcherGraphQLOptions,
47
+ FetcherInitHook,
46
48
  FetcherNormalizedCfg,
47
49
  FetcherOnErrorHook,
48
50
  FetcherOptions,
@@ -50,6 +52,7 @@ import type {
50
52
  FetcherResponse,
51
53
  FetcherResponseType,
52
54
  FetcherRetryOptions,
55
+ FetcherSuccessResponse,
53
56
  FetchFunction,
54
57
  GraphQLResponse,
55
58
  RequestInitNormalized,
@@ -68,7 +71,7 @@ export class Fetcher {
68
71
  *
69
72
  * Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
70
73
  */
71
- static readonly VERSION = 4
74
+ static readonly VERSION = 5
72
75
  /**
73
76
  * userAgent is statically exposed as Fetcher.userAgent.
74
77
  * It can be modified globally, and will be used (read) at the start of every request.
@@ -96,7 +99,9 @@ export class Fetcher {
96
99
  })
97
100
  }
98
101
 
99
- if (method === 'HEAD') return // responseType=text
102
+ if (method === 'HEAD') {
103
+ continue // responseType=text
104
+ }
100
105
  ;(this as any)[`${m}Text`] = async (url: string, opt?: FetcherOptions): Promise<string> => {
101
106
  return await this.fetch<string>({
102
107
  url,
@@ -141,8 +146,30 @@ export class Fetcher {
141
146
  return this
142
147
  }
143
148
 
149
+ /**
150
+ * Init hooks run lazily, once per Fetcher instance, before the first request.
151
+ * See FetcherInitHook docs.
152
+ */
153
+ onInit(hook: FetcherInitHook): this {
154
+ ;(this.cfg.hooks.init ||= []).push(hook)
155
+ return this
156
+ }
157
+
158
+ /**
159
+ * Clears the cached result of the init hooks, so they re-run before the next request.
160
+ * Useful when the state acquired during init (e.g an auth token) becomes stale.
161
+ * See also `cfg.hooks.shouldReinit`, which calls this automatically.
162
+ */
163
+ resetInit(): void {
164
+ this.initPromise = undefined
165
+ this.initGeneration++
166
+ }
167
+
144
168
  cfg: FetcherNormalizedCfg
145
169
 
170
+ private initPromise?: Promise<void>
171
+ private initGeneration = 0
172
+
146
173
  static create(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
147
174
  return new Fetcher(cfg)
148
175
  }
@@ -178,10 +205,9 @@ export class Fetcher {
178
205
  * - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
179
206
  *
180
207
  * Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
181
- *
182
- * @experimental
183
208
  */
184
209
  async queryGraphQL<T = unknown>(opt: FetcherGraphQLOptions): Promise<T> {
210
+ opt = { ...opt } // avoid mutating the input options
185
211
  opt.method ||= this.cfg.init.method // defaults to GET
186
212
 
187
213
  const payload: AnyObject = _filterFalsyValues({
@@ -236,6 +262,18 @@ export class Fetcher {
236
262
  return data
237
263
  }
238
264
 
265
+ // responseType=bytes
266
+ /**
267
+ * Returns response body as Uint8Array.
268
+ */
269
+ async getBytes(url: string, opt?: FetcherOptions): Promise<Uint8Array> {
270
+ return await this.fetch({
271
+ url,
272
+ responseType: 'bytes',
273
+ ...opt,
274
+ })
275
+ }
276
+
239
277
  // responseType=readableStream
240
278
  /**
241
279
  * Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
@@ -255,11 +293,30 @@ export class Fetcher {
255
293
  }
256
294
 
257
295
  async fetch<T = unknown>(opt: FetcherOptions): Promise<T> {
296
+ const res = await this.doFetch<T>(opt)
297
+ if (res.err && (res.req.throwHttpErrors || res.fetchResponse?.ok !== false)) {
298
+ // With throwHttpErrors=false only http errors (response received, but !ok) are returned instead of thrown.
299
+ // Other errors (network failure, timeout, body parsing) are still thrown.
300
+ throw res.err
301
+ }
302
+ return res.body as T
303
+ }
304
+
305
+ /**
306
+ * Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
307
+ * Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
308
+ * while still throwing on errors (unlike `doFetch`).
309
+ *
310
+ * Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
311
+ * otherwise the returned FetcherSuccessResponse type would lie.
312
+ * Use `doFetch` if you don't want throwing.
313
+ */
314
+ async fetchWithMeta<T = unknown>(opt: FetcherOptions): Promise<FetcherSuccessResponse<T>> {
258
315
  const res = await this.doFetch<T>(opt)
259
316
  if (res.err) {
260
317
  throw res.err
261
318
  }
262
- return res.body
319
+ return res
263
320
  }
264
321
 
265
322
  /**
@@ -298,11 +355,24 @@ export class Fetcher {
298
355
  /**
299
356
  * Returns FetcherResponse.
300
357
  * Never throws, returns `err` property in the response instead.
358
+ * (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
301
359
  * Use this method instead of `throwHttpErrors: false` or try-catching.
302
360
  *
303
- * Note: responseType defaults to `void`, so, override it if you expect different.
361
+ * Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
304
362
  */
305
363
  async doFetch<T = unknown>(opt: FetcherOptions): Promise<FetcherResponse<T>> {
364
+ let initGeneration = 0
365
+ if (this.cfg.hooks.init) {
366
+ try {
367
+ await (this.initPromise ??= this.runInitHooks())
368
+ } catch (err) {
369
+ // Reset, so init hooks are re-attempted on the next request
370
+ this.resetInit()
371
+ throw err
372
+ }
373
+ initGeneration = this.initGeneration
374
+ }
375
+
306
376
  const req = this.normalizeOptions(opt)
307
377
  const { logger } = this.cfg
308
378
  const {
@@ -331,6 +401,7 @@ export class Fetcher {
331
401
 
332
402
  while (!res.retryStatus.retryStopped) {
333
403
  req.started = Date.now() as UnixTimestampMillis
404
+ res.body = undefined
334
405
 
335
406
  req.init.signal = abortSignalAnyOrUndefined([
336
407
  abortSignalTimeoutOrUndefined(timeoutMillis),
@@ -350,10 +421,10 @@ export class Fetcher {
350
421
  }
351
422
 
352
423
  try {
353
- res.fetchResponse = await (this.cfg.overrideFetchFn || Fetcher.callNativeFetch)(
424
+ res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(
354
425
  req.fullUrl,
355
426
  req.init,
356
- this.cfg.fetchFn,
427
+ req.fetchFn,
357
428
  )
358
429
  res.ok = res.fetchResponse.ok
359
430
  // important to set it to undefined, otherwise it can keep the previous value (from previous try)
@@ -370,14 +441,15 @@ export class Fetcher {
370
441
  res.statusFamily = this.getStatusFamily(res)
371
442
  res.statusCode = res.fetchResponse?.status
372
443
 
373
- if (res.fetchResponse?.ok || !req.throwHttpErrors) {
444
+ if (res.fetchResponse?.ok) {
374
445
  try {
375
446
  // We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
376
447
  await pTimeout(
377
448
  async () =>
378
449
  await this.onOkResponse(res as FetcherResponse<T> & { fetchResponse: Response }),
379
450
  {
380
- timeout: timeoutMillis || Number.POSITIVE_INFINITY,
451
+ // 0 means "no timeout" for pTimeout
452
+ timeout: timeoutMillis ?? 0,
381
453
  name: 'Fetcher.downloadBody',
382
454
  },
383
455
  )
@@ -411,9 +483,42 @@ export class Fetcher {
411
483
  await hook(res)
412
484
  }
413
485
 
486
+ if (res.err && (await this.detectStaleInit(opt, res, initGeneration))) {
487
+ // Stale init (e.g expired auth token) was detected and reset - retry the request once
488
+ return await this.doFetch<T>({ ...opt, [reinitAttempted]: true } as FetcherOptions)
489
+ }
490
+
414
491
  return res
415
492
  }
416
493
 
494
+ /**
495
+ * Consults cfg.hooks.shouldReinit to detect "stale init" (e.g expired auth token).
496
+ * If detected - resets the init (so it re-runs) and returns true,
497
+ * telling the caller to retry the request. At most once per request.
498
+ */
499
+ private async detectStaleInit(
500
+ opt: FetcherOptions,
501
+ res: FetcherErrorResponse,
502
+ initGeneration: number,
503
+ ): Promise<boolean> {
504
+ const { shouldReinit, init } = this.cfg.hooks
505
+ if (!shouldReinit || !init) return false
506
+ if ((opt as any)[reinitAttempted]) return false
507
+ if (!(await shouldReinit(res))) return false
508
+
509
+ // Generation check prevents concurrent stale requests from resetting the already-fresh init
510
+ if (initGeneration === this.initGeneration) {
511
+ this.resetInit()
512
+ }
513
+ return true
514
+ }
515
+
516
+ private async runInitHooks(): Promise<void> {
517
+ for (const hook of this.cfg.hooks.init || []) {
518
+ await hook(this.cfg)
519
+ }
520
+ }
521
+
417
522
  private async onOkResponse(
418
523
  res: FetcherResponse<any> & { fetchResponse: Response },
419
524
  ): Promise<void> {
@@ -441,9 +546,17 @@ export class Fetcher {
441
546
  } else if (responseType === 'text') {
442
547
  res.body = res.fetchResponse.body ? await res.fetchResponse.text() : ''
443
548
  } else if (responseType === 'arrayBuffer') {
444
- res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : {}
549
+ res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : new ArrayBuffer(0)
550
+ } else if (responseType === 'bytes') {
551
+ // Not using fetchResponse.bytes(), as it's unavailable in older browsers (e.g Safari <18.4)
552
+ res.body = res.fetchResponse.body
553
+ ? new Uint8Array(await res.fetchResponse.arrayBuffer())
554
+ : new Uint8Array()
445
555
  } else if (responseType === 'blob') {
446
- res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : {}
556
+ res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : new Blob()
557
+ } else if (responseType === 'void') {
558
+ // Cancel the body (without downloading it), to free the underlying connection for reuse
559
+ await res.fetchResponse.body?.cancel()
447
560
  } else if (responseType === 'readableStream') {
448
561
  res.body = res.fetchResponse.body
449
562
 
@@ -455,8 +568,7 @@ export class Fetcher {
455
568
 
456
569
  res.retryStatus.retryStopped = true
457
570
 
458
- // res.err can happen on `failed to fetch` type of error, e.g JSON.parse, CORS, unexpected redirect
459
- if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
571
+ if (req.logResponse) {
460
572
  const { retryAttempt } = res.retryStatus
461
573
  const { logger } = this.cfg
462
574
  logger.log(
@@ -596,16 +708,33 @@ export class Fetcher {
596
708
  if (retryStatus.retryStopped) return
597
709
 
598
710
  retryStatus.retryAttempt++
711
+ const timeout = this.getRetryTimeout(res)
712
+ if (timeout === null) {
713
+ this.cfg.logger.warn(
714
+ `${res.signature} server-indicated delay exceeds maxRetryAfter, will not retry`,
715
+ )
716
+ retryStatus.retryStopped = true
717
+ return
718
+ }
719
+ // Increase the timeout for the next possible retry
599
720
  retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
600
721
 
601
- const timeout = this.getRetryTimeout(res)
602
722
  if (res.req.debug) {
603
723
  this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`)
604
724
  }
605
- await pDelay(timeout)
725
+ await pDelaySignal(timeout, res.req.signal)
726
+ if (res.req.signal?.aborted) {
727
+ // Aborted while waiting for the retry - stop, without issuing another request
728
+ retryStatus.retryStopped = true
729
+ }
606
730
  }
607
731
 
608
- private getRetryTimeout(res: FetcherResponse): NumberOfMilliseconds {
732
+ /**
733
+ * Returns the delay before the next retry attempt.
734
+ * Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
735
+ * meaning the retry should not be attempted at all.
736
+ */
737
+ private getRetryTimeout(res: FetcherResponse): NumberOfMilliseconds | null {
609
738
  let timeout: NumberOfMilliseconds = 0
610
739
 
611
740
  // Handling http 429 with specific retry headers
@@ -615,12 +744,19 @@ export class Fetcher {
615
744
  res.fetchResponse.headers.get('retry-after') ??
616
745
  res.fetchResponse.headers.get('x-ratelimit-reset')
617
746
  if (retryAfterStr) {
618
- if (Number(retryAfterStr)) {
619
- timeout = Number(retryAfterStr) * 1000
747
+ const retryAfterNumber = Number(retryAfterStr)
748
+ if (retryAfterNumber) {
749
+ if (retryAfterNumber > 10 ** 9) {
750
+ // Value is too large to be a "seconds from now" delay,
751
+ // treat it as a UnixTimestamp instead (e.g GitHub sends `x-ratelimit-reset` like that)
752
+ timeout = retryAfterNumber * 1000 - Date.now()
753
+ } else {
754
+ timeout = retryAfterNumber * 1000
755
+ }
620
756
  } else {
621
757
  const date = new Date(retryAfterStr)
622
- if (!Number.isNaN(date)) {
623
- timeout = Number(date) - Date.now()
758
+ if (!Number.isNaN(date.getTime())) {
759
+ timeout = date.getTime() - Date.now()
624
760
  }
625
761
  }
626
762
 
@@ -631,12 +767,16 @@ export class Fetcher {
631
767
  }
632
768
  }
633
769
 
634
- if (!timeout) {
635
- const noise = Math.random() * 500
636
- timeout = res.retryStatus.retryTimeout + noise
770
+ if (timeout) {
771
+ if (timeout > res.req.retry.maxRetryAfter) {
772
+ return null
773
+ }
774
+ // Server-indicated delay is honored as-is (a negative value means "retry now")
775
+ return Math.max(0, timeout)
637
776
  }
638
777
 
639
- return timeout
778
+ const noise = Math.random() * 500
779
+ return res.retryStatus.retryTimeout + noise
640
780
  }
641
781
 
642
782
  /**
@@ -775,6 +915,7 @@ export class Fetcher {
775
915
  ..._pick(this.cfg, [
776
916
  'timeoutSeconds',
777
917
  'retryPost',
918
+ 'retry3xx',
778
919
  'retry4xx',
779
920
  'retry5xx',
780
921
  'responseType',
@@ -786,6 +927,8 @@ export class Fetcher {
786
927
  'debug',
787
928
  'throwHttpErrors',
788
929
  'errorData',
930
+ 'fetchFn',
931
+ 'overrideFetchFn',
789
932
  ]),
790
933
  started: Date.now() as UnixTimestampMillis,
791
934
  ..._omit(opt, ['method', 'headers', 'credentials']),
@@ -839,18 +982,19 @@ export class Fetcher {
839
982
 
840
983
  // setup request body
841
984
  // Unless it's a well-defined input type (json, text) - content-type is set automatically by the native fetch
985
+ // Explicitly passed `content-type` header always wins
842
986
  if (opt.json !== undefined) {
843
987
  req.init.body = JSON.stringify(opt.json)
844
- req.init.headers['content-type'] = 'application/json'
988
+ req.init.headers['content-type'] ||= 'application/json'
845
989
  } else if (opt.text !== undefined) {
846
990
  req.init.body = opt.text
847
- req.init.headers['content-type'] = 'text/plain'
991
+ req.init.headers['content-type'] ||= 'text/plain'
848
992
  } else if (opt.form) {
849
993
  if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
850
994
  req.init.body = opt.form
851
995
  } else {
852
996
  req.init.body = new URLSearchParams(opt.form)
853
- req.init.headers['content-type'] = 'application/x-www-form-urlencoded'
997
+ req.init.headers['content-type'] ||= 'application/x-www-form-urlencoded'
854
998
  }
855
999
  } else if (opt.body !== undefined) {
856
1000
  req.init.body = opt.body
@@ -867,12 +1011,17 @@ export function getFetcher(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
867
1011
  return Fetcher.create(cfg)
868
1012
  }
869
1013
 
1014
+ // Marks FetcherOptions of a request that was already retried due to a reinit,
1015
+ // to guarantee at most one reinit per request
1016
+ const reinitAttempted = Symbol('reinitAttempted')
1017
+
870
1018
  const acceptByResponseType: Record<FetcherResponseType, string> = {
871
1019
  text: 'text/plain',
872
1020
  json: 'application/json',
873
1021
  void: '*/*',
874
1022
  readableStream: 'application/octet-stream',
875
1023
  arrayBuffer: 'application/octet-stream',
1024
+ bytes: 'application/octet-stream',
876
1025
  blob: 'application/octet-stream',
877
1026
  }
878
1027
 
@@ -881,4 +1030,5 @@ const defaultRetryOptions: FetcherRetryOptions = {
881
1030
  timeout: 1000,
882
1031
  timeoutMax: 30_000,
883
1032
  timeoutMultiplier: 2,
1033
+ maxRetryAfter: 600_000, // 10 minutes
884
1034
  }
@@ -14,6 +14,26 @@ export async function pDelay<T>(ms: NumberOfMilliseconds = 0, value?: T): Promis
14
14
  )
15
15
  }
16
16
 
17
+ /**
18
+ * Like pDelay, but also resolves early (without an error) as soon as
19
+ * the passed AbortSignal aborts.
20
+ */
21
+ export async function pDelaySignal(ms: NumberOfMilliseconds, signal?: AbortSignal): Promise<void> {
22
+ if (signal?.aborted) return
23
+
24
+ await new Promise<void>(resolve => {
25
+ const timer = setTimeout(done, ms)
26
+
27
+ function done(): void {
28
+ clearTimeout(timer)
29
+ signal?.removeEventListener('abort', done)
30
+ resolve()
31
+ }
32
+
33
+ signal?.addEventListener('abort', done)
34
+ })
35
+ }
36
+
17
37
  /* oxlint-disable @typescript-eslint/promise-function-async */
18
38
 
19
39
  /**