@naturalcycles/js-lib 14.117.1 → 14.118.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 +146 -0
- package/dist/http/fetcher.js +298 -0
- package/dist/http/http.model.d.ts +2 -0
- package/dist/http/http.model.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/string/stringifyAny.js +2 -1
- package/dist-esm/http/fetcher.js +251 -0
- package/dist-esm/http/http.model.js +1 -0
- package/dist-esm/index.js +2 -0
- package/dist-esm/string/stringifyAny.js +2 -1
- package/package.json +1 -1
- package/src/http/fetcher.ts +469 -0
- package/src/http/http.model.ts +3 -0
- package/src/index.ts +2 -0
- package/src/string/stringifyAny.ts +3 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { CommonLogger } from '../log/commonLogger';
|
|
3
|
+
import type { Promisable } from '../typeFest';
|
|
4
|
+
import type { HttpMethod, HttpStatusFamily } from './http.model';
|
|
5
|
+
export interface FetcherNormalizedCfg extends FetcherCfg, FetcherNormalizedOptions {
|
|
6
|
+
logger: CommonLogger;
|
|
7
|
+
}
|
|
8
|
+
export interface FetcherCfg {
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Default rule is that you **are allowed** to mutate req, res, res.retryStatus
|
|
12
|
+
* properties of hook function arguments.
|
|
13
|
+
* If you throw an error from the hook - it will be re-thrown as-is.
|
|
14
|
+
*/
|
|
15
|
+
hooks?: {
|
|
16
|
+
/**
|
|
17
|
+
* Allows to mutate req.
|
|
18
|
+
*/
|
|
19
|
+
beforeRequest?(req: FetcherRequest): Promisable<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Allows to mutate res.
|
|
22
|
+
* If you set `res.err` - it will be thrown.
|
|
23
|
+
*/
|
|
24
|
+
beforeResponse?(res: FetcherResponse): Promisable<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Allows to mutate res.retryStatus to override retry behavior.
|
|
27
|
+
*/
|
|
28
|
+
beforeRetry?(res: FetcherResponse): Promisable<void>;
|
|
29
|
+
};
|
|
30
|
+
debug?: boolean;
|
|
31
|
+
logRequest?: boolean;
|
|
32
|
+
logRequestBody?: boolean;
|
|
33
|
+
logResponse?: boolean;
|
|
34
|
+
logResponseBody?: boolean;
|
|
35
|
+
logger?: CommonLogger;
|
|
36
|
+
}
|
|
37
|
+
export interface FetcherRetryStatus {
|
|
38
|
+
retryAttempt: number;
|
|
39
|
+
retryTimeout: number;
|
|
40
|
+
retryStopped: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface FetcherRetryOptions {
|
|
43
|
+
count: number;
|
|
44
|
+
timeout: number;
|
|
45
|
+
timeoutMax: number;
|
|
46
|
+
timeoutMultiplier: number;
|
|
47
|
+
}
|
|
48
|
+
export interface FetcherNormalizedOptions extends FetcherOptions {
|
|
49
|
+
method: HttpMethod;
|
|
50
|
+
throwHttpErrors: boolean;
|
|
51
|
+
timeoutSeconds: number;
|
|
52
|
+
retry: FetcherRetryOptions;
|
|
53
|
+
retryPost: boolean;
|
|
54
|
+
retry4xx: boolean;
|
|
55
|
+
retry5xx: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface FetcherOptions {
|
|
58
|
+
method?: HttpMethod;
|
|
59
|
+
throwHttpErrors?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Default: 30.
|
|
62
|
+
*
|
|
63
|
+
* Timeout applies to both get the response and retrieve the body (e.g `await res.json()`),
|
|
64
|
+
* so both should finish within this single timeout (not each).
|
|
65
|
+
*/
|
|
66
|
+
timeoutSeconds?: number;
|
|
67
|
+
json?: any;
|
|
68
|
+
text?: string;
|
|
69
|
+
requestInit?: RequestInit & {
|
|
70
|
+
method?: HttpMethod;
|
|
71
|
+
};
|
|
72
|
+
mode?: FetcherMode;
|
|
73
|
+
/**
|
|
74
|
+
* Default is 2 retries (3 tries in total).
|
|
75
|
+
* Pass `retry: { count: 0 }` to disable retries.
|
|
76
|
+
*/
|
|
77
|
+
retry?: Partial<FetcherRetryOptions>;
|
|
78
|
+
/**
|
|
79
|
+
* Defaults to false.
|
|
80
|
+
* Set to true to allow retrying `post` requests.
|
|
81
|
+
*/
|
|
82
|
+
retryPost?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Defaults to false.
|
|
85
|
+
*/
|
|
86
|
+
retry4xx?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Defaults to true.
|
|
89
|
+
*/
|
|
90
|
+
retry5xx?: boolean;
|
|
91
|
+
}
|
|
92
|
+
export interface FetcherRequest {
|
|
93
|
+
url: string;
|
|
94
|
+
init: RequestInit & {
|
|
95
|
+
method: HttpMethod;
|
|
96
|
+
};
|
|
97
|
+
opt: FetcherNormalizedOptions;
|
|
98
|
+
}
|
|
99
|
+
export interface FetcherSuccessResponse<BODY = unknown> extends FetcherResponse<BODY> {
|
|
100
|
+
err?: undefined;
|
|
101
|
+
fetchResponse: Response;
|
|
102
|
+
body: BODY;
|
|
103
|
+
}
|
|
104
|
+
export interface FetcherErrorResponse<BODY = unknown> extends FetcherResponse<BODY> {
|
|
105
|
+
err: Error;
|
|
106
|
+
}
|
|
107
|
+
export interface FetcherResponse<BODY = unknown> {
|
|
108
|
+
err?: Error;
|
|
109
|
+
req: FetcherRequest;
|
|
110
|
+
fetchResponse?: Response;
|
|
111
|
+
statusFamily?: HttpStatusFamily;
|
|
112
|
+
body?: BODY;
|
|
113
|
+
retryStatus: FetcherRetryStatus;
|
|
114
|
+
}
|
|
115
|
+
export type FetcherMode = 'json' | 'text';
|
|
116
|
+
/**
|
|
117
|
+
* Experimental wrapper around Fetch.
|
|
118
|
+
* Works in both Browser and Node, using `globalThis.fetch`.
|
|
119
|
+
*
|
|
120
|
+
* @experimental
|
|
121
|
+
*/
|
|
122
|
+
export declare class Fetcher {
|
|
123
|
+
private constructor();
|
|
124
|
+
cfg: FetcherNormalizedCfg;
|
|
125
|
+
static create(cfg?: FetcherCfg & FetcherOptions): Fetcher;
|
|
126
|
+
getJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
|
|
127
|
+
postJson<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
|
|
128
|
+
getText(url: string, opt?: FetcherOptions): Promise<string>;
|
|
129
|
+
postText(url: string, opt?: FetcherOptions): Promise<string>;
|
|
130
|
+
fetch<T = unknown>(url: string, opt?: FetcherOptions): Promise<T>;
|
|
131
|
+
rawFetch<T = unknown>(url: string, rawOpt?: FetcherOptions): Promise<FetcherResponse<T>>;
|
|
132
|
+
private processRetry;
|
|
133
|
+
/**
|
|
134
|
+
* Default is yes,
|
|
135
|
+
* unless there's reason not to (e.g method is POST).
|
|
136
|
+
*/
|
|
137
|
+
private shouldRetry;
|
|
138
|
+
private getStatusFamily;
|
|
139
|
+
/**
|
|
140
|
+
* Returns url without baseUrl and before ?queryString
|
|
141
|
+
*/
|
|
142
|
+
private getShortUrl;
|
|
143
|
+
private normalizeCfg;
|
|
144
|
+
private normalizeOptions;
|
|
145
|
+
}
|
|
146
|
+
export declare function getFetcher(cfg?: FetcherCfg & FetcherOptions): Fetcher;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/// <reference lib="dom"/>
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.getFetcher = exports.Fetcher = void 0;
|
|
5
|
+
const error_util_1 = require("../error/error.util");
|
|
6
|
+
const http_error_1 = require("../error/http.error");
|
|
7
|
+
const number_util_1 = require("../number/number.util");
|
|
8
|
+
const object_util_1 = require("../object/object.util");
|
|
9
|
+
const pDelay_1 = require("../promise/pDelay");
|
|
10
|
+
const json_util_1 = require("../string/json.util");
|
|
11
|
+
const stringifyAny_1 = require("../string/stringifyAny");
|
|
12
|
+
const time_util_1 = require("../time/time.util");
|
|
13
|
+
const types_1 = require("../types");
|
|
14
|
+
const defRetryOptions = {
|
|
15
|
+
count: 2,
|
|
16
|
+
timeout: 500,
|
|
17
|
+
timeoutMax: 30000,
|
|
18
|
+
timeoutMultiplier: 2,
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Experimental wrapper around Fetch.
|
|
22
|
+
* Works in both Browser and Node, using `globalThis.fetch`.
|
|
23
|
+
*
|
|
24
|
+
* @experimental
|
|
25
|
+
*/
|
|
26
|
+
class Fetcher {
|
|
27
|
+
constructor(cfg = {}) {
|
|
28
|
+
this.cfg = this.normalizeCfg(cfg);
|
|
29
|
+
}
|
|
30
|
+
static create(cfg = {}) {
|
|
31
|
+
return new Fetcher(cfg);
|
|
32
|
+
}
|
|
33
|
+
async getJson(url, opt = {}) {
|
|
34
|
+
return await this.fetch(url, {
|
|
35
|
+
...opt,
|
|
36
|
+
mode: 'json',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async postJson(url, opt = {}) {
|
|
40
|
+
return await this.fetch(url, {
|
|
41
|
+
...opt,
|
|
42
|
+
method: 'post',
|
|
43
|
+
mode: 'json',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async getText(url, opt = {}) {
|
|
47
|
+
return await this.fetch(url, {
|
|
48
|
+
...opt,
|
|
49
|
+
mode: 'text',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async postText(url, opt = {}) {
|
|
53
|
+
return await this.fetch(url, {
|
|
54
|
+
...opt,
|
|
55
|
+
method: 'post',
|
|
56
|
+
mode: 'text',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async fetch(url, opt = {}) {
|
|
60
|
+
const res = await this.rawFetch(url, opt);
|
|
61
|
+
if (res.err) {
|
|
62
|
+
if (res.req.opt.throwHttpErrors)
|
|
63
|
+
throw res.err;
|
|
64
|
+
return res;
|
|
65
|
+
}
|
|
66
|
+
return res.body;
|
|
67
|
+
}
|
|
68
|
+
async rawFetch(url, rawOpt = {}) {
|
|
69
|
+
const { baseUrl, logger } = this.cfg;
|
|
70
|
+
const opt = this.normalizeOptions(rawOpt);
|
|
71
|
+
const { method, timeoutSeconds, mode } = opt;
|
|
72
|
+
const req = {
|
|
73
|
+
url,
|
|
74
|
+
init: {
|
|
75
|
+
...this.cfg.requestInit,
|
|
76
|
+
method,
|
|
77
|
+
},
|
|
78
|
+
opt,
|
|
79
|
+
};
|
|
80
|
+
// setup url
|
|
81
|
+
if (baseUrl) {
|
|
82
|
+
if (url.startsWith('/')) {
|
|
83
|
+
console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
|
|
84
|
+
url = url.slice(1);
|
|
85
|
+
}
|
|
86
|
+
req.url = `${baseUrl}/${url}`;
|
|
87
|
+
}
|
|
88
|
+
// setup request body
|
|
89
|
+
if (opt.json !== undefined) {
|
|
90
|
+
req.init.body = JSON.stringify(opt.json);
|
|
91
|
+
}
|
|
92
|
+
else if (opt.text !== undefined) {
|
|
93
|
+
req.init.body = opt.text;
|
|
94
|
+
}
|
|
95
|
+
// setup timeout
|
|
96
|
+
let timeout;
|
|
97
|
+
if (timeoutSeconds) {
|
|
98
|
+
const abortController = new AbortController();
|
|
99
|
+
req.init.signal = abortController.signal;
|
|
100
|
+
timeout = setTimeout(() => {
|
|
101
|
+
abortController.abort(`timeout of ${timeoutSeconds} sec`);
|
|
102
|
+
}, timeoutSeconds * 1000);
|
|
103
|
+
}
|
|
104
|
+
if (opt.requestInit) {
|
|
105
|
+
(0, types_1._objectAssign)(req.init, opt.requestInit);
|
|
106
|
+
}
|
|
107
|
+
await this.cfg.hooks?.beforeRequest?.(req);
|
|
108
|
+
const res = {
|
|
109
|
+
req,
|
|
110
|
+
retryStatus: {
|
|
111
|
+
retryAttempt: 0,
|
|
112
|
+
retryStopped: false,
|
|
113
|
+
retryTimeout: opt.retry.timeout,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const shortUrl = this.getShortUrl(req.url);
|
|
117
|
+
const signature = [method.toUpperCase(), shortUrl].join(' ');
|
|
118
|
+
/* eslint-disable no-await-in-loop */
|
|
119
|
+
while (!res.retryStatus.retryStopped) {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
if (this.cfg.logRequest) {
|
|
122
|
+
const { retryAttempt } = res.retryStatus;
|
|
123
|
+
logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(' '));
|
|
126
|
+
if (this.cfg.logRequestBody && req.init.body) {
|
|
127
|
+
logger.log(req.init.body); // todo: check if we can _inspect it
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
res.fetchResponse = await globalThis.fetch(req.url, req.init);
|
|
131
|
+
res.statusFamily = this.getStatusFamily(res);
|
|
132
|
+
if (res.fetchResponse.ok) {
|
|
133
|
+
if (mode === 'json') {
|
|
134
|
+
// if no body: set responseBody as {}
|
|
135
|
+
// do not throw a "cannot parse null as Json" error
|
|
136
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.json() : {};
|
|
137
|
+
}
|
|
138
|
+
else if (mode === 'text') {
|
|
139
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.text() : '';
|
|
140
|
+
}
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
res.retryStatus.retryStopped = true;
|
|
143
|
+
if (this.cfg.logResponse) {
|
|
144
|
+
const { retryAttempt } = res.retryStatus;
|
|
145
|
+
logger.log([
|
|
146
|
+
' <<',
|
|
147
|
+
res.fetchResponse.status,
|
|
148
|
+
signature,
|
|
149
|
+
retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
|
|
150
|
+
(0, time_util_1._since)(started),
|
|
151
|
+
]
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.join(' '));
|
|
154
|
+
if (this.cfg.logResponseBody) {
|
|
155
|
+
logger.log(res.body);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
clearTimeout(timeout);
|
|
161
|
+
const body = (0, json_util_1._jsonParseIfPossible)(await res.fetchResponse.text());
|
|
162
|
+
const errObj = (0, error_util_1._anyToErrorObject)(body);
|
|
163
|
+
const originalMessage = errObj.message;
|
|
164
|
+
errObj.message = [[res.fetchResponse.status, signature].join(' '), originalMessage].join('\n');
|
|
165
|
+
res.err = new http_error_1.HttpError(errObj.message, (0, object_util_1._filterNullishValues)({
|
|
166
|
+
...errObj.data,
|
|
167
|
+
originalMessage,
|
|
168
|
+
httpStatusCode: res.fetchResponse.status,
|
|
169
|
+
// These properties are provided to be used in e.g custom Sentry error grouping
|
|
170
|
+
// Actually, disabled now, to avoid unnecessary error printing when both msg and data are printed
|
|
171
|
+
// Enabled, cause `data` is not printed by default when error is HttpError
|
|
172
|
+
// method: req.method,
|
|
173
|
+
url: req.url,
|
|
174
|
+
// tryCount: req.tryCount,
|
|
175
|
+
}));
|
|
176
|
+
if (this.cfg.logResponse) {
|
|
177
|
+
const { retryAttempt } = res.retryStatus;
|
|
178
|
+
logger.error([
|
|
179
|
+
[
|
|
180
|
+
' <<',
|
|
181
|
+
res.fetchResponse.status,
|
|
182
|
+
signature,
|
|
183
|
+
retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
|
|
184
|
+
(0, time_util_1._since)(started),
|
|
185
|
+
]
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.join(' '),
|
|
188
|
+
(0, stringifyAny_1._stringifyAny)(body),
|
|
189
|
+
].join('\n'));
|
|
190
|
+
}
|
|
191
|
+
await this.processRetry(res);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
await this.cfg.hooks?.beforeResponse?.(res);
|
|
195
|
+
return res;
|
|
196
|
+
}
|
|
197
|
+
async processRetry(res) {
|
|
198
|
+
const { retryStatus } = res;
|
|
199
|
+
if (!this.shouldRetry(res)) {
|
|
200
|
+
retryStatus.retryStopped = true;
|
|
201
|
+
}
|
|
202
|
+
await this.cfg.hooks?.beforeRetry?.(res);
|
|
203
|
+
const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry;
|
|
204
|
+
if (retryStatus.retryAttempt >= count) {
|
|
205
|
+
retryStatus.retryStopped = true;
|
|
206
|
+
}
|
|
207
|
+
if (retryStatus.retryStopped)
|
|
208
|
+
return;
|
|
209
|
+
retryStatus.retryTimeout = (0, number_util_1._clamp)(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
|
|
210
|
+
await (0, pDelay_1.pDelay)(retryStatus.retryTimeout);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Default is yes,
|
|
214
|
+
* unless there's reason not to (e.g method is POST).
|
|
215
|
+
*/
|
|
216
|
+
shouldRetry(res) {
|
|
217
|
+
const { retryPost, retry4xx, retry5xx } = res.req.opt;
|
|
218
|
+
const { method } = res.req.init;
|
|
219
|
+
if (method === 'post' && !retryPost)
|
|
220
|
+
return false;
|
|
221
|
+
const { statusFamily } = res;
|
|
222
|
+
if (statusFamily === '5xx' && !retry5xx)
|
|
223
|
+
return false;
|
|
224
|
+
if (statusFamily === '4xx' && !retry4xx)
|
|
225
|
+
return false;
|
|
226
|
+
return true; // default is true
|
|
227
|
+
}
|
|
228
|
+
getStatusFamily(res) {
|
|
229
|
+
const status = res.fetchResponse?.status;
|
|
230
|
+
if (!status)
|
|
231
|
+
return;
|
|
232
|
+
if (status >= 500)
|
|
233
|
+
return '5xx';
|
|
234
|
+
if (status >= 400)
|
|
235
|
+
return '4xx';
|
|
236
|
+
if (status >= 300)
|
|
237
|
+
return '3xx';
|
|
238
|
+
if (status >= 200)
|
|
239
|
+
return '2xx';
|
|
240
|
+
if (status >= 100)
|
|
241
|
+
return '1xx';
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Returns url without baseUrl and before ?queryString
|
|
245
|
+
*/
|
|
246
|
+
getShortUrl(url) {
|
|
247
|
+
const { baseUrl } = this.cfg;
|
|
248
|
+
if (!baseUrl)
|
|
249
|
+
return url;
|
|
250
|
+
return url.split('?')[0].slice(baseUrl.length);
|
|
251
|
+
}
|
|
252
|
+
normalizeCfg(cfg) {
|
|
253
|
+
if (cfg.baseUrl?.endsWith('/')) {
|
|
254
|
+
console.warn(`Fetcher: baseUrl should not end with /`);
|
|
255
|
+
cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1);
|
|
256
|
+
}
|
|
257
|
+
const { debug } = cfg;
|
|
258
|
+
return {
|
|
259
|
+
timeoutSeconds: 30,
|
|
260
|
+
method: 'get',
|
|
261
|
+
throwHttpErrors: true,
|
|
262
|
+
retryPost: false,
|
|
263
|
+
retry4xx: false,
|
|
264
|
+
retry5xx: true,
|
|
265
|
+
logger: console,
|
|
266
|
+
logRequest: debug,
|
|
267
|
+
logRequestBody: debug,
|
|
268
|
+
logResponse: debug,
|
|
269
|
+
logResponseBody: debug,
|
|
270
|
+
...cfg,
|
|
271
|
+
retry: {
|
|
272
|
+
...defRetryOptions,
|
|
273
|
+
...cfg.retry,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
normalizeOptions(opt) {
|
|
278
|
+
const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } = this.cfg;
|
|
279
|
+
return {
|
|
280
|
+
timeoutSeconds,
|
|
281
|
+
throwHttpErrors,
|
|
282
|
+
method,
|
|
283
|
+
retryPost,
|
|
284
|
+
retry4xx,
|
|
285
|
+
retry5xx,
|
|
286
|
+
...opt,
|
|
287
|
+
retry: {
|
|
288
|
+
...retry,
|
|
289
|
+
...(0, object_util_1._filterUndefinedValues)(opt.retry || {}),
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
exports.Fetcher = Fetcher;
|
|
295
|
+
function getFetcher(cfg = {}) {
|
|
296
|
+
return Fetcher.create(cfg);
|
|
297
|
+
}
|
|
298
|
+
exports.getFetcher = getFetcher;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -79,3 +79,5 @@ tslib_1.__exportStar(require("./datetime/localDate"), exports);
|
|
|
79
79
|
tslib_1.__exportStar(require("./datetime/localTime"), exports);
|
|
80
80
|
tslib_1.__exportStar(require("./datetime/dateInterval"), exports);
|
|
81
81
|
tslib_1.__exportStar(require("./datetime/timeInterval"), exports);
|
|
82
|
+
tslib_1.__exportStar(require("./http/http.model"), exports);
|
|
83
|
+
tslib_1.__exportStar(require("./http/fetcher"), exports);
|
|
@@ -56,7 +56,8 @@ function _stringifyAny(obj, opt = {}) {
|
|
|
56
56
|
// This is to fix the rare error (happened with Got) where `err.message` was changed,
|
|
57
57
|
// but err.stack had "old" err.message
|
|
58
58
|
// This should "fix" that
|
|
59
|
-
|
|
59
|
+
const sLines = s.split('\n').length;
|
|
60
|
+
s = [s, ...obj.stack.split('\n').slice(sLines)].join('\n');
|
|
60
61
|
}
|
|
61
62
|
if ((0, error_util_1._isErrorObject)(obj)) {
|
|
62
63
|
if ((0, error_util_1._isHttpErrorObject)(obj)) {
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/// <reference lib="dom"/>
|
|
2
|
+
import { _anyToErrorObject } from '../error/error.util';
|
|
3
|
+
import { HttpError } from '../error/http.error';
|
|
4
|
+
import { _clamp } from '../number/number.util';
|
|
5
|
+
import { _filterNullishValues, _filterUndefinedValues } from '../object/object.util';
|
|
6
|
+
import { pDelay } from '../promise/pDelay';
|
|
7
|
+
import { _jsonParseIfPossible } from '../string/json.util';
|
|
8
|
+
import { _stringifyAny } from '../string/stringifyAny';
|
|
9
|
+
import { _since } from '../time/time.util';
|
|
10
|
+
import { _objectAssign } from '../types';
|
|
11
|
+
const defRetryOptions = {
|
|
12
|
+
count: 2,
|
|
13
|
+
timeout: 500,
|
|
14
|
+
timeoutMax: 30000,
|
|
15
|
+
timeoutMultiplier: 2,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Experimental wrapper around Fetch.
|
|
19
|
+
* Works in both Browser and Node, using `globalThis.fetch`.
|
|
20
|
+
*
|
|
21
|
+
* @experimental
|
|
22
|
+
*/
|
|
23
|
+
export class Fetcher {
|
|
24
|
+
constructor(cfg = {}) {
|
|
25
|
+
this.cfg = this.normalizeCfg(cfg);
|
|
26
|
+
}
|
|
27
|
+
static create(cfg = {}) {
|
|
28
|
+
return new Fetcher(cfg);
|
|
29
|
+
}
|
|
30
|
+
async getJson(url, opt = {}) {
|
|
31
|
+
return await this.fetch(url, Object.assign(Object.assign({}, opt), { mode: 'json' }));
|
|
32
|
+
}
|
|
33
|
+
async postJson(url, opt = {}) {
|
|
34
|
+
return await this.fetch(url, Object.assign(Object.assign({}, opt), { method: 'post', mode: 'json' }));
|
|
35
|
+
}
|
|
36
|
+
async getText(url, opt = {}) {
|
|
37
|
+
return await this.fetch(url, Object.assign(Object.assign({}, opt), { mode: 'text' }));
|
|
38
|
+
}
|
|
39
|
+
async postText(url, opt = {}) {
|
|
40
|
+
return await this.fetch(url, Object.assign(Object.assign({}, opt), { method: 'post', mode: 'text' }));
|
|
41
|
+
}
|
|
42
|
+
async fetch(url, opt = {}) {
|
|
43
|
+
const res = await this.rawFetch(url, opt);
|
|
44
|
+
if (res.err) {
|
|
45
|
+
if (res.req.opt.throwHttpErrors)
|
|
46
|
+
throw res.err;
|
|
47
|
+
return res;
|
|
48
|
+
}
|
|
49
|
+
return res.body;
|
|
50
|
+
}
|
|
51
|
+
async rawFetch(url, rawOpt = {}) {
|
|
52
|
+
var _a, _b, _c, _d;
|
|
53
|
+
const { baseUrl, logger } = this.cfg;
|
|
54
|
+
const opt = this.normalizeOptions(rawOpt);
|
|
55
|
+
const { method, timeoutSeconds, mode } = opt;
|
|
56
|
+
const req = {
|
|
57
|
+
url,
|
|
58
|
+
init: Object.assign(Object.assign({}, this.cfg.requestInit), { method }),
|
|
59
|
+
opt,
|
|
60
|
+
};
|
|
61
|
+
// setup url
|
|
62
|
+
if (baseUrl) {
|
|
63
|
+
if (url.startsWith('/')) {
|
|
64
|
+
console.warn(`Fetcher: url should not start with / when baseUrl is specified`);
|
|
65
|
+
url = url.slice(1);
|
|
66
|
+
}
|
|
67
|
+
req.url = `${baseUrl}/${url}`;
|
|
68
|
+
}
|
|
69
|
+
// setup request body
|
|
70
|
+
if (opt.json !== undefined) {
|
|
71
|
+
req.init.body = JSON.stringify(opt.json);
|
|
72
|
+
}
|
|
73
|
+
else if (opt.text !== undefined) {
|
|
74
|
+
req.init.body = opt.text;
|
|
75
|
+
}
|
|
76
|
+
// setup timeout
|
|
77
|
+
let timeout;
|
|
78
|
+
if (timeoutSeconds) {
|
|
79
|
+
const abortController = new AbortController();
|
|
80
|
+
req.init.signal = abortController.signal;
|
|
81
|
+
timeout = setTimeout(() => {
|
|
82
|
+
abortController.abort(`timeout of ${timeoutSeconds} sec`);
|
|
83
|
+
}, timeoutSeconds * 1000);
|
|
84
|
+
}
|
|
85
|
+
if (opt.requestInit) {
|
|
86
|
+
_objectAssign(req.init, opt.requestInit);
|
|
87
|
+
}
|
|
88
|
+
await ((_b = (_a = this.cfg.hooks) === null || _a === void 0 ? void 0 : _a.beforeRequest) === null || _b === void 0 ? void 0 : _b.call(_a, req));
|
|
89
|
+
const res = {
|
|
90
|
+
req,
|
|
91
|
+
retryStatus: {
|
|
92
|
+
retryAttempt: 0,
|
|
93
|
+
retryStopped: false,
|
|
94
|
+
retryTimeout: opt.retry.timeout,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const shortUrl = this.getShortUrl(req.url);
|
|
98
|
+
const signature = [method.toUpperCase(), shortUrl].join(' ');
|
|
99
|
+
/* eslint-disable no-await-in-loop */
|
|
100
|
+
while (!res.retryStatus.retryStopped) {
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
if (this.cfg.logRequest) {
|
|
103
|
+
const { retryAttempt } = res.retryStatus;
|
|
104
|
+
logger.log([' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join(' '));
|
|
107
|
+
if (this.cfg.logRequestBody && req.init.body) {
|
|
108
|
+
logger.log(req.init.body); // todo: check if we can _inspect it
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
res.fetchResponse = await globalThis.fetch(req.url, req.init);
|
|
112
|
+
res.statusFamily = this.getStatusFamily(res);
|
|
113
|
+
if (res.fetchResponse.ok) {
|
|
114
|
+
if (mode === 'json') {
|
|
115
|
+
// if no body: set responseBody as {}
|
|
116
|
+
// do not throw a "cannot parse null as Json" error
|
|
117
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.json() : {};
|
|
118
|
+
}
|
|
119
|
+
else if (mode === 'text') {
|
|
120
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.text() : '';
|
|
121
|
+
}
|
|
122
|
+
clearTimeout(timeout);
|
|
123
|
+
res.retryStatus.retryStopped = true;
|
|
124
|
+
if (this.cfg.logResponse) {
|
|
125
|
+
const { retryAttempt } = res.retryStatus;
|
|
126
|
+
logger.log([
|
|
127
|
+
' <<',
|
|
128
|
+
res.fetchResponse.status,
|
|
129
|
+
signature,
|
|
130
|
+
retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
|
|
131
|
+
_since(started),
|
|
132
|
+
]
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join(' '));
|
|
135
|
+
if (this.cfg.logResponseBody) {
|
|
136
|
+
logger.log(res.body);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
const body = _jsonParseIfPossible(await res.fetchResponse.text());
|
|
143
|
+
const errObj = _anyToErrorObject(body);
|
|
144
|
+
const originalMessage = errObj.message;
|
|
145
|
+
errObj.message = [[res.fetchResponse.status, signature].join(' '), originalMessage].join('\n');
|
|
146
|
+
res.err = new HttpError(errObj.message, _filterNullishValues(Object.assign(Object.assign({}, errObj.data), { originalMessage, httpStatusCode: res.fetchResponse.status,
|
|
147
|
+
// These properties are provided to be used in e.g custom Sentry error grouping
|
|
148
|
+
// Actually, disabled now, to avoid unnecessary error printing when both msg and data are printed
|
|
149
|
+
// Enabled, cause `data` is not printed by default when error is HttpError
|
|
150
|
+
// method: req.method,
|
|
151
|
+
url: req.url })));
|
|
152
|
+
if (this.cfg.logResponse) {
|
|
153
|
+
const { retryAttempt } = res.retryStatus;
|
|
154
|
+
logger.error([
|
|
155
|
+
[
|
|
156
|
+
' <<',
|
|
157
|
+
res.fetchResponse.status,
|
|
158
|
+
signature,
|
|
159
|
+
retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
|
|
160
|
+
_since(started),
|
|
161
|
+
]
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.join(' '),
|
|
164
|
+
_stringifyAny(body),
|
|
165
|
+
].join('\n'));
|
|
166
|
+
}
|
|
167
|
+
await this.processRetry(res);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await ((_d = (_c = this.cfg.hooks) === null || _c === void 0 ? void 0 : _c.beforeResponse) === null || _d === void 0 ? void 0 : _d.call(_c, res));
|
|
171
|
+
return res;
|
|
172
|
+
}
|
|
173
|
+
async processRetry(res) {
|
|
174
|
+
var _a, _b;
|
|
175
|
+
const { retryStatus } = res;
|
|
176
|
+
if (!this.shouldRetry(res)) {
|
|
177
|
+
retryStatus.retryStopped = true;
|
|
178
|
+
}
|
|
179
|
+
await ((_b = (_a = this.cfg.hooks) === null || _a === void 0 ? void 0 : _a.beforeRetry) === null || _b === void 0 ? void 0 : _b.call(_a, res));
|
|
180
|
+
const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry;
|
|
181
|
+
if (retryStatus.retryAttempt >= count) {
|
|
182
|
+
retryStatus.retryStopped = true;
|
|
183
|
+
}
|
|
184
|
+
if (retryStatus.retryStopped)
|
|
185
|
+
return;
|
|
186
|
+
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax);
|
|
187
|
+
await pDelay(retryStatus.retryTimeout);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Default is yes,
|
|
191
|
+
* unless there's reason not to (e.g method is POST).
|
|
192
|
+
*/
|
|
193
|
+
shouldRetry(res) {
|
|
194
|
+
const { retryPost, retry4xx, retry5xx } = res.req.opt;
|
|
195
|
+
const { method } = res.req.init;
|
|
196
|
+
if (method === 'post' && !retryPost)
|
|
197
|
+
return false;
|
|
198
|
+
const { statusFamily } = res;
|
|
199
|
+
if (statusFamily === '5xx' && !retry5xx)
|
|
200
|
+
return false;
|
|
201
|
+
if (statusFamily === '4xx' && !retry4xx)
|
|
202
|
+
return false;
|
|
203
|
+
return true; // default is true
|
|
204
|
+
}
|
|
205
|
+
getStatusFamily(res) {
|
|
206
|
+
var _a;
|
|
207
|
+
const status = (_a = res.fetchResponse) === null || _a === void 0 ? void 0 : _a.status;
|
|
208
|
+
if (!status)
|
|
209
|
+
return;
|
|
210
|
+
if (status >= 500)
|
|
211
|
+
return '5xx';
|
|
212
|
+
if (status >= 400)
|
|
213
|
+
return '4xx';
|
|
214
|
+
if (status >= 300)
|
|
215
|
+
return '3xx';
|
|
216
|
+
if (status >= 200)
|
|
217
|
+
return '2xx';
|
|
218
|
+
if (status >= 100)
|
|
219
|
+
return '1xx';
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Returns url without baseUrl and before ?queryString
|
|
223
|
+
*/
|
|
224
|
+
getShortUrl(url) {
|
|
225
|
+
const { baseUrl } = this.cfg;
|
|
226
|
+
if (!baseUrl)
|
|
227
|
+
return url;
|
|
228
|
+
return url.split('?')[0].slice(baseUrl.length);
|
|
229
|
+
}
|
|
230
|
+
normalizeCfg(cfg) {
|
|
231
|
+
var _a;
|
|
232
|
+
if ((_a = cfg.baseUrl) === null || _a === void 0 ? void 0 : _a.endsWith('/')) {
|
|
233
|
+
console.warn(`Fetcher: baseUrl should not end with /`);
|
|
234
|
+
cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1);
|
|
235
|
+
}
|
|
236
|
+
const { debug } = cfg;
|
|
237
|
+
return Object.assign(Object.assign({ timeoutSeconds: 30, method: 'get', throwHttpErrors: true, retryPost: false, retry4xx: false, retry5xx: true, logger: console, logRequest: debug, logRequestBody: debug, logResponse: debug, logResponseBody: debug }, cfg), { retry: Object.assign(Object.assign({}, defRetryOptions), cfg.retry) });
|
|
238
|
+
}
|
|
239
|
+
normalizeOptions(opt) {
|
|
240
|
+
const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } = this.cfg;
|
|
241
|
+
return Object.assign(Object.assign({ timeoutSeconds,
|
|
242
|
+
throwHttpErrors,
|
|
243
|
+
method,
|
|
244
|
+
retryPost,
|
|
245
|
+
retry4xx,
|
|
246
|
+
retry5xx }, opt), { retry: Object.assign(Object.assign({}, retry), _filterUndefinedValues(opt.retry || {})) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export function getFetcher(cfg = {}) {
|
|
250
|
+
return Fetcher.create(cfg);
|
|
251
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist-esm/index.js
CHANGED
|
@@ -53,7 +53,8 @@ export function _stringifyAny(obj, opt = {}) {
|
|
|
53
53
|
// This is to fix the rare error (happened with Got) where `err.message` was changed,
|
|
54
54
|
// but err.stack had "old" err.message
|
|
55
55
|
// This should "fix" that
|
|
56
|
-
|
|
56
|
+
const sLines = s.split('\n').length;
|
|
57
|
+
s = [s, ...obj.stack.split('\n').slice(sLines)].join('\n');
|
|
57
58
|
}
|
|
58
59
|
if (_isErrorObject(obj)) {
|
|
59
60
|
if (_isHttpErrorObject(obj)) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/// <reference lib="dom"/>
|
|
2
|
+
|
|
3
|
+
import { _anyToErrorObject } from '../error/error.util'
|
|
4
|
+
import { HttpError } from '../error/http.error'
|
|
5
|
+
import { CommonLogger } from '../log/commonLogger'
|
|
6
|
+
import { _clamp } from '../number/number.util'
|
|
7
|
+
import { _filterNullishValues, _filterUndefinedValues } from '../object/object.util'
|
|
8
|
+
import { pDelay } from '../promise/pDelay'
|
|
9
|
+
import { _jsonParseIfPossible } from '../string/json.util'
|
|
10
|
+
import { _stringifyAny } from '../string/stringifyAny'
|
|
11
|
+
import { _since } from '../time/time.util'
|
|
12
|
+
import type { Promisable } from '../typeFest'
|
|
13
|
+
import { _objectAssign } from '../types'
|
|
14
|
+
import type { HttpMethod, HttpStatusFamily } from './http.model'
|
|
15
|
+
|
|
16
|
+
export interface FetcherNormalizedCfg extends FetcherCfg, FetcherNormalizedOptions {
|
|
17
|
+
logger: CommonLogger
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FetcherCfg {
|
|
21
|
+
baseUrl?: string
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default rule is that you **are allowed** to mutate req, res, res.retryStatus
|
|
25
|
+
* properties of hook function arguments.
|
|
26
|
+
* If you throw an error from the hook - it will be re-thrown as-is.
|
|
27
|
+
*/
|
|
28
|
+
hooks?: {
|
|
29
|
+
/**
|
|
30
|
+
* Allows to mutate req.
|
|
31
|
+
*/
|
|
32
|
+
beforeRequest?(req: FetcherRequest): Promisable<void>
|
|
33
|
+
/**
|
|
34
|
+
* Allows to mutate res.
|
|
35
|
+
* If you set `res.err` - it will be thrown.
|
|
36
|
+
*/
|
|
37
|
+
beforeResponse?(res: FetcherResponse): Promisable<void>
|
|
38
|
+
/**
|
|
39
|
+
* Allows to mutate res.retryStatus to override retry behavior.
|
|
40
|
+
*/
|
|
41
|
+
beforeRetry?(res: FetcherResponse): Promisable<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
debug?: boolean
|
|
45
|
+
logRequest?: boolean
|
|
46
|
+
logRequestBody?: boolean
|
|
47
|
+
logResponse?: boolean
|
|
48
|
+
logResponseBody?: boolean
|
|
49
|
+
logger?: CommonLogger
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FetcherRetryStatus {
|
|
53
|
+
retryAttempt: number
|
|
54
|
+
retryTimeout: number
|
|
55
|
+
retryStopped: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface FetcherRetryOptions {
|
|
59
|
+
count: number
|
|
60
|
+
timeout: number
|
|
61
|
+
timeoutMax: number
|
|
62
|
+
timeoutMultiplier: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FetcherNormalizedOptions extends FetcherOptions {
|
|
66
|
+
method: HttpMethod
|
|
67
|
+
throwHttpErrors: boolean
|
|
68
|
+
timeoutSeconds: number
|
|
69
|
+
retry: FetcherRetryOptions
|
|
70
|
+
retryPost: boolean
|
|
71
|
+
retry4xx: boolean
|
|
72
|
+
retry5xx: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FetcherOptions {
|
|
76
|
+
method?: HttpMethod
|
|
77
|
+
throwHttpErrors?: boolean
|
|
78
|
+
/**
|
|
79
|
+
* Default: 30.
|
|
80
|
+
*
|
|
81
|
+
* Timeout applies to both get the response and retrieve the body (e.g `await res.json()`),
|
|
82
|
+
* so both should finish within this single timeout (not each).
|
|
83
|
+
*/
|
|
84
|
+
timeoutSeconds?: number
|
|
85
|
+
json?: any
|
|
86
|
+
text?: string
|
|
87
|
+
requestInit?: RequestInit & { method?: HttpMethod }
|
|
88
|
+
mode?: FetcherMode // default to undefined (void response)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Default is 2 retries (3 tries in total).
|
|
92
|
+
* Pass `retry: { count: 0 }` to disable retries.
|
|
93
|
+
*/
|
|
94
|
+
retry?: Partial<FetcherRetryOptions>
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Defaults to false.
|
|
98
|
+
* Set to true to allow retrying `post` requests.
|
|
99
|
+
*/
|
|
100
|
+
retryPost?: boolean
|
|
101
|
+
/**
|
|
102
|
+
* Defaults to false.
|
|
103
|
+
*/
|
|
104
|
+
retry4xx?: boolean
|
|
105
|
+
/**
|
|
106
|
+
* Defaults to true.
|
|
107
|
+
*/
|
|
108
|
+
retry5xx?: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface FetcherRequest {
|
|
112
|
+
url: string
|
|
113
|
+
init: RequestInit & { method: HttpMethod }
|
|
114
|
+
opt: FetcherNormalizedOptions
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface FetcherSuccessResponse<BODY = unknown> extends FetcherResponse<BODY> {
|
|
118
|
+
err?: undefined
|
|
119
|
+
fetchResponse: Response
|
|
120
|
+
body: BODY
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface FetcherErrorResponse<BODY = unknown> extends FetcherResponse<BODY> {
|
|
124
|
+
err: Error
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface FetcherResponse<BODY = unknown> {
|
|
128
|
+
err?: Error
|
|
129
|
+
req: FetcherRequest
|
|
130
|
+
fetchResponse?: Response
|
|
131
|
+
statusFamily?: HttpStatusFamily
|
|
132
|
+
body?: BODY
|
|
133
|
+
retryStatus: FetcherRetryStatus
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type FetcherMode = 'json' | 'text'
|
|
137
|
+
|
|
138
|
+
const defRetryOptions: FetcherRetryOptions = {
|
|
139
|
+
count: 2,
|
|
140
|
+
timeout: 500,
|
|
141
|
+
timeoutMax: 30_000,
|
|
142
|
+
timeoutMultiplier: 2,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Experimental wrapper around Fetch.
|
|
147
|
+
* Works in both Browser and Node, using `globalThis.fetch`.
|
|
148
|
+
*
|
|
149
|
+
* @experimental
|
|
150
|
+
*/
|
|
151
|
+
export class Fetcher {
|
|
152
|
+
private constructor(cfg: FetcherCfg & FetcherOptions = {}) {
|
|
153
|
+
this.cfg = this.normalizeCfg(cfg)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public cfg: FetcherNormalizedCfg
|
|
157
|
+
|
|
158
|
+
static create(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
|
|
159
|
+
return new Fetcher(cfg)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getJson<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
|
|
163
|
+
return await this.fetch<T>(url, {
|
|
164
|
+
...opt,
|
|
165
|
+
mode: 'json',
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async postJson<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
|
|
170
|
+
return await this.fetch<T>(url, {
|
|
171
|
+
...opt,
|
|
172
|
+
method: 'post',
|
|
173
|
+
mode: 'json',
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async getText(url: string, opt: FetcherOptions = {}): Promise<string> {
|
|
178
|
+
return await this.fetch<string>(url, {
|
|
179
|
+
...opt,
|
|
180
|
+
mode: 'text',
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async postText(url: string, opt: FetcherOptions = {}): Promise<string> {
|
|
185
|
+
return await this.fetch<string>(url, {
|
|
186
|
+
...opt,
|
|
187
|
+
method: 'post',
|
|
188
|
+
mode: 'text',
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async fetch<T = unknown>(url: string, opt: FetcherOptions = {}): Promise<T> {
|
|
193
|
+
const res = await this.rawFetch<T>(url, opt)
|
|
194
|
+
if (res.err) {
|
|
195
|
+
if (res.req.opt.throwHttpErrors) throw res.err
|
|
196
|
+
return res as any
|
|
197
|
+
}
|
|
198
|
+
return res.body!
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async rawFetch<T = unknown>(
|
|
202
|
+
url: string,
|
|
203
|
+
rawOpt: FetcherOptions = {},
|
|
204
|
+
): Promise<FetcherResponse<T>> {
|
|
205
|
+
const { baseUrl, logger } = this.cfg
|
|
206
|
+
|
|
207
|
+
const opt = this.normalizeOptions(rawOpt)
|
|
208
|
+
const { method, timeoutSeconds, mode } = opt
|
|
209
|
+
|
|
210
|
+
const req: FetcherRequest = {
|
|
211
|
+
url,
|
|
212
|
+
init: {
|
|
213
|
+
...this.cfg.requestInit,
|
|
214
|
+
method,
|
|
215
|
+
},
|
|
216
|
+
opt,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// setup url
|
|
220
|
+
if (baseUrl) {
|
|
221
|
+
if (url.startsWith('/')) {
|
|
222
|
+
console.warn(`Fetcher: url should not start with / when baseUrl is specified`)
|
|
223
|
+
url = url.slice(1)
|
|
224
|
+
}
|
|
225
|
+
req.url = `${baseUrl}/${url}`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// setup request body
|
|
229
|
+
if (opt.json !== undefined) {
|
|
230
|
+
req.init.body = JSON.stringify(opt.json)
|
|
231
|
+
} else if (opt.text !== undefined) {
|
|
232
|
+
req.init.body = opt.text
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// setup timeout
|
|
236
|
+
let timeout: number | undefined
|
|
237
|
+
if (timeoutSeconds) {
|
|
238
|
+
const abortController = new AbortController()
|
|
239
|
+
req.init.signal = abortController.signal
|
|
240
|
+
timeout = setTimeout(() => {
|
|
241
|
+
abortController.abort(`timeout of ${timeoutSeconds} sec`)
|
|
242
|
+
}, timeoutSeconds * 1000) as any as number
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (opt.requestInit) {
|
|
246
|
+
_objectAssign(req.init, opt.requestInit)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await this.cfg.hooks?.beforeRequest?.(req)
|
|
250
|
+
|
|
251
|
+
const res: FetcherResponse<any> = {
|
|
252
|
+
req,
|
|
253
|
+
retryStatus: {
|
|
254
|
+
retryAttempt: 0,
|
|
255
|
+
retryStopped: false,
|
|
256
|
+
retryTimeout: opt.retry.timeout,
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const shortUrl = this.getShortUrl(req.url)
|
|
261
|
+
const signature = [method.toUpperCase(), shortUrl].join(' ')
|
|
262
|
+
|
|
263
|
+
/* eslint-disable no-await-in-loop */
|
|
264
|
+
while (!res.retryStatus.retryStopped) {
|
|
265
|
+
const started = Date.now()
|
|
266
|
+
|
|
267
|
+
if (this.cfg.logRequest) {
|
|
268
|
+
const { retryAttempt } = res.retryStatus
|
|
269
|
+
logger.log(
|
|
270
|
+
[' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`]
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.join(' '),
|
|
273
|
+
)
|
|
274
|
+
if (this.cfg.logRequestBody && req.init.body) {
|
|
275
|
+
logger.log(req.init.body) // todo: check if we can _inspect it
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
res.fetchResponse = await globalThis.fetch(req.url, req.init)
|
|
280
|
+
res.statusFamily = this.getStatusFamily(res)
|
|
281
|
+
|
|
282
|
+
if (res.fetchResponse.ok) {
|
|
283
|
+
if (mode === 'json') {
|
|
284
|
+
// if no body: set responseBody as {}
|
|
285
|
+
// do not throw a "cannot parse null as Json" error
|
|
286
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.json() : {}
|
|
287
|
+
} else if (mode === 'text') {
|
|
288
|
+
res.body = res.fetchResponse.body ? await res.fetchResponse.text() : ''
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
clearTimeout(timeout)
|
|
292
|
+
res.retryStatus.retryStopped = true
|
|
293
|
+
|
|
294
|
+
if (this.cfg.logResponse) {
|
|
295
|
+
const { retryAttempt } = res.retryStatus
|
|
296
|
+
logger.log(
|
|
297
|
+
[
|
|
298
|
+
' <<',
|
|
299
|
+
res.fetchResponse.status,
|
|
300
|
+
signature,
|
|
301
|
+
retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
|
|
302
|
+
_since(started),
|
|
303
|
+
]
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
.join(' '),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if (this.cfg.logResponseBody) {
|
|
309
|
+
logger.log(res.body)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
clearTimeout(timeout)
|
|
314
|
+
|
|
315
|
+
const body = _jsonParseIfPossible(await res.fetchResponse.text())
|
|
316
|
+
const errObj = _anyToErrorObject(body)
|
|
317
|
+
const originalMessage = errObj.message
|
|
318
|
+
errObj.message = [[res.fetchResponse.status, signature].join(' '), originalMessage].join(
|
|
319
|
+
'\n',
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
res.err = new HttpError(
|
|
323
|
+
errObj.message,
|
|
324
|
+
|
|
325
|
+
_filterNullishValues({
|
|
326
|
+
...errObj.data,
|
|
327
|
+
originalMessage,
|
|
328
|
+
httpStatusCode: res.fetchResponse.status,
|
|
329
|
+
// These properties are provided to be used in e.g custom Sentry error grouping
|
|
330
|
+
// Actually, disabled now, to avoid unnecessary error printing when both msg and data are printed
|
|
331
|
+
// Enabled, cause `data` is not printed by default when error is HttpError
|
|
332
|
+
// method: req.method,
|
|
333
|
+
url: req.url,
|
|
334
|
+
// tryCount: req.tryCount,
|
|
335
|
+
}),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if (this.cfg.logResponse) {
|
|
339
|
+
const { retryAttempt } = res.retryStatus
|
|
340
|
+
logger.error(
|
|
341
|
+
[
|
|
342
|
+
[
|
|
343
|
+
' <<',
|
|
344
|
+
res.fetchResponse.status,
|
|
345
|
+
signature,
|
|
346
|
+
retryAttempt && `try#${retryAttempt + 1}/${opt.retry.count}`,
|
|
347
|
+
_since(started),
|
|
348
|
+
]
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
.join(' '),
|
|
351
|
+
_stringifyAny(body),
|
|
352
|
+
].join('\n'),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await this.processRetry(res)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await this.cfg.hooks?.beforeResponse?.(res)
|
|
361
|
+
|
|
362
|
+
return res
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async processRetry(res: FetcherResponse): Promise<void> {
|
|
366
|
+
const { retryStatus } = res
|
|
367
|
+
|
|
368
|
+
if (!this.shouldRetry(res)) {
|
|
369
|
+
retryStatus.retryStopped = true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await this.cfg.hooks?.beforeRetry?.(res)
|
|
373
|
+
|
|
374
|
+
const { count, timeoutMultiplier, timeoutMax } = res.req.opt.retry
|
|
375
|
+
|
|
376
|
+
if (retryStatus.retryAttempt >= count) {
|
|
377
|
+
retryStatus.retryStopped = true
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (retryStatus.retryStopped) return
|
|
381
|
+
|
|
382
|
+
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
|
|
383
|
+
|
|
384
|
+
await pDelay(retryStatus.retryTimeout)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Default is yes,
|
|
389
|
+
* unless there's reason not to (e.g method is POST).
|
|
390
|
+
*/
|
|
391
|
+
private shouldRetry(res: FetcherResponse): boolean {
|
|
392
|
+
const { retryPost, retry4xx, retry5xx } = res.req.opt
|
|
393
|
+
const { method } = res.req.init
|
|
394
|
+
if (method === 'post' && !retryPost) return false
|
|
395
|
+
const { statusFamily } = res
|
|
396
|
+
if (statusFamily === '5xx' && !retry5xx) return false
|
|
397
|
+
if (statusFamily === '4xx' && !retry4xx) return false
|
|
398
|
+
return true // default is true
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private getStatusFamily(res: FetcherResponse): HttpStatusFamily | undefined {
|
|
402
|
+
const status = res.fetchResponse?.status
|
|
403
|
+
if (!status) return
|
|
404
|
+
if (status >= 500) return '5xx'
|
|
405
|
+
if (status >= 400) return '4xx'
|
|
406
|
+
if (status >= 300) return '3xx'
|
|
407
|
+
if (status >= 200) return '2xx'
|
|
408
|
+
if (status >= 100) return '1xx'
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Returns url without baseUrl and before ?queryString
|
|
413
|
+
*/
|
|
414
|
+
private getShortUrl(url: string): string {
|
|
415
|
+
const { baseUrl } = this.cfg
|
|
416
|
+
if (!baseUrl) return url
|
|
417
|
+
|
|
418
|
+
return url.split('?')[0]!.slice(baseUrl.length)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private normalizeCfg(cfg: FetcherCfg & FetcherOptions): FetcherNormalizedCfg {
|
|
422
|
+
if (cfg.baseUrl?.endsWith('/')) {
|
|
423
|
+
console.warn(`Fetcher: baseUrl should not end with /`)
|
|
424
|
+
cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1)
|
|
425
|
+
}
|
|
426
|
+
const { debug } = cfg
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
timeoutSeconds: 30,
|
|
430
|
+
method: 'get',
|
|
431
|
+
throwHttpErrors: true,
|
|
432
|
+
retryPost: false,
|
|
433
|
+
retry4xx: false,
|
|
434
|
+
retry5xx: true,
|
|
435
|
+
logger: console,
|
|
436
|
+
logRequest: debug,
|
|
437
|
+
logRequestBody: debug,
|
|
438
|
+
logResponse: debug,
|
|
439
|
+
logResponseBody: debug,
|
|
440
|
+
...cfg,
|
|
441
|
+
retry: {
|
|
442
|
+
...defRetryOptions,
|
|
443
|
+
...cfg.retry,
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private normalizeOptions(opt: FetcherOptions): FetcherNormalizedOptions {
|
|
449
|
+
const { timeoutSeconds, throwHttpErrors, method, retryPost, retry4xx, retry5xx, retry } =
|
|
450
|
+
this.cfg
|
|
451
|
+
return {
|
|
452
|
+
timeoutSeconds,
|
|
453
|
+
throwHttpErrors,
|
|
454
|
+
method,
|
|
455
|
+
retryPost,
|
|
456
|
+
retry4xx,
|
|
457
|
+
retry5xx,
|
|
458
|
+
...opt,
|
|
459
|
+
retry: {
|
|
460
|
+
...retry,
|
|
461
|
+
..._filterUndefinedValues(opt.retry || {}),
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function getFetcher(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
|
|
468
|
+
return Fetcher.create(cfg)
|
|
469
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -89,7 +89,9 @@ export function _stringifyAny(obj: any, opt: StringifyAnyOptions = {}): string {
|
|
|
89
89
|
// This is to fix the rare error (happened with Got) where `err.message` was changed,
|
|
90
90
|
// but err.stack had "old" err.message
|
|
91
91
|
// This should "fix" that
|
|
92
|
-
|
|
92
|
+
const sLines = s.split('\n').length
|
|
93
|
+
|
|
94
|
+
s = [s, ...obj.stack.split('\n').slice(sLines)].join('\n')
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
if (_isErrorObject(obj)) {
|