@doist/comms-sdk 3.0.0 → 3.0.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.
|
@@ -4,7 +4,8 @@ import { transformTimestamps } from '../utils/timestamp-conversion.js';
|
|
|
4
4
|
import { getDefaultTransport } from './http-dispatcher.js';
|
|
5
5
|
export async function fetchWithRetry(url, options, maxRetries = 3, customFetch) {
|
|
6
6
|
let lastError;
|
|
7
|
-
|
|
7
|
+
let attempt = 0;
|
|
8
|
+
for (; attempt <= maxRetries; attempt++) {
|
|
8
9
|
let clearTimeoutFn;
|
|
9
10
|
try {
|
|
10
11
|
let requestSignal = options.signal || undefined;
|
|
@@ -56,7 +57,56 @@ export async function fetchWithRetry(url, options, maxRetries = 3, customFetch)
|
|
|
56
57
|
if (lastError instanceof CommsRequestError) {
|
|
57
58
|
throw lastError;
|
|
58
59
|
}
|
|
59
|
-
throw new CommsRequestError(lastError
|
|
60
|
+
throw new CommsRequestError(describeTransportFailure(lastError, attempt + 1), undefined, undefined, {
|
|
61
|
+
cause: lastError,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Causes reported before the rest of the chain is dropped. */
|
|
65
|
+
const CAUSE_CHAIN_LIMIT = 3;
|
|
66
|
+
/**
|
|
67
|
+
* Builds the message for a request that never produced a response.
|
|
68
|
+
*
|
|
69
|
+
* `fetch` reports every transport problem as the same `fetch failed`, and puts
|
|
70
|
+
* the reason a caller actually needs — `ENOTFOUND`, `ECONNRESET`, a TLS failure
|
|
71
|
+
* — in `cause`. Dropping that leaves an unattributable error in the logs, so
|
|
72
|
+
* unwrap the chain into the message and keep the original as `cause`.
|
|
73
|
+
*/
|
|
74
|
+
function describeTransportFailure(error, attempts) {
|
|
75
|
+
const base = error?.message ?? 'Request failed';
|
|
76
|
+
const cause = describeCauseChain(error);
|
|
77
|
+
const tries = attempts > 1 ? ` after ${attempts} attempts` : '';
|
|
78
|
+
return cause ? `${base} (${cause})${tries}` : `${base}${tries}`;
|
|
79
|
+
}
|
|
80
|
+
function describeCauseChain(error) {
|
|
81
|
+
const descriptions = [];
|
|
82
|
+
for (const cause of causeChain(error)) {
|
|
83
|
+
const description = describeCause(cause);
|
|
84
|
+
if (description) {
|
|
85
|
+
descriptions.push(description);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return descriptions.length > 0 ? descriptions.join(' <- ') : undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Walks an error's `cause` chain, stopping at {@link CAUSE_CHAIN_LIMIT} and on
|
|
92
|
+
* a cycle. A failed connect can carry one error per address it tried; the first
|
|
93
|
+
* is representative and the rest repeat the same failure.
|
|
94
|
+
*/
|
|
95
|
+
function* causeChain(error) {
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
let current = error?.cause;
|
|
98
|
+
let depth = 0;
|
|
99
|
+
while (current instanceof Error && !seen.has(current) && depth < CAUSE_CHAIN_LIMIT) {
|
|
100
|
+
seen.add(current);
|
|
101
|
+
depth++;
|
|
102
|
+
yield current;
|
|
103
|
+
const aggregated = current instanceof AggregateError ? current.errors[0] : undefined;
|
|
104
|
+
current = aggregated ?? current.cause;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function describeCause(error) {
|
|
108
|
+
const code = error.code;
|
|
109
|
+
return typeof code === 'string' && code.length > 0 ? `${code}: ${error.message}` : error.message;
|
|
60
110
|
}
|
|
61
111
|
async function fetchWithDefaultTransport(url, options, signal) {
|
|
62
112
|
// Read the dispatcher and its paired `fetch` as one value so they can never
|
|
@@ -92,13 +142,60 @@ function createTimeoutError(timeoutMs) {
|
|
|
92
142
|
error.name = timeoutErrorName;
|
|
93
143
|
return error;
|
|
94
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Transport-level failure codes worth another attempt: the request never got an
|
|
147
|
+
* answer, so nothing about the response argues against retrying. `UND_ERR_SOCKET`
|
|
148
|
+
* covers the HTTP/2 `GOAWAY` an edge proxy sends when it retires a pooled
|
|
149
|
+
* connection, which is otherwise indistinguishable from any other `fetch failed`.
|
|
150
|
+
*/
|
|
151
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
152
|
+
'ECONNREFUSED',
|
|
153
|
+
'ECONNRESET',
|
|
154
|
+
'EAI_AGAIN',
|
|
155
|
+
'ENOTFOUND',
|
|
156
|
+
'EPIPE',
|
|
157
|
+
'ETIMEDOUT',
|
|
158
|
+
'UND_ERR_SOCKET',
|
|
159
|
+
]);
|
|
95
160
|
function isNetworkError(error) {
|
|
96
161
|
return (error.name === 'TypeError' ||
|
|
97
162
|
error.name === timeoutErrorName ||
|
|
98
|
-
error.message.toLowerCase().includes('network')
|
|
163
|
+
error.message.toLowerCase().includes('network') ||
|
|
164
|
+
hasNetworkErrorCode(error));
|
|
165
|
+
}
|
|
166
|
+
function hasNetworkErrorCode(error) {
|
|
167
|
+
// The code can be on the error itself — a `customFetch` is free to reject
|
|
168
|
+
// with one directly — or on anything `fetch` wrapped to produce it.
|
|
169
|
+
if (hasRetryableCode(error)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
for (const cause of causeChain(error)) {
|
|
173
|
+
if (hasRetryableCode(cause)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
function hasRetryableCode(error) {
|
|
180
|
+
const code = error.code;
|
|
181
|
+
return typeof code === 'string' && NETWORK_ERROR_CODES.has(code);
|
|
99
182
|
}
|
|
183
|
+
/** First retry delay, doubling by {@link RETRY_DELAY_FACTOR} per attempt. */
|
|
184
|
+
const RETRY_BASE_DELAY_MS = 100;
|
|
185
|
+
const RETRY_DELAY_FACTOR = 4;
|
|
186
|
+
const RETRY_MAX_DELAY_MS = 2000;
|
|
187
|
+
/**
|
|
188
|
+
* How long to wait before retry `retryCount` (1-based).
|
|
189
|
+
*
|
|
190
|
+
* A retired connection takes a moment to be torn down and replaced, so retrying
|
|
191
|
+
* immediately just re-dispatches onto the socket that is going away and burns
|
|
192
|
+
* the whole retry budget inside a second. The delay grows so the later attempts
|
|
193
|
+
* land on a fresh connection, and carries jitter because one retirement hits
|
|
194
|
+
* every pooled connection at once: without it, every caller reconnects in step.
|
|
195
|
+
*/
|
|
100
196
|
function getRetryDelay(retryCount) {
|
|
101
|
-
|
|
197
|
+
const delay = Math.min(RETRY_BASE_DELAY_MS * RETRY_DELAY_FACTOR ** (retryCount - 1), RETRY_MAX_DELAY_MS);
|
|
198
|
+
return delay / 2 + Math.random() * (delay / 2);
|
|
102
199
|
}
|
|
103
200
|
function createTimeoutSignal(timeoutMs, existingSignal) {
|
|
104
201
|
const controller = new AbortController();
|
package/dist/esm/types/errors.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CustomError } from 'ts-custom-error';
|
|
2
2
|
export class CommsRequestError extends CustomError {
|
|
3
|
-
constructor(message, httpStatusCode, responseData) {
|
|
4
|
-
super(message);
|
|
3
|
+
constructor(message, httpStatusCode, responseData, options) {
|
|
4
|
+
super(message, options);
|
|
5
5
|
this.httpStatusCode = httpStatusCode;
|
|
6
6
|
this.responseData = responseData;
|
|
7
7
|
}
|
|
@@ -2,5 +2,5 @@ import { CustomError } from 'ts-custom-error';
|
|
|
2
2
|
export declare class CommsRequestError extends CustomError {
|
|
3
3
|
httpStatusCode?: number;
|
|
4
4
|
responseData?: unknown;
|
|
5
|
-
constructor(message: string, httpStatusCode?: number, responseData?: unknown);
|
|
5
|
+
constructor(message: string, httpStatusCode?: number, responseData?: unknown, options?: ErrorOptions);
|
|
6
6
|
}
|