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