@ixo/oracle-runtime-workers 0.29.0 → 0.30.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/package.json +2 -2
- package/src/plugins/hub-actions/hub-actions.plugin.ts +73 -24
- package/src/plugins/ixo-chain/balances.ts +141 -0
- package/src/plugins/ixo-chain/blocksync.ts +11 -6
- package/src/plugins/ixo-chain/denoms.ts +84 -36
- package/src/plugins/ixo-chain/http.ts +300 -63
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
- package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
- package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
- package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
- package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
- package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
- package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
- package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
- package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
- package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
- package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
- package/src/plugins/ixo-chain/kinds/core.ts +246 -0
- package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
- package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
- package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
- package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
- package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
- package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
- package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
- package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
- package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
- package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
- package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
- package/src/plugins/ixo-chain/kinds/types.ts +156 -0
- package/src/plugins/ixo-chain/lookup.ts +470 -0
- package/src/plugins/ixo-chain/overview.ts +1060 -0
- package/src/plugins/ixo-chain/queries.ts +170 -138
- package/src/plugins/ixo-chain/rest.ts +129 -54
- package/src/plugins/ixo-chain/summaries.ts +3 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The network edge shared by the chain REST and Blocksync reads: one bounded
|
|
3
|
-
* HTTP exchange per attempt
|
|
4
|
-
* failures become a {@link Failure}
|
|
5
|
-
* here throws for them. Only the
|
|
3
|
+
* HTTP exchange per attempt, the retry policy around it, and the per-user
|
|
4
|
+
* permits every attempt takes. Upstream failures become a {@link Failure}
|
|
5
|
+
* the tools hand to the model; nothing here throws for them. Only the
|
|
6
|
+
* turn's own abort is rethrown.
|
|
6
7
|
*/
|
|
7
8
|
import {
|
|
8
9
|
boundedBytes,
|
|
@@ -33,14 +34,29 @@ export interface Failure {
|
|
|
33
34
|
*/
|
|
34
35
|
export const RETRY_DELAY_MIN_MS = 1_000;
|
|
35
36
|
export const RETRY_DELAY_MAX_MS = 3_000;
|
|
36
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Longest `Retry-After` waited for before the one retry; an upstream that
|
|
39
|
+
* asks for a longer wait is not asked again by this call.
|
|
40
|
+
*/
|
|
37
41
|
export const MAX_RETRY_DELAY_MS = 5_000;
|
|
38
42
|
/** Whole-tool budget: every attempt and wait of one tool call fits inside it. */
|
|
39
43
|
export const TOOL_BUDGET_MS = 20_000;
|
|
40
|
-
/**
|
|
44
|
+
/** An attempt, the first one included, is not started with less time than this left in the budget. */
|
|
41
45
|
export const MIN_ATTEMPT_MS = 1_000;
|
|
42
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* How long a 429, 502, 503, 504 or network error answers the isolate's
|
|
48
|
+
* other reads of that upstream without asking it.
|
|
49
|
+
*/
|
|
43
50
|
export const UNHEALTHY_MS = 10_000;
|
|
51
|
+
/**
|
|
52
|
+
* Reads of one user in flight at once, across every ixo-chain tool call of
|
|
53
|
+
* that user in the isolate: chain REST GETs, denom lookups and Blocksync
|
|
54
|
+
* POSTs alike. Each attempt takes a permit and gives it back when its
|
|
55
|
+
* answer is read, so a retry's wait holds none.
|
|
56
|
+
*/
|
|
57
|
+
export const USER_READ_PERMITS = 5;
|
|
58
|
+
/** The most of an error answer's body read (for its gRPC status code). */
|
|
59
|
+
export const ERROR_BODY_MAX_BYTES = 8 * 1024;
|
|
44
60
|
|
|
45
61
|
const SOURCE_NAME: Record<Source, string> = {
|
|
46
62
|
chain: 'The chain node',
|
|
@@ -55,6 +71,28 @@ function rateLimited(source: Source): Failure {
|
|
|
55
71
|
};
|
|
56
72
|
}
|
|
57
73
|
|
|
74
|
+
/** The upstream asked for a wait longer than this call waits: say how long. */
|
|
75
|
+
function rateLimitedFor(source: Source, waitMs: number): Failure {
|
|
76
|
+
return {
|
|
77
|
+
reason: 'rate_limited',
|
|
78
|
+
detail: `${SOURCE_NAME[source]} is rate-limiting requests and asks to wait ${Math.ceil(waitMs / 1000)} s before asking again.`,
|
|
79
|
+
retryable: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The call's time budget ran out before a read could start or finish. */
|
|
84
|
+
export const BUDGET_SPENT: Failure = {
|
|
85
|
+
reason: 'timeout',
|
|
86
|
+
detail:
|
|
87
|
+
"The tool's time budget ran out before this read could finish; ask again, or ask for less in one call.",
|
|
88
|
+
retryable: true,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Whether the call has at least {@link MIN_ATTEMPT_MS} of its budget left. */
|
|
92
|
+
export function hasBudget(call: ChainCall): boolean {
|
|
93
|
+
return call.deadline - Date.now() >= MIN_ATTEMPT_MS;
|
|
94
|
+
}
|
|
95
|
+
|
|
58
96
|
function timedOut(source: Source): Failure {
|
|
59
97
|
return {
|
|
60
98
|
reason: 'timeout',
|
|
@@ -84,11 +122,16 @@ export function unavailable(
|
|
|
84
122
|
};
|
|
85
123
|
}
|
|
86
124
|
|
|
87
|
-
/** What one tool call's reads share: where they go, the budget and the turn's abort. */
|
|
125
|
+
/** What one tool call's reads share: where they go, whose they are, the budget and the turn's abort. */
|
|
88
126
|
export interface ChainCall {
|
|
89
127
|
network: 'mainnet' | 'testnet' | 'devnet';
|
|
90
128
|
restUrl: string;
|
|
91
129
|
blocksyncUrl: string;
|
|
130
|
+
/**
|
|
131
|
+
* The DID of the user the call reads for: every read of that user's
|
|
132
|
+
* calls shares the user's {@link USER_READ_PERMITS} permits.
|
|
133
|
+
*/
|
|
134
|
+
user: string;
|
|
92
135
|
/** Epoch ms by which every read of the call has finished. */
|
|
93
136
|
deadline: number;
|
|
94
137
|
signal: AbortSignal;
|
|
@@ -108,11 +151,19 @@ export type Attempt<T> =
|
|
|
108
151
|
ok: false;
|
|
109
152
|
failure: Failure;
|
|
110
153
|
retry: boolean;
|
|
154
|
+
/** The upstream's `Retry-After`, at most {@link MAX_RETRY_DELAY_MS}. */
|
|
111
155
|
retryAfterMs?: number;
|
|
112
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* A 429, 502, 503, 504 or network error: the upstream is marked
|
|
158
|
+
* unhealthy for {@link UNHEALTHY_MS}.
|
|
159
|
+
*/
|
|
113
160
|
unhealthy?: true;
|
|
114
161
|
/** An expected answer rather than a fault (a 404 the caller asked for): not logged. */
|
|
115
162
|
quiet?: true;
|
|
163
|
+
/** The HTTP status the upstream answered, when it answered one. */
|
|
164
|
+
status?: number;
|
|
165
|
+
/** The error answer's body, for a status the caller asked to read it on. */
|
|
166
|
+
errorBody?: string;
|
|
116
167
|
/** Log text: status or error class, never a request body or an address. */
|
|
117
168
|
note: string;
|
|
118
169
|
};
|
|
@@ -133,6 +184,26 @@ export function throwIfAborted(signal: AbortSignal): void {
|
|
|
133
184
|
if (signal.aborted) throw abortReason(signal);
|
|
134
185
|
}
|
|
135
186
|
|
|
187
|
+
/**
|
|
188
|
+
* `shared` (a read another call started) as this call waits for it: its
|
|
189
|
+
* answer, or this turn's own abort reason as soon as this turn is aborted.
|
|
190
|
+
* A joiner never throws another caller's abort for its own.
|
|
191
|
+
*/
|
|
192
|
+
export function untilAborted<T>(
|
|
193
|
+
shared: Promise<T>,
|
|
194
|
+
signal: AbortSignal,
|
|
195
|
+
): Promise<T> {
|
|
196
|
+
if (signal.aborted) return Promise.reject(abortReason(signal));
|
|
197
|
+
let onAbort: () => void = () => undefined;
|
|
198
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
199
|
+
onAbort = () => reject(abortReason(signal));
|
|
200
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
201
|
+
});
|
|
202
|
+
return Promise.race([shared, aborted]).finally(() =>
|
|
203
|
+
signal.removeEventListener('abort', onAbort),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
136
207
|
/** Resolves after `ms`; rejects with the turn's abort reason if it ends first. */
|
|
137
208
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
138
209
|
return new Promise((resolve, reject) => {
|
|
@@ -161,32 +232,38 @@ export function retryDelayMs(): number {
|
|
|
161
232
|
}
|
|
162
233
|
|
|
163
234
|
/**
|
|
164
|
-
* Upstreams that answered 429
|
|
165
|
-
* keyed by base URL, with the
|
|
166
|
-
*
|
|
235
|
+
* Upstreams that answered 429, 502, 503 or 504, or failed with a network
|
|
236
|
+
* error, within the last {@link UNHEALTHY_MS}, keyed by base URL, with the
|
|
237
|
+
* failure that marked them and its log note. Per isolate: every call of
|
|
238
|
+
* the isolate answers with that failure instead of asking the upstream
|
|
239
|
+
* again. A timeout of our own never marks an upstream.
|
|
167
240
|
*/
|
|
168
|
-
const unhealthy = new Map<
|
|
241
|
+
const unhealthy = new Map<
|
|
242
|
+
string,
|
|
243
|
+
{ until: number; failure: Failure; note: string }
|
|
244
|
+
>();
|
|
169
245
|
|
|
170
246
|
/** Tests only: forget every unhealthy mark. */
|
|
171
247
|
export function resetUpstreamHealth(): void {
|
|
172
248
|
unhealthy.clear();
|
|
173
249
|
}
|
|
174
250
|
|
|
175
|
-
function heldBack(upstream: string): Failure | null {
|
|
251
|
+
function heldBack(upstream: string): { failure: Failure; note: string } | null {
|
|
176
252
|
const mark = unhealthy.get(upstream);
|
|
177
253
|
if (!mark) return null;
|
|
178
|
-
if (mark.until > Date.now()) return mark
|
|
254
|
+
if (mark.until > Date.now()) return mark;
|
|
179
255
|
unhealthy.delete(upstream);
|
|
180
256
|
return null;
|
|
181
257
|
}
|
|
182
258
|
|
|
183
|
-
/** An answer clears the upstream's mark;
|
|
259
|
+
/** An answer clears the upstream's mark; an `unhealthy` failure sets it. */
|
|
184
260
|
function recordHealth(upstream: string, attempt: Attempt<unknown>): void {
|
|
185
261
|
if (attempt.ok) unhealthy.delete(upstream);
|
|
186
262
|
else if (attempt.unhealthy)
|
|
187
263
|
unhealthy.set(upstream, {
|
|
188
264
|
until: Date.now() + UNHEALTHY_MS,
|
|
189
265
|
failure: attempt.failure,
|
|
266
|
+
note: attempt.note,
|
|
190
267
|
});
|
|
191
268
|
}
|
|
192
269
|
|
|
@@ -198,11 +275,11 @@ export interface Read {
|
|
|
198
275
|
|
|
199
276
|
/**
|
|
200
277
|
* Runs `attempt`, and once more when it asks for a retry and the budget
|
|
201
|
-
* leaves room: after `Retry-After` (
|
|
202
|
-
*
|
|
203
|
-
* asked at all; the call's own
|
|
204
|
-
*
|
|
205
|
-
* (`quiet`) answer.
|
|
278
|
+
* leaves room: after `Retry-After` (at most 5 s: {@link exchange} asks no
|
|
279
|
+
* retry for a longer one) when the upstream sent one, else after a uniform
|
|
280
|
+
* 1–3 s. An upstream marked unhealthy is not asked at all; the call's own
|
|
281
|
+
* retry is. A transient answer is kept when its retry fails. Each failed
|
|
282
|
+
* attempt logs one line, except an expected (`quiet`) answer.
|
|
206
283
|
*/
|
|
207
284
|
export async function withRetry<T>(
|
|
208
285
|
call: ChainCall,
|
|
@@ -212,9 +289,9 @@ export async function withRetry<T>(
|
|
|
212
289
|
const held = heldBack(read.upstream);
|
|
213
290
|
if (held) {
|
|
214
291
|
console.warn(
|
|
215
|
-
`[ixo-chain] ${read.label} not attempted: the upstream
|
|
292
|
+
`[ixo-chain] ${read.label} not attempted: the upstream failed moments ago (${held.note})`,
|
|
216
293
|
);
|
|
217
|
-
return { ok: false, failure: held };
|
|
294
|
+
return { ok: false, failure: held.failure };
|
|
218
295
|
}
|
|
219
296
|
const first = await attempt();
|
|
220
297
|
recordHealth(read.upstream, first);
|
|
@@ -223,7 +300,7 @@ export async function withRetry<T>(
|
|
|
223
300
|
console.warn(`[ixo-chain] ${read.label} attempt 1 failed: ${first.note}`);
|
|
224
301
|
const wait =
|
|
225
302
|
!first.ok && first.retryAfterMs !== undefined
|
|
226
|
-
?
|
|
303
|
+
? first.retryAfterMs
|
|
227
304
|
: retryDelayMs();
|
|
228
305
|
const giveUp = (): Outcome<T> =>
|
|
229
306
|
first.ok
|
|
@@ -262,31 +339,174 @@ export function retryAfterMs(header: string | null): number | undefined {
|
|
|
262
339
|
|
|
263
340
|
const REDIRECTED = 'ixo-chain-redirect';
|
|
264
341
|
|
|
342
|
+
/* ── per-user permits ─────────────────────────────────────────────────── */
|
|
343
|
+
|
|
344
|
+
/** One user's permits: how many are taken, and the reads waiting for one, oldest first. */
|
|
345
|
+
interface Permits {
|
|
346
|
+
taken: number;
|
|
347
|
+
waiting: Set<() => void>;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Per isolate, the permits of each user with a read in flight or waiting;
|
|
352
|
+
* a user's entry is dropped as soon as none is.
|
|
353
|
+
*/
|
|
354
|
+
const permitsByUser = new Map<string, Permits>();
|
|
355
|
+
|
|
356
|
+
/** Tests only: how many users have a read in flight or waiting for a permit. */
|
|
357
|
+
export function usersReading(): number {
|
|
358
|
+
return permitsByUser.size;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Gives a permit back: to the oldest waiting read, else to the user's pool. */
|
|
362
|
+
function releaserOf(user: string, permits: Permits): () => void {
|
|
363
|
+
let released = false;
|
|
364
|
+
return () => {
|
|
365
|
+
if (released) return;
|
|
366
|
+
released = true;
|
|
367
|
+
const next = permits.waiting.values().next();
|
|
368
|
+
if (!next.done) {
|
|
369
|
+
next.value();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
permits.taken -= 1;
|
|
373
|
+
if (permits.taken === 0) permitsByUser.delete(user);
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
265
377
|
/**
|
|
266
|
-
* One
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
378
|
+
* One of the call's user's {@link USER_READ_PERMITS} permits, as the
|
|
379
|
+
* function that gives it back; null when none frees up while the call
|
|
380
|
+
* still has {@link MIN_ATTEMPT_MS} of its budget left. Rejects with the
|
|
381
|
+
* turn's abort reason when the turn ends while it waits.
|
|
382
|
+
*/
|
|
383
|
+
function takePermit(call: ChainCall): Promise<(() => void) | null> {
|
|
384
|
+
const permits = permitsByUser.get(call.user) ?? {
|
|
385
|
+
taken: 0,
|
|
386
|
+
waiting: new Set<() => void>(),
|
|
387
|
+
};
|
|
388
|
+
permitsByUser.set(call.user, permits);
|
|
389
|
+
if (permits.taken < USER_READ_PERMITS) {
|
|
390
|
+
permits.taken += 1;
|
|
391
|
+
return Promise.resolve(releaserOf(call.user, permits));
|
|
392
|
+
}
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
const leave = () => {
|
|
395
|
+
permits.waiting.delete(handOver);
|
|
396
|
+
clearTimeout(timer);
|
|
397
|
+
call.signal.removeEventListener('abort', onAbort);
|
|
398
|
+
};
|
|
399
|
+
// A released permit passes straight to this read: `taken` is unchanged.
|
|
400
|
+
const handOver = () => {
|
|
401
|
+
leave();
|
|
402
|
+
resolve(releaserOf(call.user, permits));
|
|
403
|
+
};
|
|
404
|
+
const onAbort = () => {
|
|
405
|
+
leave();
|
|
406
|
+
reject(abortReason(call.signal));
|
|
407
|
+
};
|
|
408
|
+
const timer = setTimeout(
|
|
409
|
+
() => {
|
|
410
|
+
leave();
|
|
411
|
+
resolve(null);
|
|
412
|
+
},
|
|
413
|
+
Math.max(0, call.deadline - MIN_ATTEMPT_MS - Date.now()),
|
|
414
|
+
);
|
|
415
|
+
permits.waiting.add(handOver);
|
|
416
|
+
call.signal.addEventListener('abort', onAbort, { once: true });
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* ── one exchange ─────────────────────────────────────────────────────── */
|
|
421
|
+
|
|
422
|
+
/** An attempt the call's budget left no time for. */
|
|
423
|
+
const outOfBudget = (note: string): Attempt<never> => ({
|
|
424
|
+
ok: false,
|
|
425
|
+
failure: BUDGET_SPENT,
|
|
426
|
+
retry: false,
|
|
427
|
+
note,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The body of an error answer as text, or undefined when it is longer than
|
|
432
|
+
* {@link ERROR_BODY_MAX_BYTES} (the rest is not read).
|
|
433
|
+
*/
|
|
434
|
+
async function errorBodyOf(response: Response): Promise<string | undefined> {
|
|
435
|
+
const reader = response.body?.getReader();
|
|
436
|
+
if (!reader) return '';
|
|
437
|
+
const decoder = new TextDecoder();
|
|
438
|
+
let text = '';
|
|
439
|
+
let size = 0;
|
|
440
|
+
try {
|
|
441
|
+
while (true) {
|
|
442
|
+
const { value, done } = await reader.read();
|
|
443
|
+
if (done) return text + decoder.decode();
|
|
444
|
+
size += value.length;
|
|
445
|
+
if (size > ERROR_BODY_MAX_BYTES) return undefined;
|
|
446
|
+
text += decoder.decode(value, { stream: true });
|
|
447
|
+
}
|
|
448
|
+
} finally {
|
|
449
|
+
await reader.cancel().catch(() => undefined);
|
|
450
|
+
reader.releaseLock();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** What bounds one exchange, and which error bodies it reads. */
|
|
455
|
+
interface ExchangeOptions {
|
|
456
|
+
timeoutMs: number;
|
|
457
|
+
maxBytes: number;
|
|
458
|
+
notFoundOn404?: boolean;
|
|
459
|
+
/** Statuses whose error body is read (at most {@link ERROR_BODY_MAX_BYTES}) and returned. */
|
|
460
|
+
errorBodyOn?: readonly number[];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* One bounded HTTP exchange, under one of the user's permits: it waits
|
|
465
|
+
* for the permit first, and its timer starts once it holds one. At most
|
|
466
|
+
* `timeoutMs` (and never past the call's deadline) including the body
|
|
467
|
+
* read, no redirects followed, the body cut off at `maxBytes`; the answer
|
|
468
|
+
* is the body text. No exchange starts with less than
|
|
469
|
+
* {@link MIN_ATTEMPT_MS} of the budget left: that, like an exchange the
|
|
470
|
+
* deadline cut short, is {@link BUDGET_SPENT}.
|
|
471
|
+
*
|
|
472
|
+
* Only a 2xx body is read, and an error body the caller names in
|
|
473
|
+
* `errorBodyOn`. 429/502/503/504 and network errors mark the upstream
|
|
474
|
+
* unhealthy and ask for a retry, unless the upstream asks to wait longer
|
|
475
|
+
* than {@link MAX_RETRY_DELAY_MS} (`rate_limited`, the wait in the
|
|
476
|
+
* detail); anything else is a failure the retry cannot fix. A 404 is
|
|
477
|
+
* `unavailable` (a wrong route or gateway) unless the caller reads a
|
|
478
|
+
* resource that may be absent (`notFoundOn404`): then it is a quiet
|
|
479
|
+
* `not_found`.
|
|
273
480
|
*/
|
|
274
481
|
export async function exchange(
|
|
275
482
|
call: ChainCall,
|
|
276
483
|
source: Source,
|
|
277
484
|
url: string,
|
|
278
485
|
init: RequestInit,
|
|
279
|
-
opts:
|
|
486
|
+
opts: ExchangeOptions,
|
|
280
487
|
): Promise<Attempt<string>> {
|
|
281
488
|
throwIfAborted(call.signal);
|
|
282
|
-
const
|
|
283
|
-
if (budget
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
489
|
+
const release = await takePermit(call);
|
|
490
|
+
if (!release) return outOfBudget('no permit before the budget ran out');
|
|
491
|
+
try {
|
|
492
|
+
return await exchangeHolding(call, source, url, init, opts);
|
|
493
|
+
} finally {
|
|
494
|
+
release();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** {@link exchange} once its permit is held. */
|
|
499
|
+
async function exchangeHolding(
|
|
500
|
+
call: ChainCall,
|
|
501
|
+
source: Source,
|
|
502
|
+
url: string,
|
|
503
|
+
init: RequestInit,
|
|
504
|
+
opts: ExchangeOptions,
|
|
505
|
+
): Promise<Attempt<string>> {
|
|
506
|
+
throwIfAborted(call.signal);
|
|
507
|
+
const left = call.deadline - Date.now();
|
|
508
|
+
if (left < MIN_ATTEMPT_MS) return outOfBudget('no budget left');
|
|
509
|
+
const budget = Math.min(opts.timeoutMs, left);
|
|
290
510
|
const controller = new AbortController();
|
|
291
511
|
const timer = setTimeout(() => controller.abort(), budget);
|
|
292
512
|
const onAbort = () => controller.abort();
|
|
@@ -300,25 +520,39 @@ export async function exchange(
|
|
|
300
520
|
);
|
|
301
521
|
const { status } = response;
|
|
302
522
|
if (!response.ok) {
|
|
303
|
-
|
|
304
|
-
if (status
|
|
523
|
+
let errorBody: string | undefined;
|
|
524
|
+
if (opts.errorBodyOn?.includes(status))
|
|
525
|
+
errorBody = await errorBodyOf(response);
|
|
526
|
+
else await response.body?.cancel().catch(() => undefined);
|
|
527
|
+
if (
|
|
528
|
+
status === 429 ||
|
|
529
|
+
status === 502 ||
|
|
530
|
+
status === 503 ||
|
|
531
|
+
status === 504
|
|
532
|
+
) {
|
|
533
|
+
const wait = retryAfterMs(response.headers.get('retry-after'));
|
|
534
|
+
if (wait !== undefined && wait > MAX_RETRY_DELAY_MS)
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
failure: rateLimitedFor(source, wait),
|
|
538
|
+
retry: false,
|
|
539
|
+
unhealthy: true,
|
|
540
|
+
status,
|
|
541
|
+
note: `HTTP ${status}, Retry-After ${Math.ceil(wait / 1000)} s`,
|
|
542
|
+
};
|
|
305
543
|
return {
|
|
306
544
|
ok: false,
|
|
307
|
-
failure:
|
|
545
|
+
failure:
|
|
546
|
+
status === 429
|
|
547
|
+
? rateLimited(source)
|
|
548
|
+
: unavailable(source, `HTTP ${status}`, true),
|
|
308
549
|
retry: true,
|
|
309
550
|
unhealthy: true,
|
|
310
|
-
|
|
311
|
-
...optionalRetryAfter(response.headers.get('retry-after')),
|
|
312
|
-
};
|
|
313
|
-
if (status === 502 || status === 503 || status === 504)
|
|
314
|
-
return {
|
|
315
|
-
ok: false,
|
|
316
|
-
failure: unavailable(source, `HTTP ${status}`, true),
|
|
317
|
-
retry: true,
|
|
318
|
-
...(status === 503 ? { unhealthy: true } : {}),
|
|
551
|
+
status,
|
|
319
552
|
note: `HTTP ${status}`,
|
|
320
|
-
...
|
|
553
|
+
...(wait !== undefined ? { retryAfterMs: wait } : {}),
|
|
321
554
|
};
|
|
555
|
+
}
|
|
322
556
|
if (status === 404 && opts.notFoundOn404)
|
|
323
557
|
return {
|
|
324
558
|
ok: false,
|
|
@@ -329,6 +563,7 @@ export async function exchange(
|
|
|
329
563
|
},
|
|
330
564
|
retry: false,
|
|
331
565
|
quiet: true,
|
|
566
|
+
status,
|
|
332
567
|
note: 'HTTP 404',
|
|
333
568
|
};
|
|
334
569
|
// 501 says the route is not implemented: asking again cannot help.
|
|
@@ -340,6 +575,8 @@ export async function exchange(
|
|
|
340
575
|
status >= 500 && status !== 501,
|
|
341
576
|
),
|
|
342
577
|
retry: false,
|
|
578
|
+
status,
|
|
579
|
+
...(errorBody !== undefined ? { errorBody } : {}),
|
|
343
580
|
note: `HTTP ${status}`,
|
|
344
581
|
};
|
|
345
582
|
}
|
|
@@ -347,13 +584,17 @@ export async function exchange(
|
|
|
347
584
|
return { ok: true, value: new TextDecoder().decode(bytes) };
|
|
348
585
|
} catch (error) {
|
|
349
586
|
throwIfAborted(call.signal);
|
|
587
|
+
// Our own timer: the attempt's bound, or the call's deadline when that
|
|
588
|
+
// came first. Neither says the upstream is unhealthy.
|
|
350
589
|
if (controller.signal.aborted)
|
|
351
|
-
return
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
590
|
+
return budget < opts.timeoutMs
|
|
591
|
+
? outOfBudget(`deadline reached after ${budget} ms`)
|
|
592
|
+
: {
|
|
593
|
+
ok: false,
|
|
594
|
+
failure: timedOut(source),
|
|
595
|
+
retry: false,
|
|
596
|
+
note: `timeout after ${budget} ms`,
|
|
597
|
+
};
|
|
357
598
|
if (error instanceof Error && error.message === REDIRECTED)
|
|
358
599
|
return {
|
|
359
600
|
ok: false,
|
|
@@ -374,6 +615,7 @@ export async function exchange(
|
|
|
374
615
|
ok: false,
|
|
375
616
|
failure: unavailable(source, 'network error', true),
|
|
376
617
|
retry: true,
|
|
618
|
+
unhealthy: true,
|
|
377
619
|
note: `network error (${error instanceof Error ? error.name : 'unknown'})`,
|
|
378
620
|
};
|
|
379
621
|
} finally {
|
|
@@ -381,8 +623,3 @@ export async function exchange(
|
|
|
381
623
|
call.signal.removeEventListener('abort', onAbort);
|
|
382
624
|
}
|
|
383
625
|
}
|
|
384
|
-
|
|
385
|
-
function optionalRetryAfter(header: string | null): { retryAfterMs?: number } {
|
|
386
|
-
const ms = retryAfterMs(header);
|
|
387
|
-
return ms === undefined ? {} : { retryAfterMs: ms };
|
|
388
|
-
}
|