@superhero/http-request 4.0.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/README.md +351 -0
- package/index.js +704 -0
- package/index.test.js +555 -0
- package/package.json +27 -0
package/index.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import https from 'node:https'
|
|
3
|
+
import http2 from 'node:http2'
|
|
4
|
+
import querystring from 'node:querystring'
|
|
5
|
+
import { URL } from 'node:url'
|
|
6
|
+
import { setTimeout as wait } from 'node:timers/promises'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} RequestResponse
|
|
10
|
+
*
|
|
11
|
+
* @property {Number} status - The HTTP status code of the response.
|
|
12
|
+
* @property {Object.<String, String>} headers - The headers of the response.
|
|
13
|
+
* @property {Object|String} body - The response body. If the content type is JSON, the body is parsed.
|
|
14
|
+
* If a downstream stream is provided, the body is piped to the downstream
|
|
15
|
+
* and this body field is omitted.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} RequestOptions
|
|
20
|
+
*
|
|
21
|
+
* @property {String} url - The URL to make the request to.
|
|
22
|
+
* @property {String} base - The base path to resolve the URL against.
|
|
23
|
+
* @property {Object.<String, String>} [headers] - The request headers.
|
|
24
|
+
* @property {Object|String} [body] - The request body.
|
|
25
|
+
* @property {Object|String} [data] - Alias for body.
|
|
26
|
+
* @property {Number} [timeout] - The request timeout in milliseconds.
|
|
27
|
+
* @property {Number} [retry] - The number of times to retry the request.
|
|
28
|
+
* @property {Number} [retryDelay] - The delay between retries in milliseconds.
|
|
29
|
+
* @property {Number[]} [retryOnStatus] - The HTTP status codes to retry on.
|
|
30
|
+
* @property {Boolean} [retryOnClientTimeout] - Whether to retry on client timeout.
|
|
31
|
+
* @property {Boolean} [retryOnDownstreamError] - Whether to retry on downstream error.
|
|
32
|
+
* @property {Boolean} [retryOnInvalidResponseBodyFormat] - Whether to retry on invalid response body format.
|
|
33
|
+
* @property {Boolean} [retryOnErrorResponseStatus] - Whether to retry on invalid response status.
|
|
34
|
+
* @property {Boolean} [doNotThrowOnErrorStatus] - Set to true to avoid throwing on error status.
|
|
35
|
+
* @property {Boolean} [doNotThrowOnRedirectStatus] - Set to true to avoid throwing on redirect status.
|
|
36
|
+
* @property {Stream.Readable>} [upstream] - An optional upstream stream to make it possible to pipe body to the upstream directly.
|
|
37
|
+
* @property {Stream.Writable} [downstream] - An optional downstream stream to make it possible to pipe body from the downstream to.
|
|
38
|
+
* @property {String} [method] - The HTTP method to use.
|
|
39
|
+
*
|
|
40
|
+
* @see {@link https://nodejs.org/api/http.html#httprequestoptions-callback}
|
|
41
|
+
* @see {@link https://nodejs.org/api/https.html#httpsrequestoptions-callback}
|
|
42
|
+
* @see {@link https://nodejs.org/api/net.html#socketconnectoptions-connectlistener}
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A class for making HTTP/S 1.1 or 2.0 requests.
|
|
47
|
+
* @memberof @superhero/http-request
|
|
48
|
+
*/
|
|
49
|
+
export default class Request
|
|
50
|
+
{
|
|
51
|
+
#config
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {RequestOptions} config - The default/fallback request options/configurations.
|
|
55
|
+
*/
|
|
56
|
+
constructor(config)
|
|
57
|
+
{
|
|
58
|
+
this.#config = 'string' === typeof config
|
|
59
|
+
? { base:config }
|
|
60
|
+
: config || {}
|
|
61
|
+
|
|
62
|
+
const { base, url } = this.#config
|
|
63
|
+
this.#config.base = base ?? url
|
|
64
|
+
delete this.#config.url
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Connects to a HTTP/2 server.
|
|
69
|
+
*
|
|
70
|
+
* @param {String} authority the URL
|
|
71
|
+
* @param {Object} [options]
|
|
72
|
+
*
|
|
73
|
+
* @throws {Error} E_HTTP_REQUEST_CONNECT_INVALID_ARGUMENT
|
|
74
|
+
*
|
|
75
|
+
* @see {@link https://nodejs.org/api/http2.html#http2connectauthority-options-listener}
|
|
76
|
+
*/
|
|
77
|
+
connect(authority, ...args)
|
|
78
|
+
{
|
|
79
|
+
return new Promise(async (accept, reject) =>
|
|
80
|
+
{
|
|
81
|
+
await this.close()
|
|
82
|
+
|
|
83
|
+
const url = new URL(authority || this.#config.base)
|
|
84
|
+
authority = url.protocol + '//' + url.host
|
|
85
|
+
|
|
86
|
+
this.http2Session = http2.connect(authority, ...args.slice(0, 1), () =>
|
|
87
|
+
{
|
|
88
|
+
this.#config.base = authority
|
|
89
|
+
this.#config.url = url.pathname + url.search
|
|
90
|
+
this.http2Session.off('error', reject)
|
|
91
|
+
accept()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
this.http2Session.once('error', reject)
|
|
95
|
+
this.http2Session.once('close', () =>
|
|
96
|
+
{
|
|
97
|
+
this.http2Session.removeAllListeners()
|
|
98
|
+
delete this.http2Session
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Closes the HTTP/2 client.
|
|
105
|
+
* @param {number} error
|
|
106
|
+
* @see {@link https://nodejs.org/api/http2.html#http2sessionclosecallback}
|
|
107
|
+
*/
|
|
108
|
+
close()
|
|
109
|
+
{
|
|
110
|
+
return new Promise((accept, reject) =>
|
|
111
|
+
{
|
|
112
|
+
if(this.http2Session
|
|
113
|
+
&& this.http2Session.closed === false)
|
|
114
|
+
{
|
|
115
|
+
this.http2Session.close((error) =>
|
|
116
|
+
{
|
|
117
|
+
error
|
|
118
|
+
? reject(error)
|
|
119
|
+
: accept()
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
{
|
|
124
|
+
accept()
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* GET – Read a resource or a collection of resources.
|
|
131
|
+
* Used to retrieve data without modifying it.
|
|
132
|
+
* Example: fetching user details.
|
|
133
|
+
*
|
|
134
|
+
* @param {RequestOptions} options
|
|
135
|
+
* @returns {RequestResponse}
|
|
136
|
+
*/
|
|
137
|
+
get(options)
|
|
138
|
+
{
|
|
139
|
+
return this.#fetch('GET', options)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* POST – Create a new resource.
|
|
144
|
+
* Used to submit data to create a new resource on the server.
|
|
145
|
+
* Example: creating a new user.
|
|
146
|
+
*
|
|
147
|
+
* @param {RequestOptions} options
|
|
148
|
+
* @returns {RequestResponse}
|
|
149
|
+
*/
|
|
150
|
+
post(options)
|
|
151
|
+
{
|
|
152
|
+
return this.#fetch('POST', options)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* PUT – Replace or update a resource completely.
|
|
157
|
+
* Used for updating an existing resource by replacing it entirely.
|
|
158
|
+
* Example: replacing a user’s details.
|
|
159
|
+
*
|
|
160
|
+
* @param {RequestOptions} options
|
|
161
|
+
* @returns {RequestResponse}
|
|
162
|
+
*/
|
|
163
|
+
put(options)
|
|
164
|
+
{
|
|
165
|
+
return this.#fetch('PUT', options)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* PATCH – Partial update of a resource.
|
|
170
|
+
* Used to apply partial modifications, often updating only specific
|
|
171
|
+
* fields. Example: updating just the email in a user profile.
|
|
172
|
+
*
|
|
173
|
+
* @param {RequestOptions} options
|
|
174
|
+
* @returns {RequestResponse}
|
|
175
|
+
*/
|
|
176
|
+
patch(options)
|
|
177
|
+
{
|
|
178
|
+
return this.#fetch('PATCH', options)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* DELETE – Remove a resource.
|
|
183
|
+
* Used to delete an existing resource.
|
|
184
|
+
*
|
|
185
|
+
* @param {RequestOptions} options
|
|
186
|
+
* @returns {RequestResponse}
|
|
187
|
+
*/
|
|
188
|
+
delete(options)
|
|
189
|
+
{
|
|
190
|
+
return this.#fetch('DELETE', options)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* HEAD – Get metadata (headers) of a resource without getting the body.
|
|
195
|
+
* Similar to GET but retrieves only headers, often used for checking if
|
|
196
|
+
* a resource exists without fetching its body.
|
|
197
|
+
*
|
|
198
|
+
* @param {RequestOptions} options
|
|
199
|
+
* @returns {RequestResponse}
|
|
200
|
+
*/
|
|
201
|
+
head(options)
|
|
202
|
+
{
|
|
203
|
+
return this.#fetch('HEAD', options)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* OPTIONS – Get information about a resource’s communication options.
|
|
208
|
+
* Used to find out which methods are supported for a specific resource,
|
|
209
|
+
* helping clients understand server capabilities.
|
|
210
|
+
*
|
|
211
|
+
* @param {RequestOptions} options
|
|
212
|
+
* @returns {RequestResponse}
|
|
213
|
+
*/
|
|
214
|
+
options(options)
|
|
215
|
+
{
|
|
216
|
+
return this.#fetch('OPTIONS', options)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* TRACE – Get diagnostic information from the server.
|
|
221
|
+
* Used to retrieve diagnostic information from the server, often used
|
|
222
|
+
* for testing or debugging purposes.
|
|
223
|
+
* Often used to see the path a request takes to reach the server.
|
|
224
|
+
* Rarely used in REST, primarily for network diagnostics.
|
|
225
|
+
*
|
|
226
|
+
* @param {RequestOptions} options
|
|
227
|
+
* @returns {RequestResponse}
|
|
228
|
+
*/
|
|
229
|
+
trace(options)
|
|
230
|
+
{
|
|
231
|
+
return this.#fetch('TRACE', options)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Generic fetch method.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} method
|
|
238
|
+
* @param {RequestOptions} options
|
|
239
|
+
* @returns {RequestResponse}
|
|
240
|
+
*/
|
|
241
|
+
#fetch(method, options)
|
|
242
|
+
{
|
|
243
|
+
if(typeof options === 'string')
|
|
244
|
+
{
|
|
245
|
+
options = { url:options }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if(this.#config.url && options.url)
|
|
249
|
+
{
|
|
250
|
+
options.url = options.url[0] === '/'
|
|
251
|
+
? options.url
|
|
252
|
+
: this.#config.url + options.url
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
options = Object.assign(
|
|
256
|
+
{
|
|
257
|
+
method,
|
|
258
|
+
headers : {},
|
|
259
|
+
retry : 3,
|
|
260
|
+
retryDelay : 200,
|
|
261
|
+
url : '',
|
|
262
|
+
timeout : 30e3,
|
|
263
|
+
retryOnStatus : []
|
|
264
|
+
}, this.#config, options)
|
|
265
|
+
|
|
266
|
+
return options.retry
|
|
267
|
+
? this.#resolveRetryLoop(options)
|
|
268
|
+
: this.#resolve(options)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolves the request.
|
|
273
|
+
*
|
|
274
|
+
* @param {String} method
|
|
275
|
+
* @param {RequestOptions} options
|
|
276
|
+
*
|
|
277
|
+
* @returns {RequestResponse}
|
|
278
|
+
*
|
|
279
|
+
* @throws {Error} E_HTTP_REQUEST_CLIENT_TIMEOUT
|
|
280
|
+
* @throws {Error} E_HTTP_REQUEST_CLIENT_ERROR
|
|
281
|
+
* @throws {Error} E_HTTP_REQUEST_DOWNSTREAM_ERROR
|
|
282
|
+
* @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
|
|
283
|
+
*/
|
|
284
|
+
#resolve(options)
|
|
285
|
+
{
|
|
286
|
+
return new Promise((accept, reject) =>
|
|
287
|
+
{
|
|
288
|
+
const
|
|
289
|
+
method = options.method,
|
|
290
|
+
headers = this.#normalizeHeaders(options.headers),
|
|
291
|
+
body = this.#normalizeBody(options.body ?? options.data, headers['content-type']),
|
|
292
|
+
delimiter = this.#createBodyHeaderDelimiter(body, !!options.upstream || !!this.http2Session),
|
|
293
|
+
url = this.#normalizeUrl(options.url, options.base)
|
|
294
|
+
|
|
295
|
+
Object.assign(headers, delimiter)
|
|
296
|
+
|
|
297
|
+
const upstream = this.http2Session
|
|
298
|
+
? this.#resolveHttp2Client(options, method, headers, url, accept, reject)
|
|
299
|
+
: this.#resolveHttp1Client(options, method, headers, url, accept, reject)
|
|
300
|
+
|
|
301
|
+
options.upstream
|
|
302
|
+
? options.upstream.pipe(upstream)
|
|
303
|
+
: upstream.writable && upstream.end(body)
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#resolveHttp2Client(options, method, headers, url, accept, reject)
|
|
308
|
+
{
|
|
309
|
+
delete headers['transfer-encoding']
|
|
310
|
+
|
|
311
|
+
const { pathname, search } = new URL(url)
|
|
312
|
+
const upstream = this.http2Session.request(
|
|
313
|
+
{
|
|
314
|
+
[http2.constants.HTTP2_HEADER_METHOD] : method,
|
|
315
|
+
[http2.constants.HTTP2_HEADER_PATH] : pathname + search,
|
|
316
|
+
...headers
|
|
317
|
+
}, options)
|
|
318
|
+
|
|
319
|
+
upstream.on('close', this.#connectionClosed .bind(this, upstream, reject))
|
|
320
|
+
upstream.on('error', this.#resolveOnClientError .bind(this, method, url, reject))
|
|
321
|
+
|
|
322
|
+
if(options.timeout)
|
|
323
|
+
{
|
|
324
|
+
upstream.setTimeout(options.timeout, this.#resolveOnClientTimeout.bind(this, upstream, options.timeout, method, url, reject))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
upstream.on('response', (headers) =>
|
|
328
|
+
{
|
|
329
|
+
upstream.headers = headers
|
|
330
|
+
upstream.statusCode = headers[http2.constants.HTTP2_HEADER_STATUS]
|
|
331
|
+
|
|
332
|
+
this.#resolveOnResponse(options, method, url, accept, reject, upstream)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
return upstream
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#resolveHttp1Client(options, method, headers, url, accept, reject)
|
|
339
|
+
{
|
|
340
|
+
const
|
|
341
|
+
request = url.protocol === 'https:' ? https.request : http.request,
|
|
342
|
+
config = Object.assign({}, options, { headers }),
|
|
343
|
+
upstream = request(url, config)
|
|
344
|
+
|
|
345
|
+
upstream.on('close', this.#connectionClosed .bind(this, upstream, reject))
|
|
346
|
+
upstream.on('error', this.#resolveOnClientError .bind(this, method, url, reject))
|
|
347
|
+
upstream.on('timeout', this.#resolveOnClientTimeout.bind(this, upstream, options.timeout, method, url, reject))
|
|
348
|
+
upstream.on('response', this.#resolveOnResponse .bind(this, options, method, url, accept, reject))
|
|
349
|
+
|
|
350
|
+
return upstream
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#connectionClosed(upstream, reject)
|
|
354
|
+
{
|
|
355
|
+
upstream.removeAllListeners()
|
|
356
|
+
const error = new Error('The connection was closed unexpectedly')
|
|
357
|
+
error.code = 'E_HTTP_REQUEST_CLIENT_ERROR'
|
|
358
|
+
setImmediate(() => reject(error)) // this error is a fallback if the promise is not already resolved
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolves the response.
|
|
363
|
+
*
|
|
364
|
+
* @param {RequestOptions} options
|
|
365
|
+
* @param {String} method
|
|
366
|
+
* @param {String} url
|
|
367
|
+
* @param {Function} accept Promise accept
|
|
368
|
+
* @param {Function} reject Promise reject
|
|
369
|
+
* @param {http.IncomingMessage} readable
|
|
370
|
+
*
|
|
371
|
+
* @returns {Void}
|
|
372
|
+
*/
|
|
373
|
+
#resolveOnResponse(options, method, url, accept, reject, readable)
|
|
374
|
+
{
|
|
375
|
+
const response =
|
|
376
|
+
{
|
|
377
|
+
status : readable.statusCode,
|
|
378
|
+
headers : readable.headers
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if((response.status >= 400 && false === !!options.doNotThrowOnErrorStatus)
|
|
382
|
+
|| (response.status >= 300 && false === !!options.doNotThrowOnRedirectStatus && response.status < 400))
|
|
383
|
+
{
|
|
384
|
+
const error = new Error(`Invalid HTTP status ${response.status} ${method} -> ${url}`)
|
|
385
|
+
error.code = 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS'
|
|
386
|
+
error.response = response
|
|
387
|
+
|
|
388
|
+
new Promise(this.#bufferResponseBody.bind(this, readable, response, method, url))
|
|
389
|
+
.catch((reason) => error.cause = reason)
|
|
390
|
+
.finally(() => reject(error))
|
|
391
|
+
}
|
|
392
|
+
else if(options.downstream)
|
|
393
|
+
{
|
|
394
|
+
readable.pipe(options.downstream)
|
|
395
|
+
readable.resume()
|
|
396
|
+
accept(response)
|
|
397
|
+
}
|
|
398
|
+
else
|
|
399
|
+
{
|
|
400
|
+
this.#bufferResponseBody(readable, response, method, url, accept, reject)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Resolves the request in a retry loop.
|
|
406
|
+
*
|
|
407
|
+
* @param {RequestOptions} options
|
|
408
|
+
*
|
|
409
|
+
* @returns {RequestResponse}
|
|
410
|
+
*
|
|
411
|
+
* @throws {Error} E_HTTP_REQUEST_RETRY
|
|
412
|
+
*/
|
|
413
|
+
async #resolveRetryLoop(options)
|
|
414
|
+
{
|
|
415
|
+
const errorTrace = []
|
|
416
|
+
|
|
417
|
+
let retry = Math.abs(Math.floor(options.retry))
|
|
418
|
+
|
|
419
|
+
while(retry--)
|
|
420
|
+
{
|
|
421
|
+
try
|
|
422
|
+
{
|
|
423
|
+
const response = await this.#resolve(options)
|
|
424
|
+
|
|
425
|
+
if(response.status >= 400
|
|
426
|
+
&& retry > 0
|
|
427
|
+
&&(options.retryOnStatus.includes(response.status)
|
|
428
|
+
|| options.retryOnErrorResponseStatus))
|
|
429
|
+
{
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
else
|
|
433
|
+
{
|
|
434
|
+
return response
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch(error)
|
|
438
|
+
{
|
|
439
|
+
if(retry === 0)
|
|
440
|
+
{
|
|
441
|
+
throw error
|
|
442
|
+
}
|
|
443
|
+
else
|
|
444
|
+
{
|
|
445
|
+
this.#resolveRetryLoopError(options, errorTrace, error)
|
|
446
|
+
await wait(options.retryDelay)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Resolves the error in the retry loop.
|
|
454
|
+
*
|
|
455
|
+
* @param {RequestOptions} options
|
|
456
|
+
* @param {Error[]} errorTrace
|
|
457
|
+
* @param {Error} error
|
|
458
|
+
*
|
|
459
|
+
* @returns {Void}
|
|
460
|
+
*
|
|
461
|
+
* @throws {Error} E_HTTP_REQUEST_CLIENT_ERROR
|
|
462
|
+
* @throws {Error} E_HTTP_REQUEST_CLIENT_TIMEOUT
|
|
463
|
+
* @throws {Error} E_HTTP_REQUEST_DOWNSTREAM_ERROR
|
|
464
|
+
* @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
|
|
465
|
+
* @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_STATUS
|
|
466
|
+
*/
|
|
467
|
+
#resolveRetryLoopError(options, errorTrace, error)
|
|
468
|
+
{
|
|
469
|
+
switch(error.code)
|
|
470
|
+
{
|
|
471
|
+
case 'E_HTTP_REQUEST_CLIENT_ERROR':
|
|
472
|
+
{
|
|
473
|
+
return errorTrace.push(error)
|
|
474
|
+
}
|
|
475
|
+
case 'E_HTTP_REQUEST_CLIENT_TIMEOUT':
|
|
476
|
+
{
|
|
477
|
+
if(options.retryOnClientTimeout)
|
|
478
|
+
{
|
|
479
|
+
return errorTrace.push(error)
|
|
480
|
+
}
|
|
481
|
+
else
|
|
482
|
+
{
|
|
483
|
+
throw error
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
case 'E_HTTP_REQUEST_DOWNSTREAM_ERROR':
|
|
487
|
+
{
|
|
488
|
+
if(options.retryOnDownstreamError)
|
|
489
|
+
{
|
|
490
|
+
return errorTrace.push(error)
|
|
491
|
+
}
|
|
492
|
+
else
|
|
493
|
+
{
|
|
494
|
+
throw error
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
case 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT':
|
|
498
|
+
{
|
|
499
|
+
if(options.retryOnInvalidResponseBodyFormat)
|
|
500
|
+
{
|
|
501
|
+
return errorTrace.push(error)
|
|
502
|
+
}
|
|
503
|
+
else
|
|
504
|
+
{
|
|
505
|
+
throw error
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
case 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS':
|
|
509
|
+
{
|
|
510
|
+
if(options.retryOnErrorResponseStatus)
|
|
511
|
+
{
|
|
512
|
+
return errorTrace.push(error)
|
|
513
|
+
}
|
|
514
|
+
else if(options.retryOnStatus.includes(error.response.status))
|
|
515
|
+
{
|
|
516
|
+
return errorTrace.push(error)
|
|
517
|
+
}
|
|
518
|
+
else
|
|
519
|
+
{
|
|
520
|
+
throw error
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
default:
|
|
524
|
+
{
|
|
525
|
+
throw error
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#createBodyHeaderDelimiter(body, isStreamed)
|
|
531
|
+
{
|
|
532
|
+
return isStreamed
|
|
533
|
+
? { 'transfer-encoding': 'chunked' }
|
|
534
|
+
: { 'content-length' : body.length }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Normalizes the headers to lowercase keys.
|
|
539
|
+
* @param {Object} headers
|
|
540
|
+
* @returns {Object} Normalized headers
|
|
541
|
+
*/
|
|
542
|
+
#normalizeHeaders(headers)
|
|
543
|
+
{
|
|
544
|
+
const
|
|
545
|
+
headerKeys = Object.keys(headers),
|
|
546
|
+
normalized = {}
|
|
547
|
+
|
|
548
|
+
headerKeys.forEach((key) => normalized[key.toLowerCase()] = headers[key])
|
|
549
|
+
|
|
550
|
+
return normalized
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Normalizes the body to a string.
|
|
555
|
+
*
|
|
556
|
+
* @param {Object|String} body
|
|
557
|
+
* @param {String} [contentType]
|
|
558
|
+
*
|
|
559
|
+
* @returns {String} Normalized body
|
|
560
|
+
*/
|
|
561
|
+
#normalizeBody(body, contentType)
|
|
562
|
+
{
|
|
563
|
+
if('string' === typeof body)
|
|
564
|
+
{
|
|
565
|
+
return body
|
|
566
|
+
}
|
|
567
|
+
else if(contentType?.startsWith('application/json'))
|
|
568
|
+
{
|
|
569
|
+
return JSON.stringify(body)
|
|
570
|
+
}
|
|
571
|
+
else
|
|
572
|
+
{
|
|
573
|
+
return querystring.stringify(body)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Normalizes the URL.
|
|
579
|
+
*
|
|
580
|
+
* @param {String} url
|
|
581
|
+
* @param {String} base
|
|
582
|
+
*
|
|
583
|
+
* @returns {String} Normalized URL
|
|
584
|
+
*/
|
|
585
|
+
#normalizeUrl(url, base)
|
|
586
|
+
{
|
|
587
|
+
return url
|
|
588
|
+
? new URL(url, base).toString()
|
|
589
|
+
: new URL(base).toString()
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Resolves the client error.
|
|
594
|
+
*
|
|
595
|
+
* @param {String} method
|
|
596
|
+
* @param {String} url
|
|
597
|
+
* @param {Function} reject Promise reject
|
|
598
|
+
* @param {Error} reason
|
|
599
|
+
*
|
|
600
|
+
* @returns {Void}
|
|
601
|
+
*
|
|
602
|
+
* @throws {Error} E_HTTP_REQUEST_CLIENT_ERROR
|
|
603
|
+
*/
|
|
604
|
+
#resolveOnClientError(method, url, reject, reason)
|
|
605
|
+
{
|
|
606
|
+
if(reason.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT')
|
|
607
|
+
{
|
|
608
|
+
return reject(reason)
|
|
609
|
+
}
|
|
610
|
+
else
|
|
611
|
+
{
|
|
612
|
+
const error = new Error(`Client error ${method} → ${url}`)
|
|
613
|
+
error.code = 'E_HTTP_REQUEST_CLIENT_ERROR'
|
|
614
|
+
error.cause = reason
|
|
615
|
+
reject(error)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Resolves the client timeout.
|
|
621
|
+
*
|
|
622
|
+
* @param {http.ClientRequest} upstream
|
|
623
|
+
* @param {Number} timeout
|
|
624
|
+
* @param {String} method
|
|
625
|
+
* @param {String} url
|
|
626
|
+
* @param {Function} reject Promise reject
|
|
627
|
+
*
|
|
628
|
+
* @returns {Void}
|
|
629
|
+
*
|
|
630
|
+
* @throws {Error} E_HTTP_REQUEST_CLIENT_TIMEOUT
|
|
631
|
+
*/
|
|
632
|
+
#resolveOnClientTimeout(upstream, timeout, method, url, reject)
|
|
633
|
+
{
|
|
634
|
+
const error = new Error(`Client timed out (${(timeout / 1e3).toFixed(1)}s) ${method} → ${url}`)
|
|
635
|
+
error.code = 'E_HTTP_REQUEST_CLIENT_TIMEOUT'
|
|
636
|
+
|
|
637
|
+
upstream.destroy(error)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#bufferResponseBody(readable, response, method, url, accept, reject)
|
|
641
|
+
{
|
|
642
|
+
response.body = ''
|
|
643
|
+
readable.on('data', (chunk) => response.body += chunk)
|
|
644
|
+
readable.on('error', this.#onStreamError.bind(this, method, url, response))
|
|
645
|
+
readable.on('end', this.#onStreamEnd .bind(this, method, url, response, accept, reject))
|
|
646
|
+
readable.resume()
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Resolves the downstream error.
|
|
651
|
+
*
|
|
652
|
+
* @param {String} method
|
|
653
|
+
* @param {String} url
|
|
654
|
+
* @param {http.IncomingMessage} response
|
|
655
|
+
* @param {Error} reason
|
|
656
|
+
*
|
|
657
|
+
* @returns {void}
|
|
658
|
+
*
|
|
659
|
+
* @throws {Error} E_HTTP_REQUEST_DOWNSTREAM_ERROR
|
|
660
|
+
*/
|
|
661
|
+
#onStreamError(method, url, response, reason)
|
|
662
|
+
{
|
|
663
|
+
const error = new Error(`Downstream error [${reason.code}] ${method} -> ${url}`)
|
|
664
|
+
error.code = 'E_HTTP_REQUEST_DOWNSTREAM_ERROR'
|
|
665
|
+
error.cause = reason
|
|
666
|
+
error.response = response
|
|
667
|
+
reject(error)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Resolves the response end.
|
|
672
|
+
*
|
|
673
|
+
* @param {String} method
|
|
674
|
+
* @param {String} url
|
|
675
|
+
* @param {http.IncomingMessage} response
|
|
676
|
+
* @param {Function} accept promise accept
|
|
677
|
+
* @param {Function} reject promise reject
|
|
678
|
+
*
|
|
679
|
+
* @returns {void}
|
|
680
|
+
*
|
|
681
|
+
* @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
|
|
682
|
+
*/
|
|
683
|
+
#onStreamEnd(method, url, response, accept, reject)
|
|
684
|
+
{
|
|
685
|
+
if(response.headers['content-type']?.startsWith('application/json'))
|
|
686
|
+
{
|
|
687
|
+
try
|
|
688
|
+
{
|
|
689
|
+
response.body = JSON.parse(response.body || '{}')
|
|
690
|
+
}
|
|
691
|
+
catch(reason)
|
|
692
|
+
{
|
|
693
|
+
const error = new TypeError(`Invalid JSON format ${method} -> ${url}`)
|
|
694
|
+
error.code = 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT'
|
|
695
|
+
error.cause = reason
|
|
696
|
+
error.response = response
|
|
697
|
+
reject(error)
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
accept(response)
|
|
703
|
+
}
|
|
704
|
+
}
|