@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.
@@ -1,5 +1,5 @@
1
1
  /// <reference lib="dom" />
2
- import type { FetcherAfterResponseHook, FetcherBeforeRequestHook, FetcherBeforeRetryHook, FetcherCfg, FetcherNormalizedCfg, FetcherOptions, FetcherRequest, FetcherResponse } from './fetcher.model';
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>(url: string, opt?: FetcherOptions<T>): Promise<T>;
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>(url: string, opt?: FetcherOptions<T>): Promise<FetcherResponse<T>>;
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).
@@ -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(url, {
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(url, {
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(url, {
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(url, {
91
+ return await this.fetch({
92
+ url,
89
93
  mode: 'readableStream',
90
94
  ...opt,
91
95
  });
92
96
  }
93
- async fetch(url, opt) {
94
- const res = await this.doFetch(url, opt);
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(url, opt = {}) {
108
- const req = this.normalizeOptions(url, opt);
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.url.includes('://');
127
- const fullUrl = isFullUrl ? new URL(req.url) : undefined;
128
- const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.url;
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.url, req.init);
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 proceed = await req.paginate(req, res);
175
- if (proceed) {
176
- return await this.doFetchRequest(req);
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.url,
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
- const noise = Math.random() * 500;
324
- await (0, pDelay_1.pDelay)(retryStatus.retryTimeout + noise);
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
- url: '',
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(url, opt) {
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 (url.startsWith('/')) {
478
+ if (req.fullUrl.startsWith('/')) {
448
479
  console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
449
- url = url.slice(1);
480
+ req.fullUrl = req.fullUrl.slice(1);
450
481
  }
451
- req.url = `${baseUrl}/${url}`;
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.url += req.url.includes('?') ? '&' : '?' + qs;
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
- url: string;
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 FetcherRequest and FetcherResponse
154
+ * Function has access to FetcherResponse and FetcherOptions
142
155
  * and has to make a decision to continue pagination or not.
143
- * Return true to continue, false otherwise.
144
- * If continue - it is expected to modify/mutate the FetcherRequest, as it will be used
145
- * to request the next page.
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?: (req: FetcherRequest<BODY>, res: FetcherSuccessResponse<BODY>) => Promisable<boolean>;
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;
@@ -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(url, Object.assign({ method, mode: 'void' }, opt));
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(url, Object.assign({ method, mode: 'text' }, opt));
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(url, Object.assign({ method, mode: 'json' }, opt));
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(url, Object.assign({ mode: 'readableStream' }, opt));
79
+ return await this.fetch(Object.assign({ url, mode: 'readableStream' }, opt));
77
80
  }
78
- async fetch(url, opt) {
79
- const res = await this.doFetch(url, opt);
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(url, opt = {}) {
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.url.includes('://');
113
- const fullUrl = isFullUrl ? new URL(req.url) : undefined;
114
- const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.url;
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.url, req.init);
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 proceed = await req.paginate(req, res);
161
- if (proceed) {
162
- return await this.doFetchRequest(req);
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.url,
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
- const noise = Math.random() * 500;
312
- await pDelay(retryStatus.retryTimeout + noise);
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
- url: '',
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(url, opt) {
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 (url.startsWith('/')) {
453
+ if (req.fullUrl.startsWith('/')) {
426
454
  console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
427
- url = url.slice(1);
455
+ req.fullUrl = req.fullUrl.slice(1);
428
456
  }
429
- req.url = `${baseUrl}/${url}`;
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.url += req.url.includes('?') ? '&' : '?' + qs;
462
+ req.fullUrl += req.fullUrl.includes('?') ? '&' : '?' + qs;
435
463
  }
436
464
  // setup request body
437
465
  if (opt.json !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/js-lib",
3
- "version": "14.146.0",
3
+ "version": "14.148.0",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build-prod": "build-prod-esm-cjs",
@@ -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
- url: string
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 FetcherRequest and FetcherResponse
190
+ * Function has access to FetcherResponse and FetcherOptions
177
191
  * and has to make a decision to continue pagination or not.
178
- * Return true to continue, false otherwise.
179
- * If continue - it is expected to modify/mutate the FetcherRequest, as it will be used
180
- * to request the next page.
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?: (req: FetcherRequest<BODY>, res: FetcherSuccessResponse<BODY>) => Promisable<boolean>
201
+ paginate?: (res: FetcherSuccessResponse<BODY>, opt: FetcherOptions<BODY>) => Promisable<boolean>
185
202
  }
186
203
 
187
204
  export type RequestInitNormalized = Omit<RequestInit, 'method' | 'headers'> & {
@@ -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>(url, {
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>(url, {
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>(url, {
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(url, {
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>(url: string, opt?: FetcherOptions<T>): Promise<T> {
155
- const res = await this.doFetch<T>(url, opt)
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
- url: string,
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.url.includes('://')
198
- const fullUrl = isFullUrl ? new URL(req.url) : undefined
199
- const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.url
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.url, req.init)
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 proceed = await req.paginate(req, res)
253
- if (proceed) {
254
- return await this.doFetchRequest(req)
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.url,
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
- const noise = Math.random() * 500
430
- await pDelay(retryStatus.retryTimeout + noise)
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
- url: '',
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>(url: string, opt: FetcherOptions<BODY>): FetcherRequest<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 (url.startsWith('/')) {
607
+ if (req.fullUrl.startsWith('/')) {
575
608
  console.warn(`Fetcher: url should not start with / when baseUrl is specified`)
576
- url = url.slice(1)
609
+ req.fullUrl = req.fullUrl.slice(1)
577
610
  }
578
- req.url = `${baseUrl}/${url}`
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.url += req.url.includes('?') ? '&' : '?' + qs
621
+ req.fullUrl += req.fullUrl.includes('?') ? '&' : '?' + qs
589
622
  }
590
623
 
591
624
  // setup request body