@imbingox/acex 0.3.1-beta.0 → 0.4.0-beta.2
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 +7 -7
- package/docs/api.md +66 -66
- package/package.json +1 -1
- package/src/adapters/binance/market-catalog.ts +42 -21
- package/src/adapters/binance/private-adapter.ts +78 -19
- package/src/adapters/juplend/private-adapter.ts +91 -55
- package/src/client/private-subscription-coordinator.ts +31 -7
- package/src/internal/decimal.ts +19 -0
- package/src/internal/http-client.ts +608 -0
- package/src/managers/account-manager.ts +40 -32
- package/src/managers/market-manager.ts +37 -26
- package/src/managers/order-manager.ts +7 -8
- package/src/types/account.ts +27 -28
- package/src/types/market.ts +12 -12
- package/src/types/order.ts +6 -7
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
export type TransportErrorKind =
|
|
2
|
+
| "timeout"
|
|
3
|
+
| "http"
|
|
4
|
+
| "network"
|
|
5
|
+
| "rate_limited"
|
|
6
|
+
| "parse";
|
|
7
|
+
|
|
8
|
+
export type HttpParseAs = "json" | "text" | "none";
|
|
9
|
+
export type JsonParseMode = "text" | "response";
|
|
10
|
+
export type EmptyBodyStrategy = "empty_object" | "empty_string" | "undefined";
|
|
11
|
+
|
|
12
|
+
export interface HttpRetryPolicy {
|
|
13
|
+
readonly idempotent: boolean;
|
|
14
|
+
readonly maxAttempts: number;
|
|
15
|
+
readonly initialDelayMs?: number;
|
|
16
|
+
readonly maxDelayMs?: number;
|
|
17
|
+
readonly jitterRatio?: number;
|
|
18
|
+
readonly random?: () => number;
|
|
19
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HttpClientMessages {
|
|
23
|
+
http?(input: HttpErrorMessageInput): string;
|
|
24
|
+
timeout?(input: HttpErrorMessageInput): string;
|
|
25
|
+
aborted?(input: HttpErrorMessageInput): string;
|
|
26
|
+
network?(input: HttpErrorMessageInput): string;
|
|
27
|
+
parse?(input: HttpErrorMessageInput): string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HttpRequestOptions {
|
|
31
|
+
readonly fetchFn?: FetchLike;
|
|
32
|
+
readonly url: string | URL;
|
|
33
|
+
readonly method?: string;
|
|
34
|
+
readonly headers?: RequestInit["headers"];
|
|
35
|
+
readonly body?: RequestInit["body"];
|
|
36
|
+
readonly signal?: AbortSignal;
|
|
37
|
+
readonly timeoutMs?: number;
|
|
38
|
+
readonly parseAs: HttpParseAs;
|
|
39
|
+
readonly jsonParseMode?: JsonParseMode;
|
|
40
|
+
readonly emptyBody?: EmptyBodyStrategy;
|
|
41
|
+
readonly retryPolicy: HttpRetryPolicy;
|
|
42
|
+
readonly messages?: HttpClientMessages;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HttpClientResponse<T> {
|
|
46
|
+
readonly body: T;
|
|
47
|
+
readonly status: number;
|
|
48
|
+
readonly statusText: string;
|
|
49
|
+
readonly headers: Headers;
|
|
50
|
+
readonly rawBody?: string;
|
|
51
|
+
readonly url: string;
|
|
52
|
+
readonly redactedUrl: string;
|
|
53
|
+
readonly attempts: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HttpErrorMessageInput {
|
|
57
|
+
readonly kind: TransportErrorKind;
|
|
58
|
+
readonly status?: number;
|
|
59
|
+
readonly statusText?: string;
|
|
60
|
+
readonly retryAfterMs?: number;
|
|
61
|
+
readonly attempts: number;
|
|
62
|
+
readonly rawBody?: string;
|
|
63
|
+
readonly url: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TransportErrorInit extends HttpErrorMessageInput {
|
|
67
|
+
readonly headers?: Headers;
|
|
68
|
+
readonly retryable: boolean;
|
|
69
|
+
readonly cause?: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class TransportError extends Error {
|
|
73
|
+
readonly isAcexTransportError = true;
|
|
74
|
+
readonly kind: TransportErrorKind;
|
|
75
|
+
readonly status?: number;
|
|
76
|
+
readonly statusText?: string;
|
|
77
|
+
readonly retryAfterMs?: number;
|
|
78
|
+
readonly retryable: boolean;
|
|
79
|
+
readonly attempts: number;
|
|
80
|
+
readonly headers: Headers;
|
|
81
|
+
readonly rawBody?: string;
|
|
82
|
+
readonly url: string;
|
|
83
|
+
override readonly cause?: unknown;
|
|
84
|
+
|
|
85
|
+
constructor(message: string, init: TransportErrorInit) {
|
|
86
|
+
super(message, { cause: init.cause });
|
|
87
|
+
this.name = "TransportError";
|
|
88
|
+
this.kind = init.kind;
|
|
89
|
+
this.status = init.status;
|
|
90
|
+
this.statusText = init.statusText;
|
|
91
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
92
|
+
this.retryable = init.retryable;
|
|
93
|
+
this.attempts = init.attempts;
|
|
94
|
+
this.headers = init.headers ?? new Headers();
|
|
95
|
+
this.rawBody = init.rawBody;
|
|
96
|
+
this.url = init.url;
|
|
97
|
+
this.cause = init.cause;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type FetchLike = (
|
|
102
|
+
input: string | URL | Request,
|
|
103
|
+
init?: RequestInit,
|
|
104
|
+
) => Promise<Response>;
|
|
105
|
+
|
|
106
|
+
interface AttemptErrorInput {
|
|
107
|
+
readonly kind: TransportErrorKind;
|
|
108
|
+
readonly status?: number;
|
|
109
|
+
readonly statusText?: string;
|
|
110
|
+
readonly headers?: Headers;
|
|
111
|
+
readonly rawBody?: string;
|
|
112
|
+
readonly retryAfterMs?: number;
|
|
113
|
+
readonly attempts: number;
|
|
114
|
+
readonly redactedUrl: string;
|
|
115
|
+
readonly retryable: boolean;
|
|
116
|
+
readonly aborted?: boolean;
|
|
117
|
+
readonly cause?: unknown;
|
|
118
|
+
readonly messages?: HttpClientMessages;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const DEFAULT_INITIAL_DELAY_MS = 100;
|
|
122
|
+
const DEFAULT_MAX_DELAY_MS = 1_000;
|
|
123
|
+
const DEFAULT_JITTER_RATIO = 0.2;
|
|
124
|
+
const SENSITIVE_QUERY_KEYS = new Set([
|
|
125
|
+
"apikey",
|
|
126
|
+
"api_key",
|
|
127
|
+
"api-key",
|
|
128
|
+
"key",
|
|
129
|
+
"secret",
|
|
130
|
+
"signature",
|
|
131
|
+
"token",
|
|
132
|
+
"access_token",
|
|
133
|
+
"listenkey",
|
|
134
|
+
"listen_key",
|
|
135
|
+
"passphrase",
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
export function isTransportError(error: unknown): error is TransportError {
|
|
139
|
+
if (!error || typeof error !== "object") {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const record = error as Record<string, unknown>;
|
|
144
|
+
return (
|
|
145
|
+
record.isAcexTransportError === true &&
|
|
146
|
+
typeof record.kind === "string" &&
|
|
147
|
+
typeof record.retryable === "boolean" &&
|
|
148
|
+
typeof record.attempts === "number"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function redactSecrets(value: string): string {
|
|
153
|
+
let redacted = value.replace(/https?:\/\/[^\s)]+/g, redactUrlMatch);
|
|
154
|
+
// Bare (non-URL) signed query fragments — e.g. a relative path like
|
|
155
|
+
// "/papi/v1/order?symbol=...&signature=..." — never match the http(s) URL
|
|
156
|
+
// branch above. Fold the whole fragment so the non-secret params riding
|
|
157
|
+
// alongside a signature do not leak; mirrors the "?query=[REDACTED]"
|
|
158
|
+
// collapse redactUrl applies to full signed URLs.
|
|
159
|
+
redacted = redacted.replace(
|
|
160
|
+
/\?[^\s)#?]*\bsignature=[^&\s)#]+/gi,
|
|
161
|
+
"?query=[REDACTED]",
|
|
162
|
+
);
|
|
163
|
+
redacted = redacted.replace(
|
|
164
|
+
/([?&](?:api[_-]?key|key|secret|signature|token|access_token|listen[_-]?key|passphrase)=)[^&\s)]+/gi,
|
|
165
|
+
"$1[REDACTED]",
|
|
166
|
+
);
|
|
167
|
+
redacted = redacted.replace(
|
|
168
|
+
/("(?:api[_-]?key|key|secret|signature|token|access_token|listen[_-]?key|passphrase)"\s*:\s*")[^"]*(")/gi,
|
|
169
|
+
"$1[REDACTED]$2",
|
|
170
|
+
);
|
|
171
|
+
redacted = redacted.replace(
|
|
172
|
+
/((?:api[_-]?key|key|secret|signature|token|access_token|listen[_-]?key|passphrase)\s*[:=]\s*)[^\s,;)"']+/gi,
|
|
173
|
+
"$1[REDACTED]",
|
|
174
|
+
);
|
|
175
|
+
redacted = redacted.replace(
|
|
176
|
+
/([?&])signature=\[REDACTED\]/gi,
|
|
177
|
+
"$1query=[REDACTED]",
|
|
178
|
+
);
|
|
179
|
+
redacted = redacted.replace(
|
|
180
|
+
/"signature"\s*:\s*"\[REDACTED\]"/gi,
|
|
181
|
+
'"redacted":"[REDACTED]"',
|
|
182
|
+
);
|
|
183
|
+
redacted = redacted.replace(
|
|
184
|
+
/signature\s*[:=]\s*\[REDACTED\]/gi,
|
|
185
|
+
"query=[REDACTED]",
|
|
186
|
+
);
|
|
187
|
+
return redacted;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function redactUrlMatch(match: string): string {
|
|
191
|
+
try {
|
|
192
|
+
const url = new URL(match);
|
|
193
|
+
if (url.searchParams.has("signature")) {
|
|
194
|
+
url.search = "?query=[REDACTED]";
|
|
195
|
+
return url.toString();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
199
|
+
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
|
|
200
|
+
url.searchParams.set(key, "[REDACTED]");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return url.toString();
|
|
205
|
+
} catch {
|
|
206
|
+
return match;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function redactUrl(input: string | URL): string {
|
|
211
|
+
const rawUrl = input.toString();
|
|
212
|
+
try {
|
|
213
|
+
const url = new URL(rawUrl);
|
|
214
|
+
const hasSignature = url.searchParams.has("signature");
|
|
215
|
+
if (hasSignature) {
|
|
216
|
+
url.search = "?query=[REDACTED]";
|
|
217
|
+
return url.toString();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let changed = false;
|
|
221
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
222
|
+
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
|
|
223
|
+
url.searchParams.set(key, "[REDACTED]");
|
|
224
|
+
changed = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return changed ? url.toString() : rawUrl;
|
|
229
|
+
} catch {
|
|
230
|
+
return redactSecrets(rawUrl);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function parseRetryAfterMs(value: string | null): number | undefined {
|
|
235
|
+
if (!value) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const seconds = Number(value);
|
|
240
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
241
|
+
return Math.round(seconds * 1_000);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const dateMs = Date.parse(value);
|
|
245
|
+
if (!Number.isFinite(dateMs)) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const deltaMs = dateMs - Date.now();
|
|
250
|
+
return deltaMs > 0 ? deltaMs : 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function httpRequest<T>(
|
|
254
|
+
options: HttpRequestOptions,
|
|
255
|
+
): Promise<HttpClientResponse<T>> {
|
|
256
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
257
|
+
const url = options.url.toString();
|
|
258
|
+
const redactedUrl = redactUrl(options.url);
|
|
259
|
+
const maxAttempts = Math.max(1, Math.floor(options.retryPolicy.maxAttempts));
|
|
260
|
+
let lastError: TransportError | undefined;
|
|
261
|
+
|
|
262
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
263
|
+
if (options.signal?.aborted) {
|
|
264
|
+
throw buildAttemptError({
|
|
265
|
+
kind: "network",
|
|
266
|
+
attempts: attempt,
|
|
267
|
+
redactedUrl,
|
|
268
|
+
retryable: false,
|
|
269
|
+
aborted: true,
|
|
270
|
+
messages: options.messages,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
return await executeAttempt<T>(
|
|
276
|
+
options,
|
|
277
|
+
fetchFn,
|
|
278
|
+
url,
|
|
279
|
+
redactedUrl,
|
|
280
|
+
attempt,
|
|
281
|
+
);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const transportError = isTransportError(error)
|
|
284
|
+
? error
|
|
285
|
+
: buildAttemptError({
|
|
286
|
+
kind: "network",
|
|
287
|
+
attempts: attempt,
|
|
288
|
+
redactedUrl,
|
|
289
|
+
retryable: retryableForKind("network", undefined, options),
|
|
290
|
+
cause: error,
|
|
291
|
+
messages: options.messages,
|
|
292
|
+
});
|
|
293
|
+
lastError = transportError;
|
|
294
|
+
if (!shouldRetry(transportError, attempt, maxAttempts, options)) {
|
|
295
|
+
throw transportError;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await delayBeforeRetry(attempt, options.retryPolicy, options.signal);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
throw lastError;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function executeAttempt<T>(
|
|
306
|
+
options: HttpRequestOptions,
|
|
307
|
+
fetchFn: FetchLike,
|
|
308
|
+
url: string,
|
|
309
|
+
redactedUrl: string,
|
|
310
|
+
attempts: number,
|
|
311
|
+
): Promise<HttpClientResponse<T>> {
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
const timeoutMs = options.timeoutMs;
|
|
314
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
315
|
+
let timedOut = false;
|
|
316
|
+
const onUpstreamAbort = (): void => {
|
|
317
|
+
controller.abort();
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (options.signal?.aborted) {
|
|
321
|
+
controller.abort();
|
|
322
|
+
} else {
|
|
323
|
+
options.signal?.addEventListener("abort", onUpstreamAbort, { once: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (timeoutMs !== undefined) {
|
|
327
|
+
timeout = setTimeout(() => {
|
|
328
|
+
timedOut = true;
|
|
329
|
+
controller.abort();
|
|
330
|
+
}, timeoutMs);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const response = await fetchFn(url, {
|
|
335
|
+
method: options.method,
|
|
336
|
+
headers: options.headers,
|
|
337
|
+
body: options.body,
|
|
338
|
+
signal: controller.signal,
|
|
339
|
+
});
|
|
340
|
+
const headers = new Headers(response.headers);
|
|
341
|
+
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
const rawBody = redactSecrets(await response.text());
|
|
344
|
+
const kind: TransportErrorKind =
|
|
345
|
+
response.status === 429 || response.status === 418
|
|
346
|
+
? "rate_limited"
|
|
347
|
+
: "http";
|
|
348
|
+
const retryAfterMs = parseRetryAfterMs(headers.get("Retry-After"));
|
|
349
|
+
throw buildAttemptError({
|
|
350
|
+
kind,
|
|
351
|
+
status: response.status,
|
|
352
|
+
statusText: response.statusText,
|
|
353
|
+
headers,
|
|
354
|
+
rawBody,
|
|
355
|
+
retryAfterMs,
|
|
356
|
+
attempts,
|
|
357
|
+
redactedUrl,
|
|
358
|
+
retryable: retryableForKind(kind, response.status, options),
|
|
359
|
+
messages: options.messages,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const parsed = await parseResponseBody<T>(
|
|
364
|
+
response,
|
|
365
|
+
options,
|
|
366
|
+
attempts,
|
|
367
|
+
redactedUrl,
|
|
368
|
+
);
|
|
369
|
+
return {
|
|
370
|
+
body: parsed.body,
|
|
371
|
+
status: response.status,
|
|
372
|
+
statusText: response.statusText,
|
|
373
|
+
headers,
|
|
374
|
+
rawBody: parsed.rawBody,
|
|
375
|
+
url,
|
|
376
|
+
redactedUrl,
|
|
377
|
+
attempts,
|
|
378
|
+
};
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (isTransportError(error)) {
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (isAbortError(error)) {
|
|
385
|
+
throw buildAttemptError({
|
|
386
|
+
kind: timedOut ? "timeout" : "network",
|
|
387
|
+
attempts,
|
|
388
|
+
redactedUrl,
|
|
389
|
+
retryable: timedOut
|
|
390
|
+
? retryableForKind("timeout", undefined, options)
|
|
391
|
+
: false,
|
|
392
|
+
aborted: !timedOut,
|
|
393
|
+
cause: error,
|
|
394
|
+
messages: options.messages,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
throw buildAttemptError({
|
|
399
|
+
kind: "network",
|
|
400
|
+
attempts,
|
|
401
|
+
redactedUrl,
|
|
402
|
+
retryable: retryableForKind("network", undefined, options),
|
|
403
|
+
cause: error,
|
|
404
|
+
messages: options.messages,
|
|
405
|
+
});
|
|
406
|
+
} finally {
|
|
407
|
+
if (timeout) {
|
|
408
|
+
clearTimeout(timeout);
|
|
409
|
+
}
|
|
410
|
+
options.signal?.removeEventListener("abort", onUpstreamAbort);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function parseResponseBody<T>(
|
|
415
|
+
response: Response,
|
|
416
|
+
options: HttpRequestOptions,
|
|
417
|
+
attempts: number,
|
|
418
|
+
redactedUrl: string,
|
|
419
|
+
): Promise<{ body: T; rawBody?: string }> {
|
|
420
|
+
if (options.parseAs === "none") {
|
|
421
|
+
return { body: undefined as T };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (options.parseAs === "text") {
|
|
425
|
+
const rawBody = await response.text();
|
|
426
|
+
return { body: rawBody as T, rawBody };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (options.jsonParseMode === "response") {
|
|
430
|
+
try {
|
|
431
|
+
return { body: (await response.json()) as T };
|
|
432
|
+
} catch (error) {
|
|
433
|
+
throw buildAttemptError({
|
|
434
|
+
kind: "parse",
|
|
435
|
+
status: response.status,
|
|
436
|
+
statusText: response.statusText,
|
|
437
|
+
headers: new Headers(response.headers),
|
|
438
|
+
attempts,
|
|
439
|
+
redactedUrl,
|
|
440
|
+
retryable: false,
|
|
441
|
+
cause: error,
|
|
442
|
+
messages: options.messages,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const rawBody = await response.text();
|
|
448
|
+
if (!rawBody) {
|
|
449
|
+
switch (options.emptyBody ?? "undefined") {
|
|
450
|
+
case "empty_object":
|
|
451
|
+
return { body: {} as T, rawBody };
|
|
452
|
+
case "empty_string":
|
|
453
|
+
return { body: "" as T, rawBody };
|
|
454
|
+
case "undefined":
|
|
455
|
+
return { body: undefined as T, rawBody };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
return { body: JSON.parse(rawBody) as T, rawBody };
|
|
461
|
+
} catch (error) {
|
|
462
|
+
throw buildAttemptError({
|
|
463
|
+
kind: "parse",
|
|
464
|
+
status: response.status,
|
|
465
|
+
statusText: response.statusText,
|
|
466
|
+
headers: new Headers(response.headers),
|
|
467
|
+
rawBody: redactSecrets(rawBody),
|
|
468
|
+
attempts,
|
|
469
|
+
redactedUrl,
|
|
470
|
+
retryable: false,
|
|
471
|
+
cause: error,
|
|
472
|
+
messages: options.messages,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function buildAttemptError(input: AttemptErrorInput): TransportError {
|
|
478
|
+
const messageInput: HttpErrorMessageInput = {
|
|
479
|
+
kind: input.kind,
|
|
480
|
+
status: input.status,
|
|
481
|
+
statusText: input.statusText,
|
|
482
|
+
retryAfterMs: input.retryAfterMs,
|
|
483
|
+
attempts: input.attempts,
|
|
484
|
+
rawBody: input.rawBody,
|
|
485
|
+
url: input.redactedUrl,
|
|
486
|
+
};
|
|
487
|
+
const message =
|
|
488
|
+
messageForKind(input.messages, input.kind, input.aborted)?.(messageInput) ??
|
|
489
|
+
defaultMessage(messageInput);
|
|
490
|
+
|
|
491
|
+
return new TransportError(message, {
|
|
492
|
+
...messageInput,
|
|
493
|
+
headers: input.headers,
|
|
494
|
+
retryable: input.retryable,
|
|
495
|
+
cause: input.cause,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function messageForKind(
|
|
500
|
+
messages: HttpClientMessages | undefined,
|
|
501
|
+
kind: TransportErrorKind,
|
|
502
|
+
aborted: boolean | undefined,
|
|
503
|
+
): ((input: HttpErrorMessageInput) => string) | undefined {
|
|
504
|
+
if (kind === "network" && aborted) {
|
|
505
|
+
return messages?.aborted ?? messages?.network;
|
|
506
|
+
}
|
|
507
|
+
if (kind === "http" || kind === "rate_limited") {
|
|
508
|
+
return messages?.http;
|
|
509
|
+
}
|
|
510
|
+
return messages?.[kind];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function defaultMessage(input: HttpErrorMessageInput): string {
|
|
514
|
+
switch (input.kind) {
|
|
515
|
+
case "timeout":
|
|
516
|
+
return `HTTP request timeout after attempt ${input.attempts}: ${input.url}`;
|
|
517
|
+
case "network":
|
|
518
|
+
return `HTTP request failed: ${input.url}`;
|
|
519
|
+
case "parse":
|
|
520
|
+
return `HTTP response parse failed: ${input.url}`;
|
|
521
|
+
case "rate_limited":
|
|
522
|
+
case "http": {
|
|
523
|
+
const status = [input.status, input.statusText].filter(Boolean).join(" ");
|
|
524
|
+
const body = input.rawBody ? ` ${input.rawBody}` : "";
|
|
525
|
+
return `HTTP request failed: ${status} ${input.url}${body}`;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function retryableForKind(
|
|
531
|
+
kind: TransportErrorKind,
|
|
532
|
+
status: number | undefined,
|
|
533
|
+
options: HttpRequestOptions,
|
|
534
|
+
): boolean {
|
|
535
|
+
if (!options.retryPolicy.idempotent || options.signal?.aborted) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (kind === "network" || kind === "timeout") {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (kind === "http" && status !== undefined) {
|
|
544
|
+
return status >= 500 && status <= 599;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function shouldRetry(
|
|
551
|
+
error: TransportError,
|
|
552
|
+
attempt: number,
|
|
553
|
+
maxAttempts: number,
|
|
554
|
+
options: HttpRequestOptions,
|
|
555
|
+
): boolean {
|
|
556
|
+
return error.retryable && attempt < maxAttempts && !options.signal?.aborted;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function delayBeforeRetry(
|
|
560
|
+
attempt: number,
|
|
561
|
+
retryPolicy: HttpRetryPolicy,
|
|
562
|
+
signal?: AbortSignal,
|
|
563
|
+
): Promise<void> {
|
|
564
|
+
const sleep = retryPolicy.sleep ?? defaultSleep;
|
|
565
|
+
const baseDelay = Math.min(
|
|
566
|
+
retryPolicy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
|
|
567
|
+
(retryPolicy.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS) *
|
|
568
|
+
2 ** Math.max(0, attempt - 1),
|
|
569
|
+
);
|
|
570
|
+
const jitterRatio = retryPolicy.jitterRatio ?? DEFAULT_JITTER_RATIO;
|
|
571
|
+
const random = retryPolicy.random ?? Math.random;
|
|
572
|
+
const jitter = baseDelay * jitterRatio * (random() * 2 - 1);
|
|
573
|
+
const delayMs = Math.max(0, Math.round(baseDelay + jitter));
|
|
574
|
+
|
|
575
|
+
if (signal === undefined) {
|
|
576
|
+
await sleep(delayMs);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (signal.aborted) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// Race the backoff against the upstream abort so a cancel mid-backoff
|
|
583
|
+
// returns immediately instead of waiting out the full delay. The retry
|
|
584
|
+
// loop re-checks signal.aborted on the next iteration and throws.
|
|
585
|
+
await new Promise<void>((resolve) => {
|
|
586
|
+
let settled = false;
|
|
587
|
+
const finish = (): void => {
|
|
588
|
+
if (settled) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
settled = true;
|
|
592
|
+
signal.removeEventListener("abort", finish);
|
|
593
|
+
resolve();
|
|
594
|
+
};
|
|
595
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
596
|
+
void Promise.resolve(sleep(delayMs)).then(finish);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isAbortError(error: unknown): boolean {
|
|
601
|
+
return error instanceof Error && error.name === "AbortError";
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
605
|
+
return new Promise((resolve) => {
|
|
606
|
+
setTimeout(resolve, ms);
|
|
607
|
+
});
|
|
608
|
+
}
|