@naturalcycles/js-lib 14.146.0 → 14.148.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 +4 -4
- package/dist/http/fetcher.js +61 -30
- package/dist/http/fetcher.model.d.ts +24 -8
- package/dist-esm/http/fetcher.js +59 -31
- package/package.json +1 -1
- package/src/http/fetcher.model.ts +25 -8
- package/src/http/fetcher.ts +67 -34
package/dist/http/fetcher.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
|
-
import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherNormalizedCfg, FetcherOptions,
|
|
2
|
+
import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherNormalizedCfg, FetcherOptions, FetcherResponse } from './fetcher.model';
|
|
3
3
|
/**
|
|
4
4
|
* Experimental wrapper around Fetch.
|
|
5
5
|
* Works in both Browser and Node, using `globalThis.fetch`.
|
|
@@ -37,14 +37,13 @@ export declare class Fetcher {
|
|
|
37
37
|
* https://css-tricks.com/web-streams-everywhere-and-fetch-for-node-js/
|
|
38
38
|
*/
|
|
39
39
|
getReadableStream(url: string, opt?: FetcherOptions<ReadableStream<Uint8Array>>): Promise<ReadableStream<Uint8Array>>;
|
|
40
|
-
fetch<T = unknown>(
|
|
40
|
+
fetch<T = unknown>(opt: FetcherOptions<T>): Promise<T>;
|
|
41
41
|
/**
|
|
42
42
|
* Returns FetcherResponse.
|
|
43
43
|
* Never throws, returns `err` property in the response instead.
|
|
44
44
|
* Use this method instead of `throwHttpErrors: false` or try-catching.
|
|
45
45
|
*/
|
|
46
|
-
doFetch<T = unknown>(
|
|
47
|
-
doFetchRequest<T = unknown>(req: FetcherRequest<T>): Promise<FetcherResponse<T>>;
|
|
46
|
+
doFetch<T = unknown>(opt: FetcherOptions<T>): Promise<FetcherResponse<T>>;
|
|
48
47
|
private onOkResponse;
|
|
49
48
|
/**
|
|
50
49
|
* This method exists to be able to easily mock it.
|
|
@@ -52,6 +51,7 @@ export declare class Fetcher {
|
|
|
52
51
|
callNativeFetch(url: string, init: RequestInit): Promise<Response>;
|
|
53
52
|
private onNotOkResponse;
|
|
54
53
|
private processRetry;
|
|
54
|
+
private getRetryTimeout;
|
|
55
55
|
/**
|
|
56
56
|
* Default is yes,
|
|
57
57
|
* unless there's reason not to (e.g method is POST).
|
package/dist/http/fetcher.js
CHANGED
|
@@ -31,7 +31,8 @@ class Fetcher {
|
|
|
31
31
|
http_model_1.HTTP_METHODS.forEach(method => {
|
|
32
32
|
const m = method.toLowerCase();
|
|
33
33
|
this[`${m}Void`] = async (url, opt) => {
|
|
34
|
-
return await this.fetch(
|
|
34
|
+
return await this.fetch({
|
|
35
|
+
url,
|
|
35
36
|
method,
|
|
36
37
|
mode: 'void',
|
|
37
38
|
...opt,
|
|
@@ -41,14 +42,16 @@ class Fetcher {
|
|
|
41
42
|
return // mode=text
|
|
42
43
|
;
|
|
43
44
|
this[`${m}Text`] = async (url, opt) => {
|
|
44
|
-
return await this.fetch(
|
|
45
|
+
return await this.fetch({
|
|
46
|
+
url,
|
|
45
47
|
method,
|
|
46
48
|
mode: 'text',
|
|
47
49
|
...opt,
|
|
48
50
|
});
|
|
49
51
|
};
|
|
50
52
|
this[m] = async (url, opt) => {
|
|
51
|
-
return await this.fetch(
|
|
53
|
+
return await this.fetch({
|
|
54
|
+
url,
|
|
52
55
|
method,
|
|
53
56
|
mode: 'json',
|
|
54
57
|
...opt,
|
|
@@ -85,13 +88,14 @@ class Fetcher {
|
|
|
85
88
|
* https://css-tricks.com/web-streams-everywhere-and-fetch-for-node-js/
|
|
86
89
|
*/
|
|
87
90
|
async getReadableStream(url, opt) {
|
|
88
|
-
return await this.fetch(
|
|
91
|
+
return await this.fetch({
|
|
92
|
+
url,
|
|
89
93
|
mode: 'readableStream',
|
|
90
94
|
...opt,
|
|
91
95
|
});
|
|
92
96
|
}
|
|
93
|
-
async fetch(
|
|
94
|
-
const res = await this.doFetch(
|
|
97
|
+
async fetch(opt) {
|
|
98
|
+
const res = await this.doFetch(opt);
|
|
95
99
|
if (res.err) {
|
|
96
100
|
if (res.req.throwHttpErrors)
|
|
97
101
|
throw res.err;
|
|
@@ -104,11 +108,8 @@ class Fetcher {
|
|
|
104
108
|
* Never throws, returns `err` property in the response instead.
|
|
105
109
|
* Use this method instead of `throwHttpErrors: false` or try-catching.
|
|
106
110
|
*/
|
|
107
|
-
async doFetch(
|
|
108
|
-
const req = this.normalizeOptions(
|
|
109
|
-
return await this.doFetchRequest(req);
|
|
110
|
-
}
|
|
111
|
-
async doFetchRequest(req) {
|
|
111
|
+
async doFetch(opt) {
|
|
112
|
+
const req = this.normalizeOptions(opt);
|
|
112
113
|
const { logger } = this.cfg;
|
|
113
114
|
const { timeoutSeconds, init: { method }, } = req;
|
|
114
115
|
// setup timeout
|
|
@@ -123,9 +124,9 @@ class Fetcher {
|
|
|
123
124
|
for (const hook of this.cfg.hooks.beforeRequest || []) {
|
|
124
125
|
await hook(req);
|
|
125
126
|
}
|
|
126
|
-
const isFullUrl = req.
|
|
127
|
-
const fullUrl = isFullUrl ? new URL(req.
|
|
128
|
-
const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.
|
|
127
|
+
const isFullUrl = req.fullUrl.includes('://');
|
|
128
|
+
const fullUrl = isFullUrl ? new URL(req.fullUrl) : undefined;
|
|
129
|
+
const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.fullUrl;
|
|
129
130
|
const signature = [method, shortUrl].join(' ');
|
|
130
131
|
const res = {
|
|
131
132
|
req,
|
|
@@ -148,8 +149,10 @@ class Fetcher {
|
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
try {
|
|
151
|
-
res.fetchResponse = await this.callNativeFetch(req.
|
|
152
|
+
res.fetchResponse = await this.callNativeFetch(req.fullUrl, req.init);
|
|
152
153
|
res.ok = res.fetchResponse.ok;
|
|
154
|
+
// important to set it to undefined, otherwise it can keep the previous value (from previous try)
|
|
155
|
+
res.err = undefined;
|
|
153
156
|
}
|
|
154
157
|
catch (err) {
|
|
155
158
|
// For example, CORS error would result in "TypeError: failed to fetch" here
|
|
@@ -171,9 +174,9 @@ class Fetcher {
|
|
|
171
174
|
await hook(res);
|
|
172
175
|
}
|
|
173
176
|
if (req.paginate && res.ok) {
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
176
|
-
return await this.
|
|
177
|
+
const proceeed = await req.paginate(res, opt);
|
|
178
|
+
if (proceeed) {
|
|
179
|
+
return await this.doFetch(opt);
|
|
177
180
|
}
|
|
178
181
|
}
|
|
179
182
|
return res;
|
|
@@ -280,7 +283,7 @@ class Fetcher {
|
|
|
280
283
|
// Enabled, cause `data` is not printed by default when error is HttpError
|
|
281
284
|
// method: req.method,
|
|
282
285
|
// tryCount: req.tryCount,
|
|
283
|
-
requestUrl: res.req.
|
|
286
|
+
requestUrl: res.req.fullUrl,
|
|
284
287
|
requestBaseUrl: this.cfg.baseUrl || null,
|
|
285
288
|
requestMethod: res.req.init.method,
|
|
286
289
|
requestSignature: res.signature,
|
|
@@ -307,12 +310,11 @@ class Fetcher {
|
|
|
307
310
|
// but we should log all previous errors, otherwise they are lost.
|
|
308
311
|
// Here is the right place where we know it's not the "last error"
|
|
309
312
|
if (res.err) {
|
|
310
|
-
const { retryAttempt } = retryStatus;
|
|
311
313
|
this.cfg.logger.error([
|
|
312
314
|
' <<',
|
|
313
315
|
res.fetchResponse?.status || 0,
|
|
314
316
|
res.signature,
|
|
315
|
-
`try#${retryAttempt + 1}/${count + 1}`,
|
|
317
|
+
`try#${retryStatus.retryAttempt + 1}/${count + 1}`,
|
|
316
318
|
(0, time_util_1._since)(res.req.started),
|
|
317
319
|
]
|
|
318
320
|
.filter(Boolean)
|
|
@@ -320,8 +322,36 @@ class Fetcher {
|
|
|
320
322
|
}
|
|
321
323
|
retryStatus.retryAttempt++;
|
|
322
324
|
retryStatus.retryTimeout = (0, number_util_1._clamp)(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
+
await (0, pDelay_1.pDelay)(this.getRetryTimeout(res));
|
|
326
|
+
}
|
|
327
|
+
getRetryTimeout(res) {
|
|
328
|
+
let timeout = 0;
|
|
329
|
+
// Handling http 429 with specific retry headers
|
|
330
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
|
331
|
+
if (res.fetchResponse && [429, 503].includes(res.fetchResponse.status)) {
|
|
332
|
+
const retryAfterStr = res.fetchResponse.headers.get('retry-after') ??
|
|
333
|
+
res.fetchResponse.headers.get('x-ratelimit-reset');
|
|
334
|
+
if (retryAfterStr) {
|
|
335
|
+
if (Number(retryAfterStr)) {
|
|
336
|
+
timeout = Number(retryAfterStr) * 1000;
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
const date = new Date(retryAfterStr);
|
|
340
|
+
if (!isNaN(date)) {
|
|
341
|
+
timeout = Number(date) - Date.now();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.cfg.logger.log(`retry-after: ${retryAfterStr}`);
|
|
345
|
+
if (!timeout) {
|
|
346
|
+
this.cfg.logger.warn(`retry-after could not be parsed`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!timeout) {
|
|
351
|
+
const noise = Math.random() * 500;
|
|
352
|
+
timeout = res.retryStatus.retryTimeout + noise;
|
|
353
|
+
}
|
|
354
|
+
return timeout;
|
|
325
355
|
}
|
|
326
356
|
/**
|
|
327
357
|
* Default is yes,
|
|
@@ -387,7 +417,7 @@ class Fetcher {
|
|
|
387
417
|
const { debug = false } = cfg;
|
|
388
418
|
const norm = (0, object_util_1._merge)({
|
|
389
419
|
baseUrl: '',
|
|
390
|
-
|
|
420
|
+
inputUrl: '',
|
|
391
421
|
mode: 'void',
|
|
392
422
|
searchParams: {},
|
|
393
423
|
timeoutSeconds: 30,
|
|
@@ -415,12 +445,11 @@ class Fetcher {
|
|
|
415
445
|
norm.init.headers = (0, object_util_1._mapKeys)(norm.init.headers, k => k.toLowerCase());
|
|
416
446
|
return norm;
|
|
417
447
|
}
|
|
418
|
-
normalizeOptions(
|
|
448
|
+
normalizeOptions(opt) {
|
|
419
449
|
const { timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry, mode, jsonReviver, } = this.cfg;
|
|
420
450
|
const req = {
|
|
421
451
|
started: Date.now(),
|
|
422
452
|
mode,
|
|
423
|
-
url,
|
|
424
453
|
timeoutSeconds,
|
|
425
454
|
throwHttpErrors,
|
|
426
455
|
retryPost,
|
|
@@ -428,6 +457,8 @@ class Fetcher {
|
|
|
428
457
|
retry5xx,
|
|
429
458
|
jsonReviver,
|
|
430
459
|
...(0, object_util_1._omit)(opt, ['method', 'headers', 'credentials']),
|
|
460
|
+
inputUrl: opt.url || '',
|
|
461
|
+
fullUrl: opt.url || '',
|
|
431
462
|
retry: {
|
|
432
463
|
...retry,
|
|
433
464
|
...(0, object_util_1._filterUndefinedValues)(opt.retry || {}),
|
|
@@ -444,11 +475,11 @@ class Fetcher {
|
|
|
444
475
|
// setup url
|
|
445
476
|
const baseUrl = opt.baseUrl || this.cfg.baseUrl;
|
|
446
477
|
if (baseUrl) {
|
|
447
|
-
if (
|
|
478
|
+
if (req.fullUrl.startsWith('/')) {
|
|
448
479
|
console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
|
|
449
|
-
|
|
480
|
+
req.fullUrl = req.fullUrl.slice(1);
|
|
450
481
|
}
|
|
451
|
-
req.
|
|
482
|
+
req.fullUrl = `${baseUrl}/${req.inputUrl}`;
|
|
452
483
|
}
|
|
453
484
|
const searchParams = (0, object_util_1._filterUndefinedValues)({
|
|
454
485
|
...this.cfg.searchParams,
|
|
@@ -456,7 +487,7 @@ class Fetcher {
|
|
|
456
487
|
});
|
|
457
488
|
if (Object.keys(searchParams).length) {
|
|
458
489
|
const qs = new URLSearchParams(searchParams).toString();
|
|
459
|
-
req.
|
|
490
|
+
req.fullUrl += req.fullUrl.includes('?') ? '&' : '?' + qs;
|
|
460
491
|
}
|
|
461
492
|
// setup request body
|
|
462
493
|
if (opt.json !== undefined) {
|
|
@@ -2,7 +2,7 @@ import type { CommonLogger } from '../log/commonLogger';
|
|
|
2
2
|
import type { Promisable } from '../typeFest';
|
|
3
3
|
import type { Reviver, UnixTimestampMillisNumber } from '../types';
|
|
4
4
|
import type { HttpMethod, HttpStatusFamily } from './http.model';
|
|
5
|
-
export interface FetcherNormalizedCfg extends Required<FetcherCfg>, Omit<FetcherRequest, 'started'> {
|
|
5
|
+
export interface FetcherNormalizedCfg extends Required<FetcherCfg>, Omit<FetcherRequest, 'started' | 'fullUrl'> {
|
|
6
6
|
logger: CommonLogger;
|
|
7
7
|
searchParams: Record<string, any>;
|
|
8
8
|
}
|
|
@@ -77,8 +77,16 @@ export interface FetcherRetryOptions {
|
|
|
77
77
|
timeoutMax: number;
|
|
78
78
|
timeoutMultiplier: number;
|
|
79
79
|
}
|
|
80
|
-
export interface FetcherRequest<BODY = unknown> extends Omit<FetcherOptions<BODY>, 'method' | 'headers' | 'baseUrl'> {
|
|
81
|
-
|
|
80
|
+
export interface FetcherRequest<BODY = unknown> extends Omit<FetcherOptions<BODY>, 'method' | 'headers' | 'baseUrl' | 'url'> {
|
|
81
|
+
/**
|
|
82
|
+
* inputUrl is only the part that was passed in the request,
|
|
83
|
+
* without baseUrl or searchParams.
|
|
84
|
+
*/
|
|
85
|
+
inputUrl: string;
|
|
86
|
+
/**
|
|
87
|
+
* fullUrl includes baseUrl and searchParams.
|
|
88
|
+
*/
|
|
89
|
+
fullUrl: string;
|
|
82
90
|
init: RequestInitNormalized;
|
|
83
91
|
mode: FetcherMode;
|
|
84
92
|
throwHttpErrors: boolean;
|
|
@@ -91,6 +99,11 @@ export interface FetcherRequest<BODY = unknown> extends Omit<FetcherOptions<BODY
|
|
|
91
99
|
}
|
|
92
100
|
export interface FetcherOptions<BODY = unknown> {
|
|
93
101
|
method?: HttpMethod;
|
|
102
|
+
/**
|
|
103
|
+
* If defined - this `url` will override the original given `url`.
|
|
104
|
+
* baseUrl (and searchParams) will still modify it.
|
|
105
|
+
*/
|
|
106
|
+
url?: string;
|
|
94
107
|
baseUrl?: string;
|
|
95
108
|
throwHttpErrors?: boolean;
|
|
96
109
|
/**
|
|
@@ -138,15 +151,18 @@ export interface FetcherOptions<BODY = unknown> {
|
|
|
138
151
|
/**
|
|
139
152
|
* Allows to walk over multiple pages of results.
|
|
140
153
|
* Paginate take a function.
|
|
141
|
-
* Function has access to
|
|
154
|
+
* Function has access to FetcherResponse and FetcherOptions
|
|
142
155
|
* and has to make a decision to continue pagination or not.
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
156
|
+
*
|
|
157
|
+
* Return false to stop pagination.
|
|
158
|
+
* Return true to continue pagination.
|
|
159
|
+
* Feel free to mutate/modify opt (FetcherOptions), for example:
|
|
160
|
+
*
|
|
161
|
+
* opt.searchParams!['page']++
|
|
146
162
|
*
|
|
147
163
|
* @experimental
|
|
148
164
|
*/
|
|
149
|
-
paginate?: (
|
|
165
|
+
paginate?: (res: FetcherSuccessResponse<BODY>, opt: FetcherOptions<BODY>) => Promisable<boolean>;
|
|
150
166
|
}
|
|
151
167
|
export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
|
|
152
168
|
method: HttpMethod;
|
package/dist-esm/http/fetcher.js
CHANGED
|
@@ -28,16 +28,19 @@ export class Fetcher {
|
|
|
28
28
|
HTTP_METHODS.forEach(method => {
|
|
29
29
|
const m = method.toLowerCase();
|
|
30
30
|
this[`${m}Void`] = async (url, opt) => {
|
|
31
|
-
return await this.fetch(
|
|
31
|
+
return await this.fetch(Object.assign({ url,
|
|
32
|
+
method, mode: 'void' }, opt));
|
|
32
33
|
};
|
|
33
34
|
if (method === 'HEAD')
|
|
34
35
|
return // mode=text
|
|
35
36
|
;
|
|
36
37
|
this[`${m}Text`] = async (url, opt) => {
|
|
37
|
-
return await this.fetch(
|
|
38
|
+
return await this.fetch(Object.assign({ url,
|
|
39
|
+
method, mode: 'text' }, opt));
|
|
38
40
|
};
|
|
39
41
|
this[m] = async (url, opt) => {
|
|
40
|
-
return await this.fetch(
|
|
42
|
+
return await this.fetch(Object.assign({ url,
|
|
43
|
+
method, mode: 'json' }, opt));
|
|
41
44
|
};
|
|
42
45
|
});
|
|
43
46
|
}
|
|
@@ -73,10 +76,10 @@ export class Fetcher {
|
|
|
73
76
|
* https://css-tricks.com/web-streams-everywhere-and-fetch-for-node-js/
|
|
74
77
|
*/
|
|
75
78
|
async getReadableStream(url, opt) {
|
|
76
|
-
return await this.fetch(
|
|
79
|
+
return await this.fetch(Object.assign({ url, mode: 'readableStream' }, opt));
|
|
77
80
|
}
|
|
78
|
-
async fetch(
|
|
79
|
-
const res = await this.doFetch(
|
|
81
|
+
async fetch(opt) {
|
|
82
|
+
const res = await this.doFetch(opt);
|
|
80
83
|
if (res.err) {
|
|
81
84
|
if (res.req.throwHttpErrors)
|
|
82
85
|
throw res.err;
|
|
@@ -89,12 +92,9 @@ export class Fetcher {
|
|
|
89
92
|
* Never throws, returns `err` property in the response instead.
|
|
90
93
|
* Use this method instead of `throwHttpErrors: false` or try-catching.
|
|
91
94
|
*/
|
|
92
|
-
async doFetch(
|
|
93
|
-
const req = this.normalizeOptions(url, opt);
|
|
94
|
-
return await this.doFetchRequest(req);
|
|
95
|
-
}
|
|
96
|
-
async doFetchRequest(req) {
|
|
95
|
+
async doFetch(opt) {
|
|
97
96
|
var _a;
|
|
97
|
+
const req = this.normalizeOptions(opt);
|
|
98
98
|
const { logger } = this.cfg;
|
|
99
99
|
const { timeoutSeconds, init: { method }, } = req;
|
|
100
100
|
// setup timeout
|
|
@@ -109,9 +109,9 @@ export class Fetcher {
|
|
|
109
109
|
for (const hook of this.cfg.hooks.beforeRequest || []) {
|
|
110
110
|
await hook(req);
|
|
111
111
|
}
|
|
112
|
-
const isFullUrl = req.
|
|
113
|
-
const fullUrl = isFullUrl ? new URL(req.
|
|
114
|
-
const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.
|
|
112
|
+
const isFullUrl = req.fullUrl.includes('://');
|
|
113
|
+
const fullUrl = isFullUrl ? new URL(req.fullUrl) : undefined;
|
|
114
|
+
const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.fullUrl;
|
|
115
115
|
const signature = [method, shortUrl].join(' ');
|
|
116
116
|
const res = {
|
|
117
117
|
req,
|
|
@@ -134,8 +134,10 @@ export class Fetcher {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
try {
|
|
137
|
-
res.fetchResponse = await this.callNativeFetch(req.
|
|
137
|
+
res.fetchResponse = await this.callNativeFetch(req.fullUrl, req.init);
|
|
138
138
|
res.ok = res.fetchResponse.ok;
|
|
139
|
+
// important to set it to undefined, otherwise it can keep the previous value (from previous try)
|
|
140
|
+
res.err = undefined;
|
|
139
141
|
}
|
|
140
142
|
catch (err) {
|
|
141
143
|
// For example, CORS error would result in "TypeError: failed to fetch" here
|
|
@@ -157,9 +159,9 @@ export class Fetcher {
|
|
|
157
159
|
await hook(res);
|
|
158
160
|
}
|
|
159
161
|
if (req.paginate && res.ok) {
|
|
160
|
-
const
|
|
161
|
-
if (
|
|
162
|
-
return await this.
|
|
162
|
+
const proceeed = await req.paginate(res, opt);
|
|
163
|
+
if (proceeed) {
|
|
164
|
+
return await this.doFetch(opt);
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
return res;
|
|
@@ -267,7 +269,7 @@ export class Fetcher {
|
|
|
267
269
|
// Enabled, cause `data` is not printed by default when error is HttpError
|
|
268
270
|
// method: req.method,
|
|
269
271
|
// tryCount: req.tryCount,
|
|
270
|
-
requestUrl: res.req.
|
|
272
|
+
requestUrl: res.req.fullUrl,
|
|
271
273
|
requestBaseUrl: this.cfg.baseUrl || null,
|
|
272
274
|
requestMethod: res.req.init.method,
|
|
273
275
|
requestSignature: res.signature,
|
|
@@ -295,12 +297,11 @@ export class Fetcher {
|
|
|
295
297
|
// but we should log all previous errors, otherwise they are lost.
|
|
296
298
|
// Here is the right place where we know it's not the "last error"
|
|
297
299
|
if (res.err) {
|
|
298
|
-
const { retryAttempt } = retryStatus;
|
|
299
300
|
this.cfg.logger.error([
|
|
300
301
|
' <<',
|
|
301
302
|
((_a = res.fetchResponse) === null || _a === void 0 ? void 0 : _a.status) || 0,
|
|
302
303
|
res.signature,
|
|
303
|
-
`try#${retryAttempt + 1}/${count + 1}`,
|
|
304
|
+
`try#${retryStatus.retryAttempt + 1}/${count + 1}`,
|
|
304
305
|
_since(res.req.started),
|
|
305
306
|
]
|
|
306
307
|
.filter(Boolean)
|
|
@@ -308,8 +309,36 @@ export class Fetcher {
|
|
|
308
309
|
}
|
|
309
310
|
retryStatus.retryAttempt++;
|
|
310
311
|
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
await pDelay(this.getRetryTimeout(res));
|
|
313
|
+
}
|
|
314
|
+
getRetryTimeout(res) {
|
|
315
|
+
var _a;
|
|
316
|
+
let timeout = 0;
|
|
317
|
+
// Handling http 429 with specific retry headers
|
|
318
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
|
319
|
+
if (res.fetchResponse && [429, 503].includes(res.fetchResponse.status)) {
|
|
320
|
+
const retryAfterStr = (_a = res.fetchResponse.headers.get('retry-after')) !== null && _a !== void 0 ? _a : res.fetchResponse.headers.get('x-ratelimit-reset');
|
|
321
|
+
if (retryAfterStr) {
|
|
322
|
+
if (Number(retryAfterStr)) {
|
|
323
|
+
timeout = Number(retryAfterStr) * 1000;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const date = new Date(retryAfterStr);
|
|
327
|
+
if (!isNaN(date)) {
|
|
328
|
+
timeout = Number(date) - Date.now();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
this.cfg.logger.log(`retry-after: ${retryAfterStr}`);
|
|
332
|
+
if (!timeout) {
|
|
333
|
+
this.cfg.logger.warn(`retry-after could not be parsed`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (!timeout) {
|
|
338
|
+
const noise = Math.random() * 500;
|
|
339
|
+
timeout = res.retryStatus.retryTimeout + noise;
|
|
340
|
+
}
|
|
341
|
+
return timeout;
|
|
313
342
|
}
|
|
314
343
|
/**
|
|
315
344
|
* Default is yes,
|
|
@@ -378,7 +407,7 @@ export class Fetcher {
|
|
|
378
407
|
const { debug = false } = cfg;
|
|
379
408
|
const norm = _merge({
|
|
380
409
|
baseUrl: '',
|
|
381
|
-
|
|
410
|
+
inputUrl: '',
|
|
382
411
|
mode: 'void',
|
|
383
412
|
searchParams: {},
|
|
384
413
|
timeoutSeconds: 30,
|
|
@@ -406,32 +435,31 @@ export class Fetcher {
|
|
|
406
435
|
norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase());
|
|
407
436
|
return norm;
|
|
408
437
|
}
|
|
409
|
-
normalizeOptions(
|
|
438
|
+
normalizeOptions(opt) {
|
|
410
439
|
var _a, _b;
|
|
411
440
|
const { timeoutSeconds, throwHttpErrors, retryPost, retry4xx, retry5xx, retry, mode, jsonReviver, } = this.cfg;
|
|
412
441
|
const req = Object.assign(Object.assign({ started: Date.now(), mode,
|
|
413
|
-
url,
|
|
414
442
|
timeoutSeconds,
|
|
415
443
|
throwHttpErrors,
|
|
416
444
|
retryPost,
|
|
417
445
|
retry4xx,
|
|
418
446
|
retry5xx,
|
|
419
|
-
jsonReviver }, _omit(opt, ['method', 'headers', 'credentials'])), { retry: Object.assign(Object.assign({}, retry), _filterUndefinedValues(opt.retry || {})), init: _merge(Object.assign(Object.assign({}, this.cfg.init), { method: opt.method || this.cfg.init.method, credentials: opt.credentials || this.cfg.init.credentials, redirect: ((_b = (_a = opt.followRedirects) !== null && _a !== void 0 ? _a : this.cfg.followRedirects) !== null && _b !== void 0 ? _b : true) ? 'follow' : 'error' }), {
|
|
447
|
+
jsonReviver }, _omit(opt, ['method', 'headers', 'credentials'])), { inputUrl: opt.url || '', fullUrl: opt.url || '', retry: Object.assign(Object.assign({}, retry), _filterUndefinedValues(opt.retry || {})), init: _merge(Object.assign(Object.assign({}, this.cfg.init), { method: opt.method || this.cfg.init.method, credentials: opt.credentials || this.cfg.init.credentials, redirect: ((_b = (_a = opt.followRedirects) !== null && _a !== void 0 ? _a : this.cfg.followRedirects) !== null && _b !== void 0 ? _b : true) ? 'follow' : 'error' }), {
|
|
420
448
|
headers: _mapKeys(opt.headers || {}, k => k.toLowerCase()),
|
|
421
449
|
}) });
|
|
422
450
|
// setup url
|
|
423
451
|
const baseUrl = opt.baseUrl || this.cfg.baseUrl;
|
|
424
452
|
if (baseUrl) {
|
|
425
|
-
if (
|
|
453
|
+
if (req.fullUrl.startsWith('/')) {
|
|
426
454
|
console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
|
|
427
|
-
|
|
455
|
+
req.fullUrl = req.fullUrl.slice(1);
|
|
428
456
|
}
|
|
429
|
-
req.
|
|
457
|
+
req.fullUrl = `${baseUrl}/${req.inputUrl}`;
|
|
430
458
|
}
|
|
431
459
|
const searchParams = _filterUndefinedValues(Object.assign(Object.assign({}, this.cfg.searchParams), opt.searchParams));
|
|
432
460
|
if (Object.keys(searchParams).length) {
|
|
433
461
|
const qs = new URLSearchParams(searchParams).toString();
|
|
434
|
-
req.
|
|
462
|
+
req.fullUrl += req.fullUrl.includes('?') ? '&' : '?' + qs;
|
|
435
463
|
}
|
|
436
464
|
// setup request body
|
|
437
465
|
if (opt.json !== undefined) {
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import type { HttpMethod, HttpStatusFamily } from './http.model'
|
|
|
5
5
|
|
|
6
6
|
export interface FetcherNormalizedCfg
|
|
7
7
|
extends Required<FetcherCfg>,
|
|
8
|
-
Omit<FetcherRequest, 'started'> {
|
|
8
|
+
Omit<FetcherRequest, 'started' | 'fullUrl'> {
|
|
9
9
|
logger: CommonLogger
|
|
10
10
|
searchParams: Record<string, any>
|
|
11
11
|
}
|
|
@@ -97,8 +97,16 @@ export interface FetcherRetryOptions {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
export interface FetcherRequest<BODY = unknown>
|
|
100
|
-
extends Omit<FetcherOptions<BODY>, 'method' | 'headers' | 'baseUrl'> {
|
|
101
|
-
|
|
100
|
+
extends Omit<FetcherOptions<BODY>, 'method' | 'headers' | 'baseUrl' | 'url'> {
|
|
101
|
+
/**
|
|
102
|
+
* inputUrl is only the part that was passed in the request,
|
|
103
|
+
* without baseUrl or searchParams.
|
|
104
|
+
*/
|
|
105
|
+
inputUrl: string
|
|
106
|
+
/**
|
|
107
|
+
* fullUrl includes baseUrl and searchParams.
|
|
108
|
+
*/
|
|
109
|
+
fullUrl: string
|
|
102
110
|
init: RequestInitNormalized
|
|
103
111
|
mode: FetcherMode
|
|
104
112
|
throwHttpErrors: boolean
|
|
@@ -113,6 +121,12 @@ export interface FetcherRequest<BODY = unknown>
|
|
|
113
121
|
export interface FetcherOptions<BODY = unknown> {
|
|
114
122
|
method?: HttpMethod
|
|
115
123
|
|
|
124
|
+
/**
|
|
125
|
+
* If defined - this `url` will override the original given `url`.
|
|
126
|
+
* baseUrl (and searchParams) will still modify it.
|
|
127
|
+
*/
|
|
128
|
+
url?: string
|
|
129
|
+
|
|
116
130
|
baseUrl?: string
|
|
117
131
|
|
|
118
132
|
throwHttpErrors?: boolean
|
|
@@ -173,15 +187,18 @@ export interface FetcherOptions<BODY = unknown> {
|
|
|
173
187
|
/**
|
|
174
188
|
* Allows to walk over multiple pages of results.
|
|
175
189
|
* Paginate take a function.
|
|
176
|
-
* Function has access to
|
|
190
|
+
* Function has access to FetcherResponse and FetcherOptions
|
|
177
191
|
* and has to make a decision to continue pagination or not.
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
192
|
+
*
|
|
193
|
+
* Return false to stop pagination.
|
|
194
|
+
* Return true to continue pagination.
|
|
195
|
+
* Feel free to mutate/modify opt (FetcherOptions), for example:
|
|
196
|
+
*
|
|
197
|
+
* opt.searchParams!['page']++
|
|
181
198
|
*
|
|
182
199
|
* @experimental
|
|
183
200
|
*/
|
|
184
|
-
paginate?: (
|
|
201
|
+
paginate?: (res: FetcherSuccessResponse<BODY>, opt: FetcherOptions<BODY>) => Promisable<boolean>
|
|
185
202
|
}
|
|
186
203
|
|
|
187
204
|
export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
|
package/src/http/fetcher.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { pDelay } from '../promise/pDelay'
|
|
16
16
|
import { _jsonParse, _jsonParseIfPossible } from '../string/json.util'
|
|
17
17
|
import { _since } from '../time/time.util'
|
|
18
|
+
import { NumberOfMilliseconds } from '../types'
|
|
18
19
|
import type {
|
|
19
20
|
FetcherAfterResponseHook,
|
|
20
21
|
FetcherBeforeRequestHook,
|
|
@@ -57,7 +58,8 @@ export class Fetcher {
|
|
|
57
58
|
url: string,
|
|
58
59
|
opt?: FetcherOptions<void>,
|
|
59
60
|
): Promise<void> => {
|
|
60
|
-
return await this.fetch<void>(
|
|
61
|
+
return await this.fetch<void>({
|
|
62
|
+
url,
|
|
61
63
|
method,
|
|
62
64
|
mode: 'void',
|
|
63
65
|
...opt,
|
|
@@ -69,7 +71,8 @@ export class Fetcher {
|
|
|
69
71
|
url: string,
|
|
70
72
|
opt?: FetcherOptions<string>,
|
|
71
73
|
): Promise<string> => {
|
|
72
|
-
return await this.fetch<string>(
|
|
74
|
+
return await this.fetch<string>({
|
|
75
|
+
url,
|
|
73
76
|
method,
|
|
74
77
|
mode: 'text',
|
|
75
78
|
...opt,
|
|
@@ -78,7 +81,8 @@ export class Fetcher {
|
|
|
78
81
|
|
|
79
82
|
// Default mode=json, but overridable
|
|
80
83
|
;(this as any)[m] = async <T = unknown>(url: string, opt?: FetcherOptions<T>): Promise<T> => {
|
|
81
|
-
return await this.fetch<T>(
|
|
84
|
+
return await this.fetch<T>({
|
|
85
|
+
url,
|
|
82
86
|
method,
|
|
83
87
|
mode: 'json',
|
|
84
88
|
...opt,
|
|
@@ -145,14 +149,15 @@ export class Fetcher {
|
|
|
145
149
|
url: string,
|
|
146
150
|
opt?: FetcherOptions<ReadableStream<Uint8Array>>,
|
|
147
151
|
): Promise<ReadableStream<Uint8Array>> {
|
|
148
|
-
return await this.fetch(
|
|
152
|
+
return await this.fetch({
|
|
153
|
+
url,
|
|
149
154
|
mode: 'readableStream',
|
|
150
155
|
...opt,
|
|
151
156
|
})
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
async fetch<T = unknown>(
|
|
155
|
-
const res = await this.doFetch<T>(
|
|
159
|
+
async fetch<T = unknown>(opt: FetcherOptions<T>): Promise<T> {
|
|
160
|
+
const res = await this.doFetch<T>(opt)
|
|
156
161
|
if (res.err) {
|
|
157
162
|
if (res.req.throwHttpErrors) throw res.err
|
|
158
163
|
return res as any
|
|
@@ -165,15 +170,8 @@ export class Fetcher {
|
|
|
165
170
|
* Never throws, returns `err` property in the response instead.
|
|
166
171
|
* Use this method instead of `throwHttpErrors: false` or try-catching.
|
|
167
172
|
*/
|
|
168
|
-
async doFetch<T = unknown>(
|
|
169
|
-
|
|
170
|
-
opt: FetcherOptions<T> = {},
|
|
171
|
-
): Promise<FetcherResponse<T>> {
|
|
172
|
-
const req = this.normalizeOptions(url, opt)
|
|
173
|
-
return await this.doFetchRequest<T>(req)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async doFetchRequest<T = unknown>(req: FetcherRequest<T>): Promise<FetcherResponse<T>> {
|
|
173
|
+
async doFetch<T = unknown>(opt: FetcherOptions<T>): Promise<FetcherResponse<T>> {
|
|
174
|
+
const req = this.normalizeOptions(opt)
|
|
177
175
|
const { logger } = this.cfg
|
|
178
176
|
const {
|
|
179
177
|
timeoutSeconds,
|
|
@@ -194,9 +192,9 @@ export class Fetcher {
|
|
|
194
192
|
await hook(req)
|
|
195
193
|
}
|
|
196
194
|
|
|
197
|
-
const isFullUrl = req.
|
|
198
|
-
const fullUrl = isFullUrl ? new URL(req.
|
|
199
|
-
const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.
|
|
195
|
+
const isFullUrl = req.fullUrl.includes('://')
|
|
196
|
+
const fullUrl = isFullUrl ? new URL(req.fullUrl) : undefined
|
|
197
|
+
const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.fullUrl
|
|
200
198
|
const signature = [method, shortUrl].join(' ')
|
|
201
199
|
|
|
202
200
|
const res = {
|
|
@@ -225,8 +223,10 @@ export class Fetcher {
|
|
|
225
223
|
}
|
|
226
224
|
|
|
227
225
|
try {
|
|
228
|
-
res.fetchResponse = await this.callNativeFetch(req.
|
|
226
|
+
res.fetchResponse = await this.callNativeFetch(req.fullUrl, req.init)
|
|
229
227
|
res.ok = res.fetchResponse.ok
|
|
228
|
+
// important to set it to undefined, otherwise it can keep the previous value (from previous try)
|
|
229
|
+
res.err = undefined
|
|
230
230
|
} catch (err) {
|
|
231
231
|
// For example, CORS error would result in "TypeError: failed to fetch" here
|
|
232
232
|
res.err = err as Error
|
|
@@ -249,9 +249,9 @@ export class Fetcher {
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
if (req.paginate && res.ok) {
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
return await this.
|
|
252
|
+
const proceeed = await req.paginate(res, opt)
|
|
253
|
+
if (proceeed) {
|
|
254
|
+
return await this.doFetch(opt)
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
@@ -372,7 +372,7 @@ export class Fetcher {
|
|
|
372
372
|
// Enabled, cause `data` is not printed by default when error is HttpError
|
|
373
373
|
// method: req.method,
|
|
374
374
|
// tryCount: req.tryCount,
|
|
375
|
-
requestUrl: res.req.
|
|
375
|
+
requestUrl: res.req.fullUrl,
|
|
376
376
|
requestBaseUrl: this.cfg.baseUrl || (null as any),
|
|
377
377
|
requestMethod: res.req.init.method,
|
|
378
378
|
requestSignature: res.signature,
|
|
@@ -408,13 +408,12 @@ export class Fetcher {
|
|
|
408
408
|
// but we should log all previous errors, otherwise they are lost.
|
|
409
409
|
// Here is the right place where we know it's not the "last error"
|
|
410
410
|
if (res.err) {
|
|
411
|
-
const { retryAttempt } = retryStatus
|
|
412
411
|
this.cfg.logger.error(
|
|
413
412
|
[
|
|
414
413
|
' <<',
|
|
415
414
|
res.fetchResponse?.status || 0,
|
|
416
415
|
res.signature,
|
|
417
|
-
`try#${retryAttempt + 1}/${count + 1}`,
|
|
416
|
+
`try#${retryStatus.retryAttempt + 1}/${count + 1}`,
|
|
418
417
|
_since(res.req.started),
|
|
419
418
|
]
|
|
420
419
|
.filter(Boolean)
|
|
@@ -426,8 +425,41 @@ export class Fetcher {
|
|
|
426
425
|
retryStatus.retryAttempt++
|
|
427
426
|
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
|
|
428
427
|
|
|
429
|
-
|
|
430
|
-
|
|
428
|
+
await pDelay(this.getRetryTimeout(res))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private getRetryTimeout(res: FetcherResponse): NumberOfMilliseconds {
|
|
432
|
+
let timeout: NumberOfMilliseconds = 0
|
|
433
|
+
|
|
434
|
+
// Handling http 429 with specific retry headers
|
|
435
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
|
436
|
+
if (res.fetchResponse && [429, 503].includes(res.fetchResponse.status)) {
|
|
437
|
+
const retryAfterStr =
|
|
438
|
+
res.fetchResponse.headers.get('retry-after') ??
|
|
439
|
+
res.fetchResponse.headers.get('x-ratelimit-reset')
|
|
440
|
+
if (retryAfterStr) {
|
|
441
|
+
if (Number(retryAfterStr)) {
|
|
442
|
+
timeout = Number(retryAfterStr) * 1000
|
|
443
|
+
} else {
|
|
444
|
+
const date = new Date(retryAfterStr)
|
|
445
|
+
if (!isNaN(date as any)) {
|
|
446
|
+
timeout = Number(date) - Date.now()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
this.cfg.logger.log(`retry-after: ${retryAfterStr}`)
|
|
451
|
+
if (!timeout) {
|
|
452
|
+
this.cfg.logger.warn(`retry-after could not be parsed`)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!timeout) {
|
|
458
|
+
const noise = Math.random() * 500
|
|
459
|
+
timeout = res.retryStatus.retryTimeout + noise
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return timeout
|
|
431
463
|
}
|
|
432
464
|
|
|
433
465
|
/**
|
|
@@ -495,7 +527,7 @@ export class Fetcher {
|
|
|
495
527
|
const norm: FetcherNormalizedCfg = _merge(
|
|
496
528
|
{
|
|
497
529
|
baseUrl: '',
|
|
498
|
-
|
|
530
|
+
inputUrl: '',
|
|
499
531
|
mode: 'void',
|
|
500
532
|
searchParams: {},
|
|
501
533
|
timeoutSeconds: 30,
|
|
@@ -528,7 +560,7 @@ export class Fetcher {
|
|
|
528
560
|
return norm
|
|
529
561
|
}
|
|
530
562
|
|
|
531
|
-
private normalizeOptions<BODY>(
|
|
563
|
+
private normalizeOptions<BODY>(opt: FetcherOptions<BODY>): FetcherRequest<BODY> {
|
|
532
564
|
const {
|
|
533
565
|
timeoutSeconds,
|
|
534
566
|
throwHttpErrors,
|
|
@@ -543,7 +575,6 @@ export class Fetcher {
|
|
|
543
575
|
const req: FetcherRequest<BODY> = {
|
|
544
576
|
started: Date.now(),
|
|
545
577
|
mode,
|
|
546
|
-
url,
|
|
547
578
|
timeoutSeconds,
|
|
548
579
|
throwHttpErrors,
|
|
549
580
|
retryPost,
|
|
@@ -551,6 +582,8 @@ export class Fetcher {
|
|
|
551
582
|
retry5xx,
|
|
552
583
|
jsonReviver,
|
|
553
584
|
..._omit(opt, ['method', 'headers', 'credentials']),
|
|
585
|
+
inputUrl: opt.url || '',
|
|
586
|
+
fullUrl: opt.url || '',
|
|
554
587
|
retry: {
|
|
555
588
|
...retry,
|
|
556
589
|
..._filterUndefinedValues(opt.retry || {}),
|
|
@@ -571,11 +604,11 @@ export class Fetcher {
|
|
|
571
604
|
// setup url
|
|
572
605
|
const baseUrl = opt.baseUrl || this.cfg.baseUrl
|
|
573
606
|
if (baseUrl) {
|
|
574
|
-
if (
|
|
607
|
+
if (req.fullUrl.startsWith('/')) {
|
|
575
608
|
console.warn(`Fetcher: url should not start with / when baseUrl is specified`)
|
|
576
|
-
|
|
609
|
+
req.fullUrl = req.fullUrl.slice(1)
|
|
577
610
|
}
|
|
578
|
-
req.
|
|
611
|
+
req.fullUrl = `${baseUrl}/${req.inputUrl}`
|
|
579
612
|
}
|
|
580
613
|
|
|
581
614
|
const searchParams = _filterUndefinedValues({
|
|
@@ -585,7 +618,7 @@ export class Fetcher {
|
|
|
585
618
|
|
|
586
619
|
if (Object.keys(searchParams).length) {
|
|
587
620
|
const qs = new URLSearchParams(searchParams).toString()
|
|
588
|
-
req.
|
|
621
|
+
req.fullUrl += req.fullUrl.includes('?') ? '&' : '?' + qs
|
|
589
622
|
}
|
|
590
623
|
|
|
591
624
|
// setup request body
|