uvd-x402-sdk 2.74.0 → 2.76.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 +145 -1
- package/dist/adapters/index.d.mts +3 -3
- package/dist/adapters/index.d.ts +3 -3
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/index.mjs.map +1 -1
- package/dist/backend/index.d.mts +350 -54
- package/dist/backend/index.d.ts +350 -54
- package/dist/backend/index.js +488 -253
- package/dist/backend/index.js.map +1 -1
- package/dist/backend/index.mjs +477 -254
- package/dist/backend/index.mjs.map +1 -1
- package/dist/erc8128/index.d.mts +1 -1
- package/dist/erc8128/index.d.ts +1 -1
- package/dist/{index-BkeMrSHP.d.mts → index-CiRbsqXe.d.mts} +2 -2
- package/dist/{index-BQ45e-Xa.d.ts → index-DTXTOEby.d.ts} +2 -2
- package/dist/{index-DeJMYEKC.d.mts → index-ZH10otHE.d.mts} +1 -1
- package/dist/{index-DeJMYEKC.d.ts → index-ZH10otHE.d.ts} +1 -1
- package/dist/index.d.mts +50 -10
- package/dist/index.d.ts +50 -10
- package/dist/index.js +238 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +228 -40
- package/dist/index.mjs.map +1 -1
- package/dist/{ows-Z9v4GxOZ.d.mts → ows-CYIVd4xO.d.mts} +1 -1
- package/dist/{ows-C-KmORG9.d.ts → ows-DTDixPzO.d.ts} +1 -1
- package/dist/providers/algorand/index.d.mts +1 -1
- package/dist/providers/algorand/index.d.ts +1 -1
- package/dist/providers/algorand/index.js +2 -0
- package/dist/providers/algorand/index.js.map +1 -1
- package/dist/providers/algorand/index.mjs +2 -0
- package/dist/providers/algorand/index.mjs.map +1 -1
- package/dist/providers/evm/index.d.mts +1 -1
- package/dist/providers/evm/index.d.ts +1 -1
- package/dist/providers/evm/index.js.map +1 -1
- package/dist/providers/evm/index.mjs.map +1 -1
- package/dist/providers/near/index.d.mts +1 -1
- package/dist/providers/near/index.d.ts +1 -1
- package/dist/providers/near/index.js +2 -0
- package/dist/providers/near/index.js.map +1 -1
- package/dist/providers/near/index.mjs +2 -0
- package/dist/providers/near/index.mjs.map +1 -1
- package/dist/providers/solana/index.d.mts +1 -1
- package/dist/providers/solana/index.d.ts +1 -1
- package/dist/providers/solana/index.js +2 -0
- package/dist/providers/solana/index.js.map +1 -1
- package/dist/providers/solana/index.mjs +2 -0
- package/dist/providers/solana/index.mjs.map +1 -1
- package/dist/providers/stellar/index.d.mts +1 -1
- package/dist/providers/stellar/index.d.ts +1 -1
- package/dist/providers/stellar/index.js +2 -0
- package/dist/providers/stellar/index.js.map +1 -1
- package/dist/providers/stellar/index.mjs +2 -0
- package/dist/providers/stellar/index.mjs.map +1 -1
- package/dist/providers/sui/index.d.mts +1 -1
- package/dist/providers/sui/index.d.ts +1 -1
- package/dist/providers/sui/index.js +2 -0
- package/dist/providers/sui/index.js.map +1 -1
- package/dist/providers/sui/index.mjs +2 -0
- package/dist/providers/sui/index.mjs.map +1 -1
- package/dist/providers/xrpl/index.d.mts +1 -1
- package/dist/providers/xrpl/index.d.ts +1 -1
- package/dist/providers/xrpl/index.js +2 -0
- package/dist/providers/xrpl/index.js.map +1 -1
- package/dist/providers/xrpl/index.mjs +2 -0
- package/dist/providers/xrpl/index.mjs.map +1 -1
- package/dist/react/index.d.mts +3 -3
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs.map +1 -1
- package/dist/utils/index.d.mts +31 -260
- package/dist/utils/index.d.ts +31 -260
- package/dist/utils/index.js +17 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/index.mjs +16 -1
- package/dist/utils/index.mjs.map +1 -1
- package/dist/validation-De3bessL.d.mts +273 -0
- package/dist/validation-DsbfDAtV.d.ts +273 -0
- package/dist/{wallet-w7BnImDG.d.mts → wallet-0cX9Pw2F.d.mts} +1 -1
- package/dist/{wallet-w7BnImDG.d.ts → wallet-0cX9Pw2F.d.ts} +1 -1
- package/package.json +1 -1
- package/src/backend/facilitator-error.ts +355 -0
- package/src/backend/index.ts +536 -263
- package/src/dx402.ts +123 -8
- package/src/index.ts +17 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/personal-sign.ts +54 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a facilitator refusal as DATA instead of prose.
|
|
3
|
+
*
|
|
4
|
+
* # Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* `402` and `503` say opposite things and the SDK used to collapse both into
|
|
7
|
+
* `success: false` plus an English sentence.
|
|
8
|
+
*
|
|
9
|
+
* - **402** means the payment was REJECTED. The credential is spent; sign a new
|
|
10
|
+
* authorization.
|
|
11
|
+
* - **503** means NO VERDICT WAS REACHED. Nothing was rejected and nothing was
|
|
12
|
+
* executed; retry the SAME credential.
|
|
13
|
+
*
|
|
14
|
+
* Turning a 503 into a 402 makes the buyer sign and send a second payment for a
|
|
15
|
+
* money movement that was never refused — they pay twice. That is the most
|
|
16
|
+
* expensive mistake this SDK can make, so every facilitator edge now reports the
|
|
17
|
+
* status, the facilitator's own `reason`, and whether the request may be
|
|
18
|
+
* replayed.
|
|
19
|
+
*
|
|
20
|
+
* # The writer lease
|
|
21
|
+
*
|
|
22
|
+
* The facilitator serialises every EVM write through the one process that holds
|
|
23
|
+
* the EVM writer lease, because they all spend gas from the same EOA and the
|
|
24
|
+
* nonce for it is allocated in memory. A task that does not hold the lease
|
|
25
|
+
* forwards the request to the one that does; when it cannot, it answers
|
|
26
|
+
* `503` + `Retry-After: 5` + `{"error": "...", "reason": "<why>"}`.
|
|
27
|
+
*
|
|
28
|
+
* The five reasons do NOT share retry semantics, which is the whole point of
|
|
29
|
+
* surfacing them:
|
|
30
|
+
*
|
|
31
|
+
* | reason | did the write run? | replay? |
|
|
32
|
+
* |----------------------------|--------------------|---------|
|
|
33
|
+
* | `holder_unknown` | no | yes |
|
|
34
|
+
* | `forwarding_disabled` | no | yes |
|
|
35
|
+
* | `forwarded_but_not_writer` | no | yes |
|
|
36
|
+
* | `body_unreadable` | no | yes |
|
|
37
|
+
* | `forward_failed` | **maybe** | **no** |
|
|
38
|
+
*
|
|
39
|
+
* `forward_failed` is emitted after the forward was attempted: the holder may
|
|
40
|
+
* have executed the write and the response been lost on the way back. It is a
|
|
41
|
+
* timeout wearing a status code. Replaying a `/register` on it is exactly the
|
|
42
|
+
* sequence that once minted five duplicate agents — reconcile with
|
|
43
|
+
* `GET /identity/{network}/owner/{recipient}` or `getRegisterStatus` instead.
|
|
44
|
+
*
|
|
45
|
+
* Source of the shape: x402-rs `src/handlers.rs` `writer_lease_unavailable()`
|
|
46
|
+
* and `require_writer_lease()`.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A `reason` the facilitator attaches to a writer-lease 503.
|
|
51
|
+
*
|
|
52
|
+
* Typed as a union for discrimination but never used to VALIDATE: the fields
|
|
53
|
+
* that carry it are plain `string`, so a reason added on the server does not
|
|
54
|
+
* break compilation here.
|
|
55
|
+
*/
|
|
56
|
+
export type WriterLeaseReason =
|
|
57
|
+
| 'holder_unknown'
|
|
58
|
+
| 'forwarding_disabled'
|
|
59
|
+
| 'forwarded_but_not_writer'
|
|
60
|
+
| 'body_unreadable'
|
|
61
|
+
| 'forward_failed';
|
|
62
|
+
|
|
63
|
+
/** Every writer-lease reason the facilitator emits today. */
|
|
64
|
+
export const WRITER_LEASE_REASONS: readonly WriterLeaseReason[] = [
|
|
65
|
+
'holder_unknown',
|
|
66
|
+
'forwarding_disabled',
|
|
67
|
+
'forwarded_but_not_writer',
|
|
68
|
+
'body_unreadable',
|
|
69
|
+
'forward_failed',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The reasons returned BEFORE the write is handed to anyone.
|
|
74
|
+
*
|
|
75
|
+
* The facilitator refuses these in its router, so the request provably did not
|
|
76
|
+
* execute and re-sending the identical body is safe — including a mint.
|
|
77
|
+
*/
|
|
78
|
+
export const REPLAYABLE_LEASE_REASONS: readonly WriterLeaseReason[] = [
|
|
79
|
+
'holder_unknown',
|
|
80
|
+
'forwarding_disabled',
|
|
81
|
+
'forwarded_but_not_writer',
|
|
82
|
+
'body_unreadable',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The reason that is ambiguous: the write may already have happened.
|
|
87
|
+
*
|
|
88
|
+
* Still `retryable` — the credential was not rejected — but never replayed
|
|
89
|
+
* automatically. Resolve it by reading state, not by re-POSTing.
|
|
90
|
+
*/
|
|
91
|
+
export const AMBIGUOUS_LEASE_REASONS: readonly WriterLeaseReason[] = ['forward_failed'];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ceiling, in seconds, on how long an automatic retry will wait.
|
|
95
|
+
*
|
|
96
|
+
* `Retry-After` is a hint from a server that may be misconfigured. Honouring a
|
|
97
|
+
* literal `Retry-After: 3600` would hang the caller's request for an hour
|
|
98
|
+
* inside a function documented as returning promptly, so the header is honoured
|
|
99
|
+
* only up to this bound.
|
|
100
|
+
*/
|
|
101
|
+
export const MAX_RETRY_AFTER_SECONDS = 15;
|
|
102
|
+
|
|
103
|
+
/** Wait used when a 503 carries no usable `Retry-After`. */
|
|
104
|
+
export const DEFAULT_RETRY_AFTER_SECONDS = 5;
|
|
105
|
+
|
|
106
|
+
/** How many EXTRA attempts a retryable facilitator refusal gets by default. */
|
|
107
|
+
export const DEFAULT_FACILITATOR_RETRIES = 2;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A non-2xx answer from the facilitator, kept structured.
|
|
111
|
+
*
|
|
112
|
+
* `error` is byte-identical to the string this SDK has always produced, so
|
|
113
|
+
* callers matching on it keep working; everything else is new and optional.
|
|
114
|
+
*/
|
|
115
|
+
export interface FacilitatorErrorInfo {
|
|
116
|
+
/** Legacy flattened message: `Facilitator error: <status> - <body>`. */
|
|
117
|
+
error: string;
|
|
118
|
+
/** HTTP status the facilitator answered with. */
|
|
119
|
+
status: number;
|
|
120
|
+
/** The facilitator's own `reason`, when the body carried one. */
|
|
121
|
+
reason?: string;
|
|
122
|
+
/**
|
|
123
|
+
* Seconds to wait before retrying, already clamped to
|
|
124
|
+
* {@link MAX_RETRY_AFTER_SECONDS}. Absent when the answer is not retryable.
|
|
125
|
+
*/
|
|
126
|
+
retryAfterSeconds?: number;
|
|
127
|
+
/**
|
|
128
|
+
* The request reached no verdict; the credential is untouched and the SAME
|
|
129
|
+
* request may be sent again. Never surface this as a payment rejection.
|
|
130
|
+
*/
|
|
131
|
+
retryable: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* The facilitator NAMED a reason that proves it executed nothing, so an
|
|
134
|
+
* automatic replay cannot double-write.
|
|
135
|
+
*
|
|
136
|
+
* False for `forward_failed`, whose write may already have landed, and false
|
|
137
|
+
* for an unattributed 5xx from a proxy — "something in front answered" is not
|
|
138
|
+
* evidence that nothing ran.
|
|
139
|
+
*/
|
|
140
|
+
safeToReplay: boolean;
|
|
141
|
+
/** Raw response body, for logs. */
|
|
142
|
+
body: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Fields every facilitator response type gained so a 503 stops looking terminal. */
|
|
146
|
+
export interface FacilitatorFailureFields {
|
|
147
|
+
/** HTTP status, when the failure was transport-level rather than a verdict. */
|
|
148
|
+
status?: number;
|
|
149
|
+
/** The facilitator's `reason` for the refusal (see {@link WriterLeaseReason}). */
|
|
150
|
+
reason?: string;
|
|
151
|
+
/** True when the same request may be sent again without re-signing anything. */
|
|
152
|
+
retryable?: boolean;
|
|
153
|
+
/** Seconds to wait before retrying, clamped to {@link MAX_RETRY_AFTER_SECONDS}. */
|
|
154
|
+
retryAfterSeconds?: number;
|
|
155
|
+
/** True when the facilitator provably executed nothing. */
|
|
156
|
+
safeToReplay?: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A 503/429 that carries a reason known to be pre-execution. */
|
|
160
|
+
export function isReplayableLeaseReason(reason?: string): boolean {
|
|
161
|
+
return REPLAYABLE_LEASE_REASONS.includes(reason as WriterLeaseReason);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A 503 whose write may already have run. Reconcile, do not re-POST. */
|
|
165
|
+
export function isAmbiguousLeaseReason(reason?: string): boolean {
|
|
166
|
+
return AMBIGUOUS_LEASE_REASONS.includes(reason as WriterLeaseReason);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read `Retry-After` and clamp it.
|
|
171
|
+
*
|
|
172
|
+
* Tolerates a `Response`-shaped object with no `headers` at all: test doubles
|
|
173
|
+
* and non-standard fetch polyfills routinely omit it, and throwing there would
|
|
174
|
+
* turn a readable refusal into an unreadable crash.
|
|
175
|
+
*/
|
|
176
|
+
export function parseRetryAfterSeconds(response: {
|
|
177
|
+
headers?: { get?: (name: string) => string | null };
|
|
178
|
+
}): number | undefined {
|
|
179
|
+
let raw: string | null | undefined;
|
|
180
|
+
try {
|
|
181
|
+
raw = response?.headers?.get?.('retry-after');
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (raw === null || raw === undefined || raw === '') return undefined;
|
|
186
|
+
const seconds = Number(raw);
|
|
187
|
+
if (!Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
188
|
+
return Math.min(seconds, MAX_RETRY_AFTER_SECONDS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Pull `reason` out of a JSON error body, if there is one and it is a string. */
|
|
192
|
+
function reasonFrom(body: string): string | undefined {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(body) as { reason?: unknown };
|
|
195
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.reason === 'string') {
|
|
196
|
+
return parsed.reason;
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
/* an HTML 503 from a load balancer is still a 503 */
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Turn a non-2xx facilitator response into {@link FacilitatorErrorInfo}.
|
|
206
|
+
*
|
|
207
|
+
* Reads the body exactly once. The `error` string keeps the historical format
|
|
208
|
+
* verbatim; reformatting it would break callers that match on it.
|
|
209
|
+
*/
|
|
210
|
+
export async function readFacilitatorError(response: {
|
|
211
|
+
status: number;
|
|
212
|
+
text: () => Promise<string>;
|
|
213
|
+
headers?: { get?: (name: string) => string | null };
|
|
214
|
+
}): Promise<FacilitatorErrorInfo> {
|
|
215
|
+
let body = '';
|
|
216
|
+
try {
|
|
217
|
+
body = await response.text();
|
|
218
|
+
} catch {
|
|
219
|
+
body = '';
|
|
220
|
+
}
|
|
221
|
+
const status = response.status;
|
|
222
|
+
const reason = reasonFrom(body);
|
|
223
|
+
// 429 is admission control and 502/503/504 are "no verdict". None of them is
|
|
224
|
+
// a rejection of the payment, so none of them may be reported as one.
|
|
225
|
+
const retryable = status === 429 || status === 502 || status === 503 || status === 504;
|
|
226
|
+
const retryAfterSeconds = retryable
|
|
227
|
+
? (parseRetryAfterSeconds(response) ?? DEFAULT_RETRY_AFTER_SECONDS)
|
|
228
|
+
: undefined;
|
|
229
|
+
// Replay only what the facilitator SAID it did not execute.
|
|
230
|
+
//
|
|
231
|
+
// A 429 is admission control: the request was counted, never handled. A 503
|
|
232
|
+
// that names one of the pre-execution writer-lease reasons is refused in the
|
|
233
|
+
// router before any signing. Everything else — an unattributed 503 from a
|
|
234
|
+
// load balancer, a 502, a 504, `forward_failed` — is merely retryable: the
|
|
235
|
+
// caller may resend after reconciling, but this SDK will not do it for them,
|
|
236
|
+
// because "a proxy answered" is not evidence that nothing ran.
|
|
237
|
+
const safeToReplay = status === 429 || (status === 503 && isReplayableLeaseReason(reason));
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
error: `Facilitator error: ${status} - ${body}`,
|
|
241
|
+
status,
|
|
242
|
+
reason,
|
|
243
|
+
retryAfterSeconds,
|
|
244
|
+
retryable,
|
|
245
|
+
safeToReplay,
|
|
246
|
+
body,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The subset of {@link FacilitatorErrorInfo} that is copied onto a response. */
|
|
251
|
+
export function failureFields(info: FacilitatorErrorInfo): Required<
|
|
252
|
+
Pick<FacilitatorFailureFields, 'status' | 'retryable' | 'safeToReplay'>
|
|
253
|
+
> &
|
|
254
|
+
FacilitatorFailureFields {
|
|
255
|
+
return {
|
|
256
|
+
status: info.status,
|
|
257
|
+
retryable: info.retryable,
|
|
258
|
+
safeToReplay: info.safeToReplay,
|
|
259
|
+
...(info.reason !== undefined ? { reason: info.reason } : {}),
|
|
260
|
+
...(info.retryAfterSeconds !== undefined
|
|
261
|
+
? { retryAfterSeconds: info.retryAfterSeconds }
|
|
262
|
+
: {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Copy the failure fields off one response onto another.
|
|
268
|
+
*
|
|
269
|
+
* Used where a wrapper returns its own shape: without this the wrapper flattens
|
|
270
|
+
* a `503` back into a bare failure and reintroduces, one level up, exactly the
|
|
271
|
+
* ambiguity the fields exist to remove.
|
|
272
|
+
*/
|
|
273
|
+
export function carryFailureFields(
|
|
274
|
+
source: FacilitatorFailureFields,
|
|
275
|
+
): FacilitatorFailureFields {
|
|
276
|
+
return {
|
|
277
|
+
...(source.status !== undefined ? { status: source.status } : {}),
|
|
278
|
+
...(source.reason !== undefined ? { reason: source.reason } : {}),
|
|
279
|
+
...(source.retryable !== undefined ? { retryable: source.retryable } : {}),
|
|
280
|
+
...(source.retryAfterSeconds !== undefined
|
|
281
|
+
? { retryAfterSeconds: source.retryAfterSeconds }
|
|
282
|
+
: {}),
|
|
283
|
+
...(source.safeToReplay !== undefined ? { safeToReplay: source.safeToReplay } : {}),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Options for {@link facilitatorFetch}. */
|
|
288
|
+
export interface FacilitatorFetchOptions {
|
|
289
|
+
/** Abort the request after this many milliseconds. */
|
|
290
|
+
timeoutMs: number;
|
|
291
|
+
/** Extra attempts after the first. Default {@link DEFAULT_FACILITATOR_RETRIES}. */
|
|
292
|
+
retries?: number;
|
|
293
|
+
/**
|
|
294
|
+
* Whether this particular refusal may be replayed.
|
|
295
|
+
*
|
|
296
|
+
* Default: only when the facilitator proved it executed nothing
|
|
297
|
+
* (`info.safeToReplay`). Pass a stricter predicate on paths where even a
|
|
298
|
+
* proven-safe replay is unwanted.
|
|
299
|
+
*/
|
|
300
|
+
canReplay?: (info: FacilitatorErrorInfo) => boolean;
|
|
301
|
+
/** Injected for tests. */
|
|
302
|
+
fetchImpl?: typeof fetch;
|
|
303
|
+
/** Injected for tests, so a retry does not really sleep. */
|
|
304
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
308
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* POST/GET the facilitator, replaying a refusal that provably executed nothing.
|
|
312
|
+
*
|
|
313
|
+
* Returns the response plus, when it was not ok, the structured refusal — the
|
|
314
|
+
* body has already been consumed to build it, so callers must not read it
|
|
315
|
+
* again.
|
|
316
|
+
*
|
|
317
|
+
* Network errors and timeouts still THROW, exactly as before: callers already
|
|
318
|
+
* have `catch` blocks that turn them into `{ success: false }`, and an
|
|
319
|
+
* `AbortError` on the escrow paths triggers an on-chain reconciliation that
|
|
320
|
+
* must keep firing.
|
|
321
|
+
*/
|
|
322
|
+
export async function facilitatorFetch(
|
|
323
|
+
url: string,
|
|
324
|
+
init: RequestInit,
|
|
325
|
+
options: FacilitatorFetchOptions,
|
|
326
|
+
): Promise<{ response: Response; error?: FacilitatorErrorInfo }> {
|
|
327
|
+
const retries = options.retries ?? DEFAULT_FACILITATOR_RETRIES;
|
|
328
|
+
const canReplay = options.canReplay ?? ((info: FacilitatorErrorInfo) => info.safeToReplay);
|
|
329
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
330
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
331
|
+
|
|
332
|
+
let attempt = 0;
|
|
333
|
+
for (;;) {
|
|
334
|
+
// A fresh controller per attempt: an aborted signal stays aborted, so
|
|
335
|
+
// reusing one would make every retry fail instantly.
|
|
336
|
+
const controller = new AbortController();
|
|
337
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
338
|
+
let response: Response;
|
|
339
|
+
try {
|
|
340
|
+
response = await doFetch(url, { ...init, signal: controller.signal });
|
|
341
|
+
} finally {
|
|
342
|
+
clearTimeout(timeoutId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (response.ok) return { response };
|
|
346
|
+
|
|
347
|
+
const error = await readFacilitatorError(response);
|
|
348
|
+
if (attempt >= retries || !error.retryable || !canReplay(error)) {
|
|
349
|
+
return { response, error };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
attempt += 1;
|
|
353
|
+
await sleep((error.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS) * 1000);
|
|
354
|
+
}
|
|
355
|
+
}
|