@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.
- package/dist/http/fetcher.d.ts +43 -5
- package/dist/http/fetcher.js +166 -29
- package/dist/http/fetcher.model.d.ts +47 -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 +47 -1
- package/src/http/fetcher.ts +179 -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,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
|
|
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,
|
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,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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
493
|
-
|
|
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 =
|
|
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 (
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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']
|
|
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']
|
|
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']
|
|
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
|
|
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,
|
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.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": "
|
|
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
|
|
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
|
|
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'
|
|
@@ -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 =
|
|
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')
|
|
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
|
|
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
|
|
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 (
|
|
424
|
+
res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(
|
|
354
425
|
req.fullUrl,
|
|
355
426
|
req.init,
|
|
356
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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 =
|
|
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 (
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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']
|
|
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']
|
|
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']
|
|
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
|
}
|
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
|
/**
|