rqlite-js 2.6.0 → 3.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 +207 -29
- package/dist/index.cjs +904 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +329 -0
- package/dist/index.d.ts +329 -0
- package/dist/index.js +865 -0
- package/dist/index.js.map +1 -0
- package/docs/MIGRATION-v2-to-v3.md +71 -0
- package/package.json +56 -35
- package/src/api/backup/index.ts +69 -0
- package/src/api/backup-formats.ts +8 -0
- package/src/api/client/index.ts +97 -0
- package/src/api/consistency-levels.ts +10 -0
- package/src/api/data/index.ts +87 -0
- package/src/api/paths.ts +13 -0
- package/src/api/query-params.ts +15 -0
- package/src/api/results/data-result-error.ts +41 -0
- package/src/api/results/data-result.ts +91 -0
- package/src/api/results/data-results.ts +91 -0
- package/src/api/results/index.ts +7 -0
- package/src/api/results/result-fields.ts +14 -0
- package/src/api/status/index.ts +53 -0
- package/src/http-request/auth-keys.ts +14 -0
- package/src/http-request/content-types.ts +4 -0
- package/src/http-request/errors.ts +17 -0
- package/src/http-request/http-headers.ts +19 -0
- package/src/http-request/http-methods.ts +12 -0
- package/src/http-request/index.ts +676 -0
- package/src/http-request/retryable.ts +41 -0
- package/src/index.ts +38 -0
- package/lib/api/backup/index.js +0 -72
- package/lib/api/backup/index.js.map +0 -1
- package/lib/api/backup/test.unit.js +0 -45
- package/lib/api/backup/test.unit.js.map +0 -1
- package/lib/api/backup-formats.js +0 -16
- package/lib/api/backup-formats.js.map +0 -1
- package/lib/api/client/index.js +0 -105
- package/lib/api/client/index.js.map +0 -1
- package/lib/api/client/test.unit.js +0 -53
- package/lib/api/client/test.unit.js.map +0 -1
- package/lib/api/consistency-levels.js +0 -30
- package/lib/api/consistency-levels.js.map +0 -1
- package/lib/api/data/index.js +0 -108
- package/lib/api/data/index.js.map +0 -1
- package/lib/api/data/test.unit.js +0 -98
- package/lib/api/data/test.unit.js.map +0 -1
- package/lib/api/paths.js +0 -33
- package/lib/api/paths.js.map +0 -1
- package/lib/api/query-params.js +0 -43
- package/lib/api/query-params.js.map +0 -1
- package/lib/api/results/data-result-error.js +0 -62
- package/lib/api/results/data-result-error.js.map +0 -1
- package/lib/api/results/data-result.js +0 -141
- package/lib/api/results/data-result.js.map +0 -1
- package/lib/api/results/data-results.js +0 -142
- package/lib/api/results/data-results.js.map +0 -1
- package/lib/api/results/index.js +0 -8
- package/lib/api/results/index.js.map +0 -1
- package/lib/api/results/result-fields.js +0 -47
- package/lib/api/results/result-fields.js.map +0 -1
- package/lib/api/results/test.unit.js +0 -238
- package/lib/api/results/test.unit.js.map +0 -1
- package/lib/api/status/index.js +0 -67
- package/lib/api/status/index.js.map +0 -1
- package/lib/api/status/test.unit.js +0 -57
- package/lib/api/status/test.unit.js.map +0 -1
- package/lib/http-request/auth-keys.js +0 -15
- package/lib/http-request/auth-keys.js.map +0 -1
- package/lib/http-request/content-types.js +0 -5
- package/lib/http-request/content-types.js.map +0 -1
- package/lib/http-request/errors.js +0 -17
- package/lib/http-request/errors.js.map +0 -1
- package/lib/http-request/http-headers.js +0 -20
- package/lib/http-request/http-headers.js.map +0 -1
- package/lib/http-request/http-methods.js +0 -40
- package/lib/http-request/http-methods.js.map +0 -1
- package/lib/http-request/index.js +0 -764
- package/lib/http-request/index.js.map +0 -1
- package/lib/http-request/retryable.js +0 -42
- package/lib/http-request/retryable.js.map +0 -1
- package/lib/http-request/test.unit.js +0 -560
- package/lib/http-request/test.unit.js.map +0 -1
- package/lib/index.js +0 -39
- package/lib/index.js.map +0 -1
- package/lib/test/api-data-execute-nock.js +0 -142
- package/lib/test/api-data-execute-nock.js.map +0 -1
- package/lib/test/api-data-query-nock.js +0 -210
- package/lib/test/api-data-query-nock.js.map +0 -1
- package/lib/test/backups/backup-nock.js +0 -29
- package/lib/test/backups/backup-nock.js.map +0 -1
- package/lib/test/backups/restore-nock.js +0 -30
- package/lib/test/backups/restore-nock.js.map +0 -1
- package/lib/test/env.js +0 -15
- package/lib/test/env.js.map +0 -1
- package/lib/test/unit/index.unit.js +0 -27
- package/lib/test/unit/index.unit.js.map +0 -1
- package/lib/test.integration.js +0 -238
- package/lib/test.integration.js.map +0 -1
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain HTTP client to be used when creating RQLite specific API HTTP clients
|
|
3
|
+
* @module http-request
|
|
4
|
+
* @see https://rqlite.io/docs/api/api/
|
|
5
|
+
*/
|
|
6
|
+
import { Readable } from 'node:stream'
|
|
7
|
+
import type { Agent as HttpAgent } from 'node:http'
|
|
8
|
+
import type { Agent as HttpsAgent } from 'node:https'
|
|
9
|
+
import {
|
|
10
|
+
HTTP_METHOD_GET,
|
|
11
|
+
HTTP_METHOD_POST,
|
|
12
|
+
} from './http-methods.js'
|
|
13
|
+
import {
|
|
14
|
+
CONTENT_TYPE_APPLICATION_JSON,
|
|
15
|
+
} from './content-types.js'
|
|
16
|
+
import { ERROR_HTTP_REQUEST_MAX_REDIRECTS } from './errors.js'
|
|
17
|
+
import {
|
|
18
|
+
RETRYABLE_ERROR_CODES,
|
|
19
|
+
RETRYABLE_HTTP_METHODS,
|
|
20
|
+
RETRYABLE_STATUS_CODES,
|
|
21
|
+
} from './retryable.js'
|
|
22
|
+
import { AUTH_PASSWORD, AUTH_USERNAME } from './auth-keys.js'
|
|
23
|
+
import {
|
|
24
|
+
HTTP_HEADER_ACCEPT,
|
|
25
|
+
HTTP_HEADER_CONTENT_TYPE,
|
|
26
|
+
HTTP_HEADER_LOCATION,
|
|
27
|
+
} from './http-headers.js'
|
|
28
|
+
|
|
29
|
+
export type HttpAuthentication = {
|
|
30
|
+
username?: string
|
|
31
|
+
password?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type HttpRequestOptions = {
|
|
35
|
+
auth?: HttpAuthentication
|
|
36
|
+
body?: unknown
|
|
37
|
+
headers?: Record<string, string>
|
|
38
|
+
httpMethod?: string
|
|
39
|
+
query?: Record<string, unknown>
|
|
40
|
+
stream?: boolean
|
|
41
|
+
timeout?: number
|
|
42
|
+
uri?: string
|
|
43
|
+
useLeader?: boolean
|
|
44
|
+
retries?: number
|
|
45
|
+
maxRedirects?: number
|
|
46
|
+
attempt?: number
|
|
47
|
+
retryAttempt?: number
|
|
48
|
+
redirectAttempt?: number
|
|
49
|
+
attemptHostIndex?: number
|
|
50
|
+
exponentailBackoffBase?: number
|
|
51
|
+
exponentailBackoffExponent?: number
|
|
52
|
+
json?: boolean
|
|
53
|
+
httpAgent?: HttpAgent | DispatcherLike
|
|
54
|
+
httpsAgent?: HttpsAgent | DispatcherLike
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Minimal undici/Node fetch dispatcher shape */
|
|
58
|
+
export type DispatcherLike = {
|
|
59
|
+
dispatch?: (...args: unknown[]) => unknown
|
|
60
|
+
close?: () => Promise<void>
|
|
61
|
+
destroy?: () => Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type HttpResponse = {
|
|
65
|
+
status: number
|
|
66
|
+
body: unknown
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type HttpRequestConstructorOptions = {
|
|
70
|
+
authentication?: HttpAuthentication
|
|
71
|
+
activeHostRoundRobin?: boolean
|
|
72
|
+
httpAgent?: HttpAgent | DispatcherLike
|
|
73
|
+
httpsAgent?: HttpsAgent | DispatcherLike
|
|
74
|
+
retryableErrorCodes?: Set<string> | string[]
|
|
75
|
+
retryableStatusCodes?: Set<number> | number[]
|
|
76
|
+
retryableHttpMethods?: Set<string> | string[]
|
|
77
|
+
exponentailBackoffBase?: number
|
|
78
|
+
exponentailBackoffExponent?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The default timeout value
|
|
83
|
+
*/
|
|
84
|
+
export const DEAULT_TIMEOUT = 30000
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The default to retry a request using the next host in the chain
|
|
88
|
+
*/
|
|
89
|
+
export const DEAULT_RETRY_DELAY = 30000
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create default header for all HTTP requests
|
|
93
|
+
*/
|
|
94
|
+
export function createDefaultHeaders (headers: Record<string, string> = {}): Record<string, string> {
|
|
95
|
+
const accept = headers[HTTP_HEADER_ACCEPT] || CONTENT_TYPE_APPLICATION_JSON
|
|
96
|
+
return { ...headers, [HTTP_HEADER_ACCEPT]: accept }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Clean the request path remove / from the beginning
|
|
101
|
+
*/
|
|
102
|
+
function cleanPath (path: string): string {
|
|
103
|
+
return String(path).replace(/^\//, '')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Serialize query params similar to qs arrayFormat: 'brackets'
|
|
108
|
+
*/
|
|
109
|
+
export function stringifyQuery (params: Record<string, unknown> = {}): string {
|
|
110
|
+
const search = new URLSearchParams()
|
|
111
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
112
|
+
if (typeof value === 'undefined') {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
value.forEach((entry) => {
|
|
117
|
+
search.append(`${key}[]`, String(entry))
|
|
118
|
+
})
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
search.append(key, String(value))
|
|
122
|
+
})
|
|
123
|
+
return search.toString()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function basicAuthHeader (username: string, password: string): string {
|
|
127
|
+
const token = Buffer.from(`${username}:${password}`).toString('base64')
|
|
128
|
+
return `Basic ${token}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getErrorCode (error: unknown): string | undefined {
|
|
132
|
+
if (!error || typeof error !== 'object') {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
const err = error as { code?: string, cause?: { code?: string }, name?: string }
|
|
136
|
+
if (typeof err.code === 'string') {
|
|
137
|
+
return err.code
|
|
138
|
+
}
|
|
139
|
+
if (err.cause && typeof err.cause.code === 'string') {
|
|
140
|
+
return err.cause.code
|
|
141
|
+
}
|
|
142
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
|
|
143
|
+
return 'ETIMEDOUT'
|
|
144
|
+
}
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Returns the next wait interval, in milliseconds, using an exponential
|
|
150
|
+
* backoff algorithm.
|
|
151
|
+
*/
|
|
152
|
+
export function getWaitTimeExponential (attempt = 0, base = 100, exponent = 2): number {
|
|
153
|
+
if (attempt === 0) {
|
|
154
|
+
return 0
|
|
155
|
+
}
|
|
156
|
+
return (exponent ** attempt) * base
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generic HTTP Request class which all RQLiteJS clients should extend
|
|
161
|
+
*/
|
|
162
|
+
export default class HttpRequest {
|
|
163
|
+
activeHostIndex = 0
|
|
164
|
+
|
|
165
|
+
activeHostRoundRobin = true
|
|
166
|
+
|
|
167
|
+
absoluteUriPattern = /^https?:\/\//
|
|
168
|
+
|
|
169
|
+
hosts: string[] = []
|
|
170
|
+
|
|
171
|
+
httpAgent?: HttpAgent | DispatcherLike
|
|
172
|
+
|
|
173
|
+
httpsAgent?: HttpsAgent | DispatcherLike
|
|
174
|
+
|
|
175
|
+
leaderHostIndex = 0
|
|
176
|
+
|
|
177
|
+
retryableErrorCodes: Set<string> = RETRYABLE_ERROR_CODES
|
|
178
|
+
|
|
179
|
+
retryableStatusCodes: Set<number> = RETRYABLE_STATUS_CODES
|
|
180
|
+
|
|
181
|
+
retryableHttpMethods: Set<string> = RETRYABLE_HTTP_METHODS
|
|
182
|
+
|
|
183
|
+
exponentailBackoffBase = 100
|
|
184
|
+
|
|
185
|
+
exponentailBackoffExponent = 1.1
|
|
186
|
+
|
|
187
|
+
authentication = new Map<string, string>()
|
|
188
|
+
|
|
189
|
+
constructor (hosts: string[] | string, options: HttpRequestConstructorOptions = {}) {
|
|
190
|
+
this.setHosts(hosts)
|
|
191
|
+
if (this.getTotalHosts() === 0) {
|
|
192
|
+
throw new Error('At least one host must be provided')
|
|
193
|
+
}
|
|
194
|
+
const {
|
|
195
|
+
activeHostRoundRobin = true,
|
|
196
|
+
httpAgent,
|
|
197
|
+
httpsAgent,
|
|
198
|
+
retryableErrorCodes,
|
|
199
|
+
retryableStatusCodes,
|
|
200
|
+
retryableHttpMethods,
|
|
201
|
+
exponentailBackoffExponent,
|
|
202
|
+
exponentailBackoffBase,
|
|
203
|
+
authentication,
|
|
204
|
+
} = options
|
|
205
|
+
if (typeof authentication === 'object') {
|
|
206
|
+
const { username, password } = authentication
|
|
207
|
+
if (username) {
|
|
208
|
+
this.authentication.set(AUTH_USERNAME, username)
|
|
209
|
+
}
|
|
210
|
+
if (password) {
|
|
211
|
+
this.authentication.set(AUTH_PASSWORD, password)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (typeof activeHostRoundRobin !== 'undefined') {
|
|
215
|
+
this.setActiveHostRoundRobin(activeHostRoundRobin)
|
|
216
|
+
}
|
|
217
|
+
if (typeof httpAgent !== 'undefined') {
|
|
218
|
+
this.setHttpAgent(httpAgent)
|
|
219
|
+
}
|
|
220
|
+
if (typeof httpsAgent !== 'undefined') {
|
|
221
|
+
this.setHttpsAgent(httpsAgent)
|
|
222
|
+
}
|
|
223
|
+
if (retryableErrorCodes instanceof Set || Array.isArray(retryableErrorCodes)) {
|
|
224
|
+
this.setRetryableErrorCodes(
|
|
225
|
+
Array.isArray(retryableErrorCodes) ? new Set(retryableErrorCodes) : retryableErrorCodes,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
if (retryableStatusCodes instanceof Set || Array.isArray(retryableStatusCodes)) {
|
|
229
|
+
this.setRetryableStatusCodes(
|
|
230
|
+
Array.isArray(retryableStatusCodes) ? new Set(retryableStatusCodes) : retryableStatusCodes,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
if (retryableHttpMethods instanceof Set || Array.isArray(retryableHttpMethods)) {
|
|
234
|
+
this.setRetryableHttpMethods(
|
|
235
|
+
Array.isArray(retryableHttpMethods) ? new Set(retryableHttpMethods) : retryableHttpMethods,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
if (Number.isFinite(exponentailBackoffBase)) {
|
|
239
|
+
this.setExponentailBackoffBase(exponentailBackoffBase as number)
|
|
240
|
+
}
|
|
241
|
+
if (Number.isFinite(exponentailBackoffExponent)) {
|
|
242
|
+
this.setExponentailBackoffExponent(exponentailBackoffExponent as number)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setAuthentication (authentication: HttpAuthentication = {}): void {
|
|
247
|
+
const { username, password } = authentication
|
|
248
|
+
if (username) {
|
|
249
|
+
this.authentication.set(AUTH_USERNAME, username)
|
|
250
|
+
}
|
|
251
|
+
if (password) {
|
|
252
|
+
this.authentication.set(AUTH_PASSWORD, password)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
setExponentailBackoffBase (exponentailBackoffBase: number): void {
|
|
257
|
+
this.exponentailBackoffBase = exponentailBackoffBase
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
getExponentailBackoffBase (): number {
|
|
261
|
+
return this.exponentailBackoffBase
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
setExponentailBackoffExponent (exponentailBackoffExponent: number): void {
|
|
265
|
+
this.exponentailBackoffExponent = exponentailBackoffExponent
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getExponentailBackoffExponent (): number {
|
|
269
|
+
return this.exponentailBackoffExponent
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setRetryableErrorCodes (retryableErrorCodes: Set<string>): void {
|
|
273
|
+
this.retryableErrorCodes = retryableErrorCodes
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getRetryableErrorCodes (): Set<string> {
|
|
277
|
+
return this.retryableErrorCodes
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
setRetryableStatusCodes (retryableStatusCodes: Set<number>): void {
|
|
281
|
+
this.retryableStatusCodes = retryableStatusCodes
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
getRetryableStatusCodes (): Set<number> {
|
|
285
|
+
return this.retryableStatusCodes
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
setRetryableHttpMethods (retryableHttpMethods: Set<string>): void {
|
|
289
|
+
this.retryableHttpMethods = retryableHttpMethods
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getRetryableHttpMethods (): Set<string> {
|
|
293
|
+
return this.retryableHttpMethods
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
setHttpAgent (httpAgent: HttpAgent | DispatcherLike): void {
|
|
297
|
+
this.httpAgent = httpAgent
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
getHttpAgent (): HttpAgent | DispatcherLike | undefined {
|
|
301
|
+
return this.httpAgent
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
setHttpsAgent (httpsAgent: HttpsAgent | DispatcherLike): void {
|
|
305
|
+
this.httpsAgent = httpsAgent
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
getHttpsAgent (): HttpsAgent | DispatcherLike | undefined {
|
|
309
|
+
return this.httpsAgent
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
setHosts (hosts: string[] | string): void {
|
|
313
|
+
const list = !Array.isArray(hosts) ? String(hosts).split(',') : hosts
|
|
314
|
+
this.hosts = list.reduce<string[]>((acc, v) => {
|
|
315
|
+
const host = String(v).trim().replace(/\/$/, '')
|
|
316
|
+
if (!host) {
|
|
317
|
+
return acc
|
|
318
|
+
}
|
|
319
|
+
return acc.concat(host)
|
|
320
|
+
}, [])
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
getHosts (): string[] {
|
|
324
|
+
return this.hosts
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
findHostIndex (host: string): number {
|
|
328
|
+
let parsedHostToFind: URL
|
|
329
|
+
try {
|
|
330
|
+
parsedHostToFind = new URL(host)
|
|
331
|
+
} catch {
|
|
332
|
+
return -1
|
|
333
|
+
}
|
|
334
|
+
return this.getHosts().findIndex((v) => {
|
|
335
|
+
try {
|
|
336
|
+
const parsedHost = new URL(v)
|
|
337
|
+
return (['hostname', 'protocol', 'port', 'pathname'] as const).every(
|
|
338
|
+
(field) => parsedHostToFind[field] === parsedHost[field],
|
|
339
|
+
)
|
|
340
|
+
} catch {
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
getActiveHost (useLeader?: boolean): string {
|
|
347
|
+
const activeHostIndex = useLeader ? this.getLeaderHostIndex() : this.getActiveHostIndex()
|
|
348
|
+
return this.getHosts()[activeHostIndex]
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
setActiveHostIndex (activeHostIndex: number): number {
|
|
352
|
+
if (!Number.isFinite(activeHostIndex)) {
|
|
353
|
+
throw new Error('The activeHostIndex should be a finite number')
|
|
354
|
+
}
|
|
355
|
+
const totalHosts = this.getTotalHosts()
|
|
356
|
+
if (activeHostIndex < 0) {
|
|
357
|
+
this.activeHostIndex = 0
|
|
358
|
+
} else if (activeHostIndex >= totalHosts) {
|
|
359
|
+
this.activeHostIndex = totalHosts - 1
|
|
360
|
+
} else {
|
|
361
|
+
this.activeHostIndex = activeHostIndex
|
|
362
|
+
}
|
|
363
|
+
return this.activeHostIndex
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
getLeaderHostIndex (): number {
|
|
367
|
+
return this.leaderHostIndex
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
setLeaderHostIndex (leaderHostIndex: number): number {
|
|
371
|
+
if (!Number.isFinite(leaderHostIndex)) {
|
|
372
|
+
throw new Error('The leaderHostIndex should be a finite number')
|
|
373
|
+
}
|
|
374
|
+
const totalHosts = this.getTotalHosts()
|
|
375
|
+
if (leaderHostIndex < 0) {
|
|
376
|
+
this.leaderHostIndex = 0
|
|
377
|
+
} else if (leaderHostIndex > totalHosts) {
|
|
378
|
+
this.leaderHostIndex = totalHosts - 1
|
|
379
|
+
} else {
|
|
380
|
+
this.leaderHostIndex = leaderHostIndex
|
|
381
|
+
}
|
|
382
|
+
return this.leaderHostIndex
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
getActiveHostIndex (): number {
|
|
386
|
+
return this.activeHostIndex
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
setActiveHostRoundRobin (activeHostRoundRobin: boolean): void {
|
|
390
|
+
if (typeof activeHostRoundRobin !== 'boolean') {
|
|
391
|
+
throw new Error('The activeHostRoundRobin argument must be boolean')
|
|
392
|
+
}
|
|
393
|
+
this.activeHostRoundRobin = activeHostRoundRobin
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
getActiveHostRoundRobin (): boolean {
|
|
397
|
+
return this.activeHostRoundRobin
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
getNextActiveHostIndex (activeHostIndex = this.getActiveHostIndex()): number {
|
|
401
|
+
const totalHosts = this.getTotalHosts()
|
|
402
|
+
const nextIndex = activeHostIndex + 1
|
|
403
|
+
if (totalHosts === nextIndex) {
|
|
404
|
+
return 0
|
|
405
|
+
}
|
|
406
|
+
return nextIndex
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
setNextActiveHostIndex (): void {
|
|
410
|
+
if (!this.getActiveHostRoundRobin()) {
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const totalHosts = this.getTotalHosts()
|
|
414
|
+
if (this.getActiveHostRoundRobin() && totalHosts <= 1) {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
this.setActiveHostIndex(this.getNextActiveHostIndex())
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
getTotalHosts (): number {
|
|
421
|
+
return this.getHosts().length
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
uriIsAbsolute (uri: string): boolean {
|
|
425
|
+
return this.absoluteUriPattern.test(uri)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
requestIsRetryable (options: {
|
|
429
|
+
statusCode?: number
|
|
430
|
+
errorCode?: string
|
|
431
|
+
httpMethod?: string
|
|
432
|
+
} = {}): boolean {
|
|
433
|
+
const {
|
|
434
|
+
statusCode,
|
|
435
|
+
errorCode,
|
|
436
|
+
httpMethod,
|
|
437
|
+
} = options
|
|
438
|
+
if (!httpMethod || !this.getRetryableHttpMethods().has(httpMethod)) {
|
|
439
|
+
return false
|
|
440
|
+
}
|
|
441
|
+
if (statusCode && this.getRetryableStatusCodes().has(statusCode)) {
|
|
442
|
+
return true
|
|
443
|
+
}
|
|
444
|
+
if (errorCode && this.getRetryableErrorCodes().has(errorCode)) {
|
|
445
|
+
return true
|
|
446
|
+
}
|
|
447
|
+
return false
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Perform an HTTP request using the provided options
|
|
452
|
+
*/
|
|
453
|
+
async fetch (options: HttpRequestOptions = {}): Promise<HttpResponse | Readable> {
|
|
454
|
+
const {
|
|
455
|
+
body,
|
|
456
|
+
headers = {},
|
|
457
|
+
httpMethod = HTTP_METHOD_GET,
|
|
458
|
+
query,
|
|
459
|
+
stream = false,
|
|
460
|
+
timeout = DEAULT_TIMEOUT,
|
|
461
|
+
useLeader = false,
|
|
462
|
+
retries = this.getTotalHosts() * 3,
|
|
463
|
+
maxRedirects = 10,
|
|
464
|
+
attempt = 0,
|
|
465
|
+
retryAttempt = 0,
|
|
466
|
+
redirectAttempt = 0,
|
|
467
|
+
attemptHostIndex,
|
|
468
|
+
exponentailBackoffExponent = this.getExponentailBackoffExponent(),
|
|
469
|
+
exponentailBackoffBase = this.getExponentailBackoffBase(),
|
|
470
|
+
httpAgent = this.getHttpAgent(),
|
|
471
|
+
httpsAgent = this.getHttpsAgent(),
|
|
472
|
+
} = options
|
|
473
|
+
|
|
474
|
+
const activeHost = Number.isFinite(attemptHostIndex)
|
|
475
|
+
? this.getHosts()[attemptHostIndex as number]
|
|
476
|
+
: this.getActiveHost(useLeader)
|
|
477
|
+
|
|
478
|
+
let { uri } = options
|
|
479
|
+
if (!uri) {
|
|
480
|
+
throw new Error('The uri option is required')
|
|
481
|
+
}
|
|
482
|
+
uri = this.uriIsAbsolute(uri) ? uri : `${activeHost}/${cleanPath(uri)}`
|
|
483
|
+
|
|
484
|
+
const requestHeaders = createDefaultHeaders(headers)
|
|
485
|
+
if (this.authentication.size) {
|
|
486
|
+
const username = this.authentication.get(AUTH_USERNAME) || ''
|
|
487
|
+
const password = this.authentication.get(AUTH_PASSWORD) || ''
|
|
488
|
+
requestHeaders.Authorization = basicAuthHeader(username, password)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const url = new URL(uri)
|
|
492
|
+
if (query && typeof query === 'object') {
|
|
493
|
+
const serialized = stringifyQuery(query as Record<string, unknown>)
|
|
494
|
+
if (serialized) {
|
|
495
|
+
url.search = serialized
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const controller = new AbortController()
|
|
500
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
501
|
+
|
|
502
|
+
const fetchInit: RequestInit & { dispatcher?: unknown } = {
|
|
503
|
+
method: httpMethod,
|
|
504
|
+
headers: requestHeaders,
|
|
505
|
+
redirect: 'manual',
|
|
506
|
+
signal: controller.signal,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Only pass undici-compatible dispatchers. Node http(s).Agent is still
|
|
510
|
+
// accepted via options for API compatibility but is not a fetch dispatcher.
|
|
511
|
+
const agent = url.protocol === 'https:' ? httpsAgent : httpAgent
|
|
512
|
+
if (agent && typeof (agent as DispatcherLike).dispatch === 'function') {
|
|
513
|
+
// undici Dispatcher; cast avoids Node http.Agent type conflict
|
|
514
|
+
const init = fetchInit as { dispatcher?: unknown }
|
|
515
|
+
init.dispatcher = agent
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (typeof body !== 'undefined' && body !== null) {
|
|
519
|
+
if (Buffer.isBuffer(body) || typeof body === 'string') {
|
|
520
|
+
fetchInit.body = body as string | Buffer
|
|
521
|
+
} else {
|
|
522
|
+
fetchInit.body = JSON.stringify(body)
|
|
523
|
+
if (!requestHeaders[HTTP_HEADER_ACCEPT]) {
|
|
524
|
+
requestHeaders[HTTP_HEADER_ACCEPT] = CONTENT_TYPE_APPLICATION_JSON
|
|
525
|
+
}
|
|
526
|
+
if (!(HTTP_HEADER_CONTENT_TYPE in requestHeaders)
|
|
527
|
+
&& !Object.keys(requestHeaders).some((k) => k.toLowerCase() === 'content-type')) {
|
|
528
|
+
requestHeaders[HTTP_HEADER_CONTENT_TYPE] = CONTENT_TYPE_APPLICATION_JSON
|
|
529
|
+
}
|
|
530
|
+
fetchInit.headers = requestHeaders
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const retryOrRedirect = async (
|
|
535
|
+
responseStatus: number | undefined,
|
|
536
|
+
responseHeaders: Headers | Record<string, string> | undefined,
|
|
537
|
+
errorCode: string | undefined,
|
|
538
|
+
error: unknown,
|
|
539
|
+
): Promise<HttpResponse | Readable> => {
|
|
540
|
+
const retryable = this.requestIsRetryable({
|
|
541
|
+
statusCode: responseStatus,
|
|
542
|
+
errorCode,
|
|
543
|
+
httpMethod,
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
let nextAttemptHostIndex = Number.isFinite(attemptHostIndex)
|
|
547
|
+
? (attemptHostIndex as number)
|
|
548
|
+
: this.getActiveHostIndex()
|
|
549
|
+
nextAttemptHostIndex += 1
|
|
550
|
+
if (nextAttemptHostIndex === this.getTotalHosts()) {
|
|
551
|
+
nextAttemptHostIndex = 0
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (responseStatus === 301 || responseStatus === 302) {
|
|
555
|
+
if (redirectAttempt >= maxRedirects) {
|
|
556
|
+
throw new ERROR_HTTP_REQUEST_MAX_REDIRECTS(
|
|
557
|
+
`The maximum number of redirects ${maxRedirects} has been reached`,
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
let location: string | undefined
|
|
561
|
+
if (responseHeaders && typeof (responseHeaders as Headers).get === 'function') {
|
|
562
|
+
location = (responseHeaders as Headers).get(HTTP_HEADER_LOCATION)
|
|
563
|
+
|| (responseHeaders as Headers).get(HTTP_HEADER_LOCATION.toLowerCase())
|
|
564
|
+
|| undefined
|
|
565
|
+
} else if (responseHeaders && typeof responseHeaders === 'object') {
|
|
566
|
+
const headerMap = responseHeaders as Record<string, string>
|
|
567
|
+
location = headerMap[HTTP_HEADER_LOCATION.toLowerCase()]
|
|
568
|
+
|| headerMap[HTTP_HEADER_LOCATION]
|
|
569
|
+
}
|
|
570
|
+
if (useLeader && location) {
|
|
571
|
+
const newLeaderHostIndex = this.findHostIndex(location)
|
|
572
|
+
if (newLeaderHostIndex > -1) {
|
|
573
|
+
this.setLeaderHostIndex(newLeaderHostIndex)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return this.fetch({
|
|
577
|
+
...options,
|
|
578
|
+
uri: location,
|
|
579
|
+
attempt: attempt + 1,
|
|
580
|
+
redirectAttempt: redirectAttempt + 1,
|
|
581
|
+
attemptHostIndex: nextAttemptHostIndex,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (retryable && retryAttempt < retries) {
|
|
586
|
+
const waitTime = getWaitTimeExponential(
|
|
587
|
+
retryAttempt,
|
|
588
|
+
exponentailBackoffBase,
|
|
589
|
+
exponentailBackoffExponent,
|
|
590
|
+
)
|
|
591
|
+
await new Promise<void>((resolve) => {
|
|
592
|
+
setTimeout(() => resolve(), waitTime)
|
|
593
|
+
})
|
|
594
|
+
return this.fetch({
|
|
595
|
+
...options,
|
|
596
|
+
attempt: attempt + 1,
|
|
597
|
+
retryAttempt: retryAttempt + 1,
|
|
598
|
+
attemptHostIndex: nextAttemptHostIndex,
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (error instanceof Error) {
|
|
603
|
+
throw error
|
|
604
|
+
}
|
|
605
|
+
const wrapped = new Error(
|
|
606
|
+
(error as { message?: string })?.message || String(error),
|
|
607
|
+
)
|
|
608
|
+
Object.assign(wrapped, error)
|
|
609
|
+
throw wrapped
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const response = await fetch(url as unknown as string, fetchInit as RequestInit)
|
|
614
|
+
clearTimeout(timeoutId)
|
|
615
|
+
|
|
616
|
+
if (response.status === 301 || response.status === 302) {
|
|
617
|
+
return retryOrRedirect(response.status, response.headers, undefined, new Error(
|
|
618
|
+
`Redirect ${response.status}`,
|
|
619
|
+
))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Match axios: non-2xx responses reject (after retry logic when applicable)
|
|
623
|
+
if (response.status >= 400) {
|
|
624
|
+
return retryOrRedirect(
|
|
625
|
+
response.status,
|
|
626
|
+
response.headers,
|
|
627
|
+
undefined,
|
|
628
|
+
Object.assign(new Error(`HTTP ${response.status}`), {
|
|
629
|
+
response: {
|
|
630
|
+
status: response.status,
|
|
631
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
632
|
+
},
|
|
633
|
+
}),
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (stream) {
|
|
638
|
+
if (!response.body) {
|
|
639
|
+
return Readable.from([])
|
|
640
|
+
}
|
|
641
|
+
return Readable.fromWeb(response.body as import('stream/web').ReadableStream)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const contentType = response.headers.get('content-type') || ''
|
|
645
|
+
let responseBody: unknown
|
|
646
|
+
if (contentType.includes('application/json')) {
|
|
647
|
+
responseBody = await response.json()
|
|
648
|
+
} else {
|
|
649
|
+
responseBody = await response.text()
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
body: responseBody,
|
|
654
|
+
status: response.status,
|
|
655
|
+
}
|
|
656
|
+
} catch (e) {
|
|
657
|
+
clearTimeout(timeoutId)
|
|
658
|
+
const errorCode = getErrorCode(e)
|
|
659
|
+
const err = e as { response?: { status?: number, headers?: Record<string, string> } }
|
|
660
|
+
const responseStatus = err.response?.status
|
|
661
|
+
const responseHeaders = err.response?.headers
|
|
662
|
+
return retryOrRedirect(responseStatus, responseHeaders, errorCode, e)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Rest args keep subclass ApiClient get(path, sql, options) compatible (v2 JS shape)
|
|
667
|
+
async get (...args: any[]): Promise<HttpResponse | Readable> {
|
|
668
|
+
const options = (args[0] || {}) as HttpRequestOptions
|
|
669
|
+
return this.fetch({ ...options, httpMethod: HTTP_METHOD_GET })
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async post (...args: any[]): Promise<HttpResponse | Readable> {
|
|
673
|
+
const options = (args[0] || {}) as HttpRequestOptions
|
|
674
|
+
return this.fetch({ ...options, httpMethod: HTTP_METHOD_POST })
|
|
675
|
+
}
|
|
676
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HTTP_METHOD_DELETE,
|
|
3
|
+
HTTP_METHOD_GET,
|
|
4
|
+
HTTP_METHOD_HEAD,
|
|
5
|
+
HTTP_METHOD_OPTIONS,
|
|
6
|
+
HTTP_METHOD_PATCH,
|
|
7
|
+
HTTP_METHOD_POST,
|
|
8
|
+
HTTP_METHOD_PUT,
|
|
9
|
+
} from './http-methods.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Error codes which can be retried
|
|
13
|
+
*/
|
|
14
|
+
export const RETRYABLE_ERROR_CODES = new Set([
|
|
15
|
+
'EADDRINUSE',
|
|
16
|
+
'EAI_AGAIN',
|
|
17
|
+
'ECONNREFUSED',
|
|
18
|
+
'ECONNRESET',
|
|
19
|
+
'ENETUNREACH',
|
|
20
|
+
'ENOTFOUND',
|
|
21
|
+
'EPIPE',
|
|
22
|
+
'ETIMEDOUT',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* HTTP status codes which can be retried
|
|
27
|
+
*/
|
|
28
|
+
export const RETRYABLE_STATUS_CODES = new Set([408, 413, 429, 500, 502, 503, 504, 521, 522, 524])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* HTTP methods which can be retried
|
|
32
|
+
*/
|
|
33
|
+
export const RETRYABLE_HTTP_METHODS = new Set([
|
|
34
|
+
HTTP_METHOD_DELETE,
|
|
35
|
+
HTTP_METHOD_GET,
|
|
36
|
+
HTTP_METHOD_HEAD,
|
|
37
|
+
HTTP_METHOD_OPTIONS,
|
|
38
|
+
HTTP_METHOD_PATCH,
|
|
39
|
+
HTTP_METHOD_POST,
|
|
40
|
+
HTTP_METHOD_PUT,
|
|
41
|
+
])
|