@naturalcycles/js-lib 15.81.0 → 15.82.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.
- package/dist/http/fetcher.d.ts +30 -5
- package/dist/http/fetcher.js +128 -29
- package/dist/http/fetcher.model.d.ts +31 -2
- package/dist/promise/pDelay.d.ts +5 -0
- package/dist/promise/pDelay.js +17 -0
- package/package.json +2 -2
- package/src/http/fetcher.model.ts +31 -1
- package/src/http/fetcher.ts +134 -29
- package/src/promise/pDelay.ts +20 -0
package/dist/http/fetcher.d.ts
CHANGED
|
@@ -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 =
|
|
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,13 @@ 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;
|
|
33
38
|
cfg: FetcherNormalizedCfg;
|
|
39
|
+
private initPromise?;
|
|
34
40
|
static create(cfg?: FetcherCfg & FetcherOptions): Fetcher;
|
|
35
41
|
get: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
|
|
36
42
|
post: <T = unknown>(url: string, opt?: FetcherOptions) => Promise<T>;
|
|
@@ -56,10 +62,12 @@ export declare class Fetcher {
|
|
|
56
62
|
* - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
|
|
57
63
|
*
|
|
58
64
|
* Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
|
|
59
|
-
*
|
|
60
|
-
* @experimental
|
|
61
65
|
*/
|
|
62
66
|
queryGraphQL<T = unknown>(opt: FetcherGraphQLOptions): Promise<T>;
|
|
67
|
+
/**
|
|
68
|
+
* Returns response body as Uint8Array.
|
|
69
|
+
*/
|
|
70
|
+
getBytes(url: string, opt?: FetcherOptions): Promise<Uint8Array>;
|
|
63
71
|
/**
|
|
64
72
|
* Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
|
|
65
73
|
*
|
|
@@ -68,6 +76,16 @@ export declare class Fetcher {
|
|
|
68
76
|
*/
|
|
69
77
|
getReadableStream(url: string, opt?: FetcherOptions): Promise<WebReadableStream<Uint8Array>>;
|
|
70
78
|
fetch<T = unknown>(opt: FetcherOptions): Promise<T>;
|
|
79
|
+
/**
|
|
80
|
+
* Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
|
|
81
|
+
* Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
|
|
82
|
+
* while still throwing on errors (unlike `doFetch`).
|
|
83
|
+
*
|
|
84
|
+
* Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
|
|
85
|
+
* otherwise the returned FetcherSuccessResponse type would lie.
|
|
86
|
+
* Use `doFetch` if you don't want throwing.
|
|
87
|
+
*/
|
|
88
|
+
fetchWithMeta<T = unknown>(opt: FetcherOptions): Promise<FetcherSuccessResponse<T>>;
|
|
71
89
|
/**
|
|
72
90
|
* Execute fetch and expect/assert it to return an Error (which will be wrapped in
|
|
73
91
|
* HttpRequestError as it normally would).
|
|
@@ -86,11 +104,13 @@ export declare class Fetcher {
|
|
|
86
104
|
/**
|
|
87
105
|
* Returns FetcherResponse.
|
|
88
106
|
* Never throws, returns `err` property in the response instead.
|
|
107
|
+
* (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
|
|
89
108
|
* Use this method instead of `throwHttpErrors: false` or try-catching.
|
|
90
109
|
*
|
|
91
|
-
* Note: responseType defaults to
|
|
110
|
+
* Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
|
|
92
111
|
*/
|
|
93
112
|
doFetch<T = unknown>(opt: FetcherOptions): Promise<FetcherResponse<T>>;
|
|
113
|
+
private runInitHooks;
|
|
94
114
|
private onOkResponse;
|
|
95
115
|
/**
|
|
96
116
|
* This method exists to be able to easily mock it.
|
|
@@ -99,6 +119,11 @@ export declare class Fetcher {
|
|
|
99
119
|
static callNativeFetch(url: string, init: RequestInitNormalized, fetchFn?: FetchFunction): Promise<Response>;
|
|
100
120
|
private onNotOkResponse;
|
|
101
121
|
private processRetry;
|
|
122
|
+
/**
|
|
123
|
+
* Returns the delay before the next retry attempt.
|
|
124
|
+
* Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
|
|
125
|
+
* meaning the retry should not be attempted at all.
|
|
126
|
+
*/
|
|
102
127
|
private getRetryTimeout;
|
|
103
128
|
/**
|
|
104
129
|
* Default is yes,
|
package/dist/http/fetcher.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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,17 @@ 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
|
+
}
|
|
94
104
|
cfg;
|
|
105
|
+
initPromise;
|
|
95
106
|
static create(cfg = {}) {
|
|
96
107
|
return new Fetcher(cfg);
|
|
97
108
|
}
|
|
@@ -123,10 +134,9 @@ export class Fetcher {
|
|
|
123
134
|
* - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
|
|
124
135
|
*
|
|
125
136
|
* Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
|
|
126
|
-
*
|
|
127
|
-
* @experimental
|
|
128
137
|
*/
|
|
129
138
|
async queryGraphQL(opt) {
|
|
139
|
+
opt = { ...opt }; // avoid mutating the input options
|
|
130
140
|
opt.method ||= this.cfg.init.method; // defaults to GET
|
|
131
141
|
const payload = _filterFalsyValues({
|
|
132
142
|
query: opt.query,
|
|
@@ -173,6 +183,17 @@ export class Fetcher {
|
|
|
173
183
|
}
|
|
174
184
|
return data;
|
|
175
185
|
}
|
|
186
|
+
// responseType=bytes
|
|
187
|
+
/**
|
|
188
|
+
* Returns response body as Uint8Array.
|
|
189
|
+
*/
|
|
190
|
+
async getBytes(url, opt) {
|
|
191
|
+
return await this.fetch({
|
|
192
|
+
url,
|
|
193
|
+
responseType: 'bytes',
|
|
194
|
+
...opt,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
176
197
|
// responseType=readableStream
|
|
177
198
|
/**
|
|
178
199
|
* Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
|
|
@@ -189,11 +210,29 @@ export class Fetcher {
|
|
|
189
210
|
}
|
|
190
211
|
async fetch(opt) {
|
|
191
212
|
const res = await this.doFetch(opt);
|
|
192
|
-
if (res.err) {
|
|
213
|
+
if (res.err && (res.req.throwHttpErrors || res.fetchResponse?.ok !== false)) {
|
|
214
|
+
// With throwHttpErrors=false only http errors (response received, but !ok) are returned instead of thrown.
|
|
215
|
+
// Other errors (network failure, timeout, body parsing) are still thrown.
|
|
193
216
|
throw res.err;
|
|
194
217
|
}
|
|
195
218
|
return res.body;
|
|
196
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
|
|
222
|
+
* Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
|
|
223
|
+
* while still throwing on errors (unlike `doFetch`).
|
|
224
|
+
*
|
|
225
|
+
* Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
|
|
226
|
+
* otherwise the returned FetcherSuccessResponse type would lie.
|
|
227
|
+
* Use `doFetch` if you don't want throwing.
|
|
228
|
+
*/
|
|
229
|
+
async fetchWithMeta(opt) {
|
|
230
|
+
const res = await this.doFetch(opt);
|
|
231
|
+
if (res.err) {
|
|
232
|
+
throw res.err;
|
|
233
|
+
}
|
|
234
|
+
return res;
|
|
235
|
+
}
|
|
197
236
|
/**
|
|
198
237
|
* Execute fetch and expect/assert it to return an Error (which will be wrapped in
|
|
199
238
|
* HttpRequestError as it normally would).
|
|
@@ -226,11 +265,22 @@ export class Fetcher {
|
|
|
226
265
|
/**
|
|
227
266
|
* Returns FetcherResponse.
|
|
228
267
|
* Never throws, returns `err` property in the response instead.
|
|
268
|
+
* (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
|
|
229
269
|
* Use this method instead of `throwHttpErrors: false` or try-catching.
|
|
230
270
|
*
|
|
231
|
-
* Note: responseType defaults to
|
|
271
|
+
* Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
|
|
232
272
|
*/
|
|
233
273
|
async doFetch(opt) {
|
|
274
|
+
if (this.cfg.hooks.init) {
|
|
275
|
+
try {
|
|
276
|
+
await (this.initPromise ??= this.runInitHooks());
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
// Reset, so init hooks are re-attempted on the next request
|
|
280
|
+
this.initPromise = undefined;
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
234
284
|
const req = this.normalizeOptions(opt);
|
|
235
285
|
const { logger } = this.cfg;
|
|
236
286
|
const { init: { method }, } = req;
|
|
@@ -253,6 +303,7 @@ export class Fetcher {
|
|
|
253
303
|
};
|
|
254
304
|
while (!res.retryStatus.retryStopped) {
|
|
255
305
|
req.started = Date.now();
|
|
306
|
+
res.body = undefined;
|
|
256
307
|
req.init.signal = abortSignalAnyOrUndefined([
|
|
257
308
|
abortSignalTimeoutOrUndefined(timeoutMillis),
|
|
258
309
|
opt.signal,
|
|
@@ -267,7 +318,7 @@ export class Fetcher {
|
|
|
267
318
|
}
|
|
268
319
|
}
|
|
269
320
|
try {
|
|
270
|
-
res.fetchResponse = await (
|
|
321
|
+
res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(req.fullUrl, req.init, req.fetchFn);
|
|
271
322
|
res.ok = res.fetchResponse.ok;
|
|
272
323
|
// important to set it to undefined, otherwise it can keep the previous value (from previous try)
|
|
273
324
|
res.err = undefined;
|
|
@@ -283,11 +334,12 @@ export class Fetcher {
|
|
|
283
334
|
}
|
|
284
335
|
res.statusFamily = this.getStatusFamily(res);
|
|
285
336
|
res.statusCode = res.fetchResponse?.status;
|
|
286
|
-
if (res.fetchResponse?.ok
|
|
337
|
+
if (res.fetchResponse?.ok) {
|
|
287
338
|
try {
|
|
288
339
|
// We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
|
|
289
340
|
await pTimeout(async () => await this.onOkResponse(res), {
|
|
290
|
-
timeout
|
|
341
|
+
// 0 means "no timeout" for pTimeout
|
|
342
|
+
timeout: timeoutMillis ?? 0,
|
|
291
343
|
name: 'Fetcher.downloadBody',
|
|
292
344
|
});
|
|
293
345
|
}
|
|
@@ -318,6 +370,11 @@ export class Fetcher {
|
|
|
318
370
|
}
|
|
319
371
|
return res;
|
|
320
372
|
}
|
|
373
|
+
async runInitHooks() {
|
|
374
|
+
for (const hook of this.cfg.hooks.init || []) {
|
|
375
|
+
await hook(this.cfg);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
321
378
|
async onOkResponse(res) {
|
|
322
379
|
const { req } = res;
|
|
323
380
|
const { responseType } = res.req;
|
|
@@ -345,10 +402,20 @@ export class Fetcher {
|
|
|
345
402
|
res.body = res.fetchResponse.body ? await res.fetchResponse.text() : '';
|
|
346
403
|
}
|
|
347
404
|
else if (responseType === 'arrayBuffer') {
|
|
348
|
-
res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() :
|
|
405
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : new ArrayBuffer(0);
|
|
406
|
+
}
|
|
407
|
+
else if (responseType === 'bytes') {
|
|
408
|
+
// Not using fetchResponse.bytes(), as it's unavailable in older browsers (e.g Safari <18.4)
|
|
409
|
+
res.body = res.fetchResponse.body
|
|
410
|
+
? new Uint8Array(await res.fetchResponse.arrayBuffer())
|
|
411
|
+
: new Uint8Array();
|
|
349
412
|
}
|
|
350
413
|
else if (responseType === 'blob') {
|
|
351
|
-
res.body = res.fetchResponse.body ? await res.fetchResponse.blob() :
|
|
414
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : new Blob();
|
|
415
|
+
}
|
|
416
|
+
else if (responseType === 'void') {
|
|
417
|
+
// Cancel the body (without downloading it), to free the underlying connection for reuse
|
|
418
|
+
await res.fetchResponse.body?.cancel();
|
|
352
419
|
}
|
|
353
420
|
else if (responseType === 'readableStream') {
|
|
354
421
|
res.body = res.fetchResponse.body;
|
|
@@ -358,8 +425,7 @@ export class Fetcher {
|
|
|
358
425
|
}
|
|
359
426
|
}
|
|
360
427
|
res.retryStatus.retryStopped = true;
|
|
361
|
-
|
|
362
|
-
if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
|
|
428
|
+
if (req.logResponse) {
|
|
363
429
|
const { retryAttempt } = res.retryStatus;
|
|
364
430
|
const { logger } = this.cfg;
|
|
365
431
|
logger.log([
|
|
@@ -474,13 +540,28 @@ export class Fetcher {
|
|
|
474
540
|
if (retryStatus.retryStopped)
|
|
475
541
|
return;
|
|
476
542
|
retryStatus.retryAttempt++;
|
|
477
|
-
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
|
|
478
543
|
const timeout = this.getRetryTimeout(res);
|
|
544
|
+
if (timeout === null) {
|
|
545
|
+
this.cfg.logger.warn(`${res.signature} server-indicated delay exceeds maxRetryAfter, will not retry`);
|
|
546
|
+
retryStatus.retryStopped = true;
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// Increase the timeout for the next possible retry
|
|
550
|
+
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
|
|
479
551
|
if (res.req.debug) {
|
|
480
552
|
this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`);
|
|
481
553
|
}
|
|
482
|
-
await
|
|
554
|
+
await pDelaySignal(timeout, res.req.signal);
|
|
555
|
+
if (res.req.signal?.aborted) {
|
|
556
|
+
// Aborted while waiting for the retry - stop, without issuing another request
|
|
557
|
+
retryStatus.retryStopped = true;
|
|
558
|
+
}
|
|
483
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* Returns the delay before the next retry attempt.
|
|
562
|
+
* Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
|
|
563
|
+
* meaning the retry should not be attempted at all.
|
|
564
|
+
*/
|
|
484
565
|
getRetryTimeout(res) {
|
|
485
566
|
let timeout = 0;
|
|
486
567
|
// Handling http 429 with specific retry headers
|
|
@@ -489,13 +570,21 @@ export class Fetcher {
|
|
|
489
570
|
const retryAfterStr = res.fetchResponse.headers.get('retry-after') ??
|
|
490
571
|
res.fetchResponse.headers.get('x-ratelimit-reset');
|
|
491
572
|
if (retryAfterStr) {
|
|
492
|
-
|
|
493
|
-
|
|
573
|
+
const retryAfterNumber = Number(retryAfterStr);
|
|
574
|
+
if (retryAfterNumber) {
|
|
575
|
+
if (retryAfterNumber > 10 ** 9) {
|
|
576
|
+
// Value is too large to be a "seconds from now" delay,
|
|
577
|
+
// treat it as a UnixTimestamp instead (e.g GitHub sends `x-ratelimit-reset` like that)
|
|
578
|
+
timeout = retryAfterNumber * 1000 - Date.now();
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
timeout = retryAfterNumber * 1000;
|
|
582
|
+
}
|
|
494
583
|
}
|
|
495
584
|
else {
|
|
496
585
|
const date = new Date(retryAfterStr);
|
|
497
|
-
if (!Number.isNaN(date)) {
|
|
498
|
-
timeout =
|
|
586
|
+
if (!Number.isNaN(date.getTime())) {
|
|
587
|
+
timeout = date.getTime() - Date.now();
|
|
499
588
|
}
|
|
500
589
|
}
|
|
501
590
|
this.cfg.logger.log(`retry-after: ${retryAfterStr}`);
|
|
@@ -504,11 +593,15 @@ export class Fetcher {
|
|
|
504
593
|
}
|
|
505
594
|
}
|
|
506
595
|
}
|
|
507
|
-
if (
|
|
508
|
-
|
|
509
|
-
|
|
596
|
+
if (timeout) {
|
|
597
|
+
if (timeout > res.req.retry.maxRetryAfter) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
// Server-indicated delay is honored as-is (a negative value means "retry now")
|
|
601
|
+
return Math.max(0, timeout);
|
|
510
602
|
}
|
|
511
|
-
|
|
603
|
+
const noise = Math.random() * 500;
|
|
604
|
+
return res.retryStatus.retryTimeout + noise;
|
|
512
605
|
}
|
|
513
606
|
/**
|
|
514
607
|
* Default is yes,
|
|
@@ -635,6 +728,7 @@ export class Fetcher {
|
|
|
635
728
|
..._pick(this.cfg, [
|
|
636
729
|
'timeoutSeconds',
|
|
637
730
|
'retryPost',
|
|
731
|
+
'retry3xx',
|
|
638
732
|
'retry4xx',
|
|
639
733
|
'retry5xx',
|
|
640
734
|
'responseType',
|
|
@@ -646,6 +740,8 @@ export class Fetcher {
|
|
|
646
740
|
'debug',
|
|
647
741
|
'throwHttpErrors',
|
|
648
742
|
'errorData',
|
|
743
|
+
'fetchFn',
|
|
744
|
+
'overrideFetchFn',
|
|
649
745
|
]),
|
|
650
746
|
started: Date.now(),
|
|
651
747
|
..._omit(opt, ['method', 'headers', 'credentials']),
|
|
@@ -691,13 +787,14 @@ export class Fetcher {
|
|
|
691
787
|
}
|
|
692
788
|
// setup request body
|
|
693
789
|
// Unless it's a well-defined input type (json, text) - content-type is set automatically by the native fetch
|
|
790
|
+
// Explicitly passed `content-type` header always wins
|
|
694
791
|
if (opt.json !== undefined) {
|
|
695
792
|
req.init.body = JSON.stringify(opt.json);
|
|
696
|
-
req.init.headers['content-type']
|
|
793
|
+
req.init.headers['content-type'] ||= 'application/json';
|
|
697
794
|
}
|
|
698
795
|
else if (opt.text !== undefined) {
|
|
699
796
|
req.init.body = opt.text;
|
|
700
|
-
req.init.headers['content-type']
|
|
797
|
+
req.init.headers['content-type'] ||= 'text/plain';
|
|
701
798
|
}
|
|
702
799
|
else if (opt.form) {
|
|
703
800
|
if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
|
|
@@ -705,7 +802,7 @@ export class Fetcher {
|
|
|
705
802
|
}
|
|
706
803
|
else {
|
|
707
804
|
req.init.body = new URLSearchParams(opt.form);
|
|
708
|
-
req.init.headers['content-type']
|
|
805
|
+
req.init.headers['content-type'] ||= 'application/x-www-form-urlencoded';
|
|
709
806
|
}
|
|
710
807
|
}
|
|
711
808
|
else if (opt.body !== undefined) {
|
|
@@ -725,6 +822,7 @@ const acceptByResponseType = {
|
|
|
725
822
|
void: '*/*',
|
|
726
823
|
readableStream: 'application/octet-stream',
|
|
727
824
|
arrayBuffer: 'application/octet-stream',
|
|
825
|
+
bytes: 'application/octet-stream',
|
|
728
826
|
blob: 'application/octet-stream',
|
|
729
827
|
};
|
|
730
828
|
const defaultRetryOptions = {
|
|
@@ -732,4 +830,5 @@ const defaultRetryOptions = {
|
|
|
732
830
|
timeout: 1000,
|
|
733
831
|
timeoutMax: 30_000,
|
|
734
832
|
timeoutMultiplier: 2,
|
|
833
|
+
maxRetryAfter: 600_000, // 10 minutes
|
|
735
834
|
};
|
|
@@ -18,6 +18,18 @@ 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>;
|
|
21
33
|
/**
|
|
22
34
|
* FetcherCfg: configuration of the Fetcher instance. One per instance.
|
|
23
35
|
* FetcherOptions: options for a single request. One per request.
|
|
@@ -39,6 +51,11 @@ export interface FetcherCfg {
|
|
|
39
51
|
* If you throw an error from the hook - it will be re-thrown as-is.
|
|
40
52
|
*/
|
|
41
53
|
hooks?: {
|
|
54
|
+
/**
|
|
55
|
+
* Runs lazily, once per Fetcher instance, before the first request.
|
|
56
|
+
* See FetcherInitHook docs.
|
|
57
|
+
*/
|
|
58
|
+
init?: FetcherInitHook[];
|
|
42
59
|
/**
|
|
43
60
|
* Allows to mutate req.
|
|
44
61
|
*/
|
|
@@ -113,6 +130,16 @@ export interface FetcherRetryOptions {
|
|
|
113
130
|
timeout: NumberOfMilliseconds;
|
|
114
131
|
timeoutMax: NumberOfMilliseconds;
|
|
115
132
|
timeoutMultiplier: number;
|
|
133
|
+
/**
|
|
134
|
+
* Upper limit for the server-indicated delay (`retry-after` and similar headers).
|
|
135
|
+
* Server-indicated delays within the limit are honored as-is (not affected by `timeoutMax`,
|
|
136
|
+
* which only limits the exponential backoff).
|
|
137
|
+
* If the server indicates a delay larger than this - the retry is NOT attempted,
|
|
138
|
+
* and the error is returned/thrown right away.
|
|
139
|
+
*
|
|
140
|
+
* Defaults to 10 minutes.
|
|
141
|
+
*/
|
|
142
|
+
maxRetryAfter: NumberOfMilliseconds;
|
|
116
143
|
}
|
|
117
144
|
export interface FetcherRequest extends Omit<FetcherOptions, 'method' | 'headers' | 'baseUrl' | 'url'> {
|
|
118
145
|
/**
|
|
@@ -265,7 +292,9 @@ export interface FetcherOptions {
|
|
|
265
292
|
overrideFetchFn?: FetchFunction;
|
|
266
293
|
/**
|
|
267
294
|
* Default to true.
|
|
268
|
-
* Set to false to not throw on `!Response.ok
|
|
295
|
+
* Set to false to not throw on http errors (`!Response.ok`), but simply return the response body as-is (json parsed, etc).
|
|
296
|
+
* Retries still apply as usual.
|
|
297
|
+
* Non-http errors (network failure, timeout, body parse error) are still thrown.
|
|
269
298
|
*/
|
|
270
299
|
throwHttpErrors?: boolean;
|
|
271
300
|
/**
|
|
@@ -318,7 +347,7 @@ export interface FetcherErrorResponse<BODY = unknown> {
|
|
|
318
347
|
signature: string;
|
|
319
348
|
}
|
|
320
349
|
export type FetcherResponse<BODY = unknown> = FetcherSuccessResponse<BODY> | FetcherErrorResponse<BODY>;
|
|
321
|
-
export type FetcherResponseType = 'json' | 'text' | 'void' | 'arrayBuffer' | 'blob' | 'readableStream';
|
|
350
|
+
export type FetcherResponseType = 'json' | 'text' | 'void' | 'arrayBuffer' | 'bytes' | 'blob' | 'readableStream';
|
|
322
351
|
/**
|
|
323
352
|
* Signature for the `fetch` function.
|
|
324
353
|
* Used to be able to override and provide a different implementation,
|
package/dist/promise/pDelay.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/promise/pDelay.js
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "15.82.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": "
|
|
20
|
+
"typescript": "^7",
|
|
21
21
|
"crypto-js": "^4",
|
|
22
22
|
"dayjs": "^1",
|
|
23
23
|
"@naturalcycles/dev-lib": "0.0.0"
|
|
@@ -47,6 +47,18 @@ 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>
|
|
50
62
|
|
|
51
63
|
/**
|
|
52
64
|
* FetcherCfg: configuration of the Fetcher instance. One per instance.
|
|
@@ -71,6 +83,11 @@ export interface FetcherCfg {
|
|
|
71
83
|
* If you throw an error from the hook - it will be re-thrown as-is.
|
|
72
84
|
*/
|
|
73
85
|
hooks?: {
|
|
86
|
+
/**
|
|
87
|
+
* Runs lazily, once per Fetcher instance, before the first request.
|
|
88
|
+
* See FetcherInitHook docs.
|
|
89
|
+
*/
|
|
90
|
+
init?: FetcherInitHook[]
|
|
74
91
|
/**
|
|
75
92
|
* Allows to mutate req.
|
|
76
93
|
*/
|
|
@@ -155,6 +172,16 @@ export interface FetcherRetryOptions {
|
|
|
155
172
|
timeout: NumberOfMilliseconds
|
|
156
173
|
timeoutMax: NumberOfMilliseconds
|
|
157
174
|
timeoutMultiplier: number
|
|
175
|
+
/**
|
|
176
|
+
* Upper limit for the server-indicated delay (`retry-after` and similar headers).
|
|
177
|
+
* Server-indicated delays within the limit are honored as-is (not affected by `timeoutMax`,
|
|
178
|
+
* which only limits the exponential backoff).
|
|
179
|
+
* If the server indicates a delay larger than this - the retry is NOT attempted,
|
|
180
|
+
* and the error is returned/thrown right away.
|
|
181
|
+
*
|
|
182
|
+
* Defaults to 10 minutes.
|
|
183
|
+
*/
|
|
184
|
+
maxRetryAfter: NumberOfMilliseconds
|
|
158
185
|
}
|
|
159
186
|
|
|
160
187
|
export interface FetcherRequest extends Omit<
|
|
@@ -336,7 +363,9 @@ export interface FetcherOptions {
|
|
|
336
363
|
|
|
337
364
|
/**
|
|
338
365
|
* Default to true.
|
|
339
|
-
* Set to false to not throw on `!Response.ok
|
|
366
|
+
* Set to false to not throw on http errors (`!Response.ok`), but simply return the response body as-is (json parsed, etc).
|
|
367
|
+
* Retries still apply as usual.
|
|
368
|
+
* Non-http errors (network failure, timeout, body parse error) are still thrown.
|
|
340
369
|
*/
|
|
341
370
|
throwHttpErrors?: boolean
|
|
342
371
|
|
|
@@ -404,6 +433,7 @@ export type FetcherResponseType =
|
|
|
404
433
|
| 'text'
|
|
405
434
|
| 'void'
|
|
406
435
|
| 'arrayBuffer'
|
|
436
|
+
| 'bytes'
|
|
407
437
|
| 'blob'
|
|
408
438
|
| 'readableStream'
|
|
409
439
|
|
package/src/http/fetcher.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
_omit,
|
|
27
27
|
_pick,
|
|
28
28
|
} from '../object/object.util.js'
|
|
29
|
-
import {
|
|
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'
|
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
FetcherBeforeRetryHook,
|
|
44
44
|
FetcherCfg,
|
|
45
45
|
FetcherGraphQLOptions,
|
|
46
|
+
FetcherInitHook,
|
|
46
47
|
FetcherNormalizedCfg,
|
|
47
48
|
FetcherOnErrorHook,
|
|
48
49
|
FetcherOptions,
|
|
@@ -50,6 +51,7 @@ import type {
|
|
|
50
51
|
FetcherResponse,
|
|
51
52
|
FetcherResponseType,
|
|
52
53
|
FetcherRetryOptions,
|
|
54
|
+
FetcherSuccessResponse,
|
|
53
55
|
FetchFunction,
|
|
54
56
|
GraphQLResponse,
|
|
55
57
|
RequestInitNormalized,
|
|
@@ -68,7 +70,7 @@ export class Fetcher {
|
|
|
68
70
|
*
|
|
69
71
|
* Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
|
|
70
72
|
*/
|
|
71
|
-
static readonly VERSION =
|
|
73
|
+
static readonly VERSION = 5
|
|
72
74
|
/**
|
|
73
75
|
* userAgent is statically exposed as Fetcher.userAgent.
|
|
74
76
|
* It can be modified globally, and will be used (read) at the start of every request.
|
|
@@ -96,7 +98,9 @@ export class Fetcher {
|
|
|
96
98
|
})
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
if (method === 'HEAD')
|
|
101
|
+
if (method === 'HEAD') {
|
|
102
|
+
continue // responseType=text
|
|
103
|
+
}
|
|
100
104
|
;(this as any)[`${m}Text`] = async (url: string, opt?: FetcherOptions): Promise<string> => {
|
|
101
105
|
return await this.fetch<string>({
|
|
102
106
|
url,
|
|
@@ -141,8 +145,19 @@ export class Fetcher {
|
|
|
141
145
|
return this
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Init hooks run lazily, once per Fetcher instance, before the first request.
|
|
150
|
+
* See FetcherInitHook docs.
|
|
151
|
+
*/
|
|
152
|
+
onInit(hook: FetcherInitHook): this {
|
|
153
|
+
;(this.cfg.hooks.init ||= []).push(hook)
|
|
154
|
+
return this
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
cfg: FetcherNormalizedCfg
|
|
145
158
|
|
|
159
|
+
private initPromise?: Promise<void>
|
|
160
|
+
|
|
146
161
|
static create(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
|
|
147
162
|
return new Fetcher(cfg)
|
|
148
163
|
}
|
|
@@ -178,10 +193,9 @@ export class Fetcher {
|
|
|
178
193
|
* - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
|
|
179
194
|
*
|
|
180
195
|
* Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
|
|
181
|
-
*
|
|
182
|
-
* @experimental
|
|
183
196
|
*/
|
|
184
197
|
async queryGraphQL<T = unknown>(opt: FetcherGraphQLOptions): Promise<T> {
|
|
198
|
+
opt = { ...opt } // avoid mutating the input options
|
|
185
199
|
opt.method ||= this.cfg.init.method // defaults to GET
|
|
186
200
|
|
|
187
201
|
const payload: AnyObject = _filterFalsyValues({
|
|
@@ -236,6 +250,18 @@ export class Fetcher {
|
|
|
236
250
|
return data
|
|
237
251
|
}
|
|
238
252
|
|
|
253
|
+
// responseType=bytes
|
|
254
|
+
/**
|
|
255
|
+
* Returns response body as Uint8Array.
|
|
256
|
+
*/
|
|
257
|
+
async getBytes(url: string, opt?: FetcherOptions): Promise<Uint8Array> {
|
|
258
|
+
return await this.fetch({
|
|
259
|
+
url,
|
|
260
|
+
responseType: 'bytes',
|
|
261
|
+
...opt,
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
239
265
|
// responseType=readableStream
|
|
240
266
|
/**
|
|
241
267
|
* Returns raw fetchResponse.body, which is a ReadableStream<Uint8Array>
|
|
@@ -255,11 +281,30 @@ export class Fetcher {
|
|
|
255
281
|
}
|
|
256
282
|
|
|
257
283
|
async fetch<T = unknown>(opt: FetcherOptions): Promise<T> {
|
|
284
|
+
const res = await this.doFetch<T>(opt)
|
|
285
|
+
if (res.err && (res.req.throwHttpErrors || res.fetchResponse?.ok !== false)) {
|
|
286
|
+
// With throwHttpErrors=false only http errors (response received, but !ok) are returned instead of thrown.
|
|
287
|
+
// Other errors (network failure, timeout, body parsing) are still thrown.
|
|
288
|
+
throw res.err
|
|
289
|
+
}
|
|
290
|
+
return res.body as T
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
|
|
295
|
+
* Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
|
|
296
|
+
* while still throwing on errors (unlike `doFetch`).
|
|
297
|
+
*
|
|
298
|
+
* Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
|
|
299
|
+
* otherwise the returned FetcherSuccessResponse type would lie.
|
|
300
|
+
* Use `doFetch` if you don't want throwing.
|
|
301
|
+
*/
|
|
302
|
+
async fetchWithMeta<T = unknown>(opt: FetcherOptions): Promise<FetcherSuccessResponse<T>> {
|
|
258
303
|
const res = await this.doFetch<T>(opt)
|
|
259
304
|
if (res.err) {
|
|
260
305
|
throw res.err
|
|
261
306
|
}
|
|
262
|
-
return res
|
|
307
|
+
return res
|
|
263
308
|
}
|
|
264
309
|
|
|
265
310
|
/**
|
|
@@ -298,11 +343,22 @@ export class Fetcher {
|
|
|
298
343
|
/**
|
|
299
344
|
* Returns FetcherResponse.
|
|
300
345
|
* Never throws, returns `err` property in the response instead.
|
|
346
|
+
* (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
|
|
301
347
|
* Use this method instead of `throwHttpErrors: false` or try-catching.
|
|
302
348
|
*
|
|
303
|
-
* Note: responseType defaults to
|
|
349
|
+
* Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
|
|
304
350
|
*/
|
|
305
351
|
async doFetch<T = unknown>(opt: FetcherOptions): Promise<FetcherResponse<T>> {
|
|
352
|
+
if (this.cfg.hooks.init) {
|
|
353
|
+
try {
|
|
354
|
+
await (this.initPromise ??= this.runInitHooks())
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// Reset, so init hooks are re-attempted on the next request
|
|
357
|
+
this.initPromise = undefined
|
|
358
|
+
throw err
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
306
362
|
const req = this.normalizeOptions(opt)
|
|
307
363
|
const { logger } = this.cfg
|
|
308
364
|
const {
|
|
@@ -331,6 +387,7 @@ export class Fetcher {
|
|
|
331
387
|
|
|
332
388
|
while (!res.retryStatus.retryStopped) {
|
|
333
389
|
req.started = Date.now() as UnixTimestampMillis
|
|
390
|
+
res.body = undefined
|
|
334
391
|
|
|
335
392
|
req.init.signal = abortSignalAnyOrUndefined([
|
|
336
393
|
abortSignalTimeoutOrUndefined(timeoutMillis),
|
|
@@ -350,10 +407,10 @@ export class Fetcher {
|
|
|
350
407
|
}
|
|
351
408
|
|
|
352
409
|
try {
|
|
353
|
-
res.fetchResponse = await (
|
|
410
|
+
res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(
|
|
354
411
|
req.fullUrl,
|
|
355
412
|
req.init,
|
|
356
|
-
|
|
413
|
+
req.fetchFn,
|
|
357
414
|
)
|
|
358
415
|
res.ok = res.fetchResponse.ok
|
|
359
416
|
// important to set it to undefined, otherwise it can keep the previous value (from previous try)
|
|
@@ -370,14 +427,15 @@ export class Fetcher {
|
|
|
370
427
|
res.statusFamily = this.getStatusFamily(res)
|
|
371
428
|
res.statusCode = res.fetchResponse?.status
|
|
372
429
|
|
|
373
|
-
if (res.fetchResponse?.ok
|
|
430
|
+
if (res.fetchResponse?.ok) {
|
|
374
431
|
try {
|
|
375
432
|
// We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
|
|
376
433
|
await pTimeout(
|
|
377
434
|
async () =>
|
|
378
435
|
await this.onOkResponse(res as FetcherResponse<T> & { fetchResponse: Response }),
|
|
379
436
|
{
|
|
380
|
-
timeout
|
|
437
|
+
// 0 means "no timeout" for pTimeout
|
|
438
|
+
timeout: timeoutMillis ?? 0,
|
|
381
439
|
name: 'Fetcher.downloadBody',
|
|
382
440
|
},
|
|
383
441
|
)
|
|
@@ -414,6 +472,12 @@ export class Fetcher {
|
|
|
414
472
|
return res
|
|
415
473
|
}
|
|
416
474
|
|
|
475
|
+
private async runInitHooks(): Promise<void> {
|
|
476
|
+
for (const hook of this.cfg.hooks.init || []) {
|
|
477
|
+
await hook(this.cfg)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
417
481
|
private async onOkResponse(
|
|
418
482
|
res: FetcherResponse<any> & { fetchResponse: Response },
|
|
419
483
|
): Promise<void> {
|
|
@@ -441,9 +505,17 @@ export class Fetcher {
|
|
|
441
505
|
} else if (responseType === 'text') {
|
|
442
506
|
res.body = res.fetchResponse.body ? await res.fetchResponse.text() : ''
|
|
443
507
|
} else if (responseType === 'arrayBuffer') {
|
|
444
|
-
res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() :
|
|
508
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : new ArrayBuffer(0)
|
|
509
|
+
} else if (responseType === 'bytes') {
|
|
510
|
+
// Not using fetchResponse.bytes(), as it's unavailable in older browsers (e.g Safari <18.4)
|
|
511
|
+
res.body = res.fetchResponse.body
|
|
512
|
+
? new Uint8Array(await res.fetchResponse.arrayBuffer())
|
|
513
|
+
: new Uint8Array()
|
|
445
514
|
} else if (responseType === 'blob') {
|
|
446
|
-
res.body = res.fetchResponse.body ? await res.fetchResponse.blob() :
|
|
515
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : new Blob()
|
|
516
|
+
} else if (responseType === 'void') {
|
|
517
|
+
// Cancel the body (without downloading it), to free the underlying connection for reuse
|
|
518
|
+
await res.fetchResponse.body?.cancel()
|
|
447
519
|
} else if (responseType === 'readableStream') {
|
|
448
520
|
res.body = res.fetchResponse.body
|
|
449
521
|
|
|
@@ -455,8 +527,7 @@ export class Fetcher {
|
|
|
455
527
|
|
|
456
528
|
res.retryStatus.retryStopped = true
|
|
457
529
|
|
|
458
|
-
|
|
459
|
-
if ((!res.err || !req.throwHttpErrors) && req.logResponse) {
|
|
530
|
+
if (req.logResponse) {
|
|
460
531
|
const { retryAttempt } = res.retryStatus
|
|
461
532
|
const { logger } = this.cfg
|
|
462
533
|
logger.log(
|
|
@@ -596,16 +667,33 @@ export class Fetcher {
|
|
|
596
667
|
if (retryStatus.retryStopped) return
|
|
597
668
|
|
|
598
669
|
retryStatus.retryAttempt++
|
|
670
|
+
const timeout = this.getRetryTimeout(res)
|
|
671
|
+
if (timeout === null) {
|
|
672
|
+
this.cfg.logger.warn(
|
|
673
|
+
`${res.signature} server-indicated delay exceeds maxRetryAfter, will not retry`,
|
|
674
|
+
)
|
|
675
|
+
retryStatus.retryStopped = true
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
// Increase the timeout for the next possible retry
|
|
599
679
|
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
|
|
600
680
|
|
|
601
|
-
const timeout = this.getRetryTimeout(res)
|
|
602
681
|
if (res.req.debug) {
|
|
603
682
|
this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`)
|
|
604
683
|
}
|
|
605
|
-
await
|
|
684
|
+
await pDelaySignal(timeout, res.req.signal)
|
|
685
|
+
if (res.req.signal?.aborted) {
|
|
686
|
+
// Aborted while waiting for the retry - stop, without issuing another request
|
|
687
|
+
retryStatus.retryStopped = true
|
|
688
|
+
}
|
|
606
689
|
}
|
|
607
690
|
|
|
608
|
-
|
|
691
|
+
/**
|
|
692
|
+
* Returns the delay before the next retry attempt.
|
|
693
|
+
* Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
|
|
694
|
+
* meaning the retry should not be attempted at all.
|
|
695
|
+
*/
|
|
696
|
+
private getRetryTimeout(res: FetcherResponse): NumberOfMilliseconds | null {
|
|
609
697
|
let timeout: NumberOfMilliseconds = 0
|
|
610
698
|
|
|
611
699
|
// Handling http 429 with specific retry headers
|
|
@@ -615,12 +703,19 @@ export class Fetcher {
|
|
|
615
703
|
res.fetchResponse.headers.get('retry-after') ??
|
|
616
704
|
res.fetchResponse.headers.get('x-ratelimit-reset')
|
|
617
705
|
if (retryAfterStr) {
|
|
618
|
-
|
|
619
|
-
|
|
706
|
+
const retryAfterNumber = Number(retryAfterStr)
|
|
707
|
+
if (retryAfterNumber) {
|
|
708
|
+
if (retryAfterNumber > 10 ** 9) {
|
|
709
|
+
// Value is too large to be a "seconds from now" delay,
|
|
710
|
+
// treat it as a UnixTimestamp instead (e.g GitHub sends `x-ratelimit-reset` like that)
|
|
711
|
+
timeout = retryAfterNumber * 1000 - Date.now()
|
|
712
|
+
} else {
|
|
713
|
+
timeout = retryAfterNumber * 1000
|
|
714
|
+
}
|
|
620
715
|
} else {
|
|
621
716
|
const date = new Date(retryAfterStr)
|
|
622
|
-
if (!Number.isNaN(date)) {
|
|
623
|
-
timeout =
|
|
717
|
+
if (!Number.isNaN(date.getTime())) {
|
|
718
|
+
timeout = date.getTime() - Date.now()
|
|
624
719
|
}
|
|
625
720
|
}
|
|
626
721
|
|
|
@@ -631,12 +726,16 @@ export class Fetcher {
|
|
|
631
726
|
}
|
|
632
727
|
}
|
|
633
728
|
|
|
634
|
-
if (
|
|
635
|
-
|
|
636
|
-
|
|
729
|
+
if (timeout) {
|
|
730
|
+
if (timeout > res.req.retry.maxRetryAfter) {
|
|
731
|
+
return null
|
|
732
|
+
}
|
|
733
|
+
// Server-indicated delay is honored as-is (a negative value means "retry now")
|
|
734
|
+
return Math.max(0, timeout)
|
|
637
735
|
}
|
|
638
736
|
|
|
639
|
-
|
|
737
|
+
const noise = Math.random() * 500
|
|
738
|
+
return res.retryStatus.retryTimeout + noise
|
|
640
739
|
}
|
|
641
740
|
|
|
642
741
|
/**
|
|
@@ -775,6 +874,7 @@ export class Fetcher {
|
|
|
775
874
|
..._pick(this.cfg, [
|
|
776
875
|
'timeoutSeconds',
|
|
777
876
|
'retryPost',
|
|
877
|
+
'retry3xx',
|
|
778
878
|
'retry4xx',
|
|
779
879
|
'retry5xx',
|
|
780
880
|
'responseType',
|
|
@@ -786,6 +886,8 @@ export class Fetcher {
|
|
|
786
886
|
'debug',
|
|
787
887
|
'throwHttpErrors',
|
|
788
888
|
'errorData',
|
|
889
|
+
'fetchFn',
|
|
890
|
+
'overrideFetchFn',
|
|
789
891
|
]),
|
|
790
892
|
started: Date.now() as UnixTimestampMillis,
|
|
791
893
|
..._omit(opt, ['method', 'headers', 'credentials']),
|
|
@@ -839,18 +941,19 @@ export class Fetcher {
|
|
|
839
941
|
|
|
840
942
|
// setup request body
|
|
841
943
|
// Unless it's a well-defined input type (json, text) - content-type is set automatically by the native fetch
|
|
944
|
+
// Explicitly passed `content-type` header always wins
|
|
842
945
|
if (opt.json !== undefined) {
|
|
843
946
|
req.init.body = JSON.stringify(opt.json)
|
|
844
|
-
req.init.headers['content-type']
|
|
947
|
+
req.init.headers['content-type'] ||= 'application/json'
|
|
845
948
|
} else if (opt.text !== undefined) {
|
|
846
949
|
req.init.body = opt.text
|
|
847
|
-
req.init.headers['content-type']
|
|
950
|
+
req.init.headers['content-type'] ||= 'text/plain'
|
|
848
951
|
} else if (opt.form) {
|
|
849
952
|
if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
|
|
850
953
|
req.init.body = opt.form
|
|
851
954
|
} else {
|
|
852
955
|
req.init.body = new URLSearchParams(opt.form)
|
|
853
|
-
req.init.headers['content-type']
|
|
956
|
+
req.init.headers['content-type'] ||= 'application/x-www-form-urlencoded'
|
|
854
957
|
}
|
|
855
958
|
} else if (opt.body !== undefined) {
|
|
856
959
|
req.init.body = opt.body
|
|
@@ -873,6 +976,7 @@ const acceptByResponseType: Record<FetcherResponseType, string> = {
|
|
|
873
976
|
void: '*/*',
|
|
874
977
|
readableStream: 'application/octet-stream',
|
|
875
978
|
arrayBuffer: 'application/octet-stream',
|
|
979
|
+
bytes: 'application/octet-stream',
|
|
876
980
|
blob: 'application/octet-stream',
|
|
877
981
|
}
|
|
878
982
|
|
|
@@ -881,4 +985,5 @@ const defaultRetryOptions: FetcherRetryOptions = {
|
|
|
881
985
|
timeout: 1000,
|
|
882
986
|
timeoutMax: 30_000,
|
|
883
987
|
timeoutMultiplier: 2,
|
|
988
|
+
maxRetryAfter: 600_000, // 10 minutes
|
|
884
989
|
}
|
package/src/promise/pDelay.ts
CHANGED
|
@@ -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
|
/**
|