@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.
Files changed (74) hide show
  1. package/package.json +7 -5
  2. package/src/attachments/channel-links.ts +135 -0
  3. package/src/attachments/channel.ts +203 -0
  4. package/src/attachments/download.ts +23 -6
  5. package/src/attachments/index.ts +23 -0
  6. package/src/attachments/pipeline.ts +120 -42
  7. package/src/attachments/retention.ts +11 -3
  8. package/src/attachments/sanitize.ts +20 -8
  9. package/src/attachments/types.ts +7 -0
  10. package/src/attachments/view.ts +24 -7
  11. package/src/channels/contract.ts +69 -0
  12. package/src/channels/turns.ts +23 -1
  13. package/src/core/main-agent-types.ts +2 -0
  14. package/src/core/runtime-context.ts +3 -0
  15. package/src/delivery/plan.ts +3 -1
  16. package/src/do/contracts.ts +3 -0
  17. package/src/do/turn-attachments.ts +103 -0
  18. package/src/do/user-oracle-do.ts +142 -42
  19. package/src/hub-actions/client.ts +87 -7
  20. package/src/hub-actions/hub-text.ts +82 -0
  21. package/src/hub-actions/request.ts +7 -4
  22. package/src/hub-actions/store.ts +5 -1
  23. package/src/hub-actions/transfer.ts +23 -6
  24. package/src/index.ts +1 -0
  25. package/src/plugin-api/index.ts +1 -0
  26. package/src/plugin-api/types.ts +43 -2
  27. package/src/plugins/attachments/view-attachment-tool.ts +7 -3
  28. package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
  29. package/src/plugins/ixo-chain/balances.ts +141 -0
  30. package/src/plugins/ixo-chain/blocksync.ts +17 -8
  31. package/src/plugins/ixo-chain/denoms.ts +84 -36
  32. package/src/plugins/ixo-chain/http.ts +315 -70
  33. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
  34. package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
  35. package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
  36. package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
  37. package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
  38. package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
  39. package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
  40. package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
  41. package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
  42. package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
  43. package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
  44. package/src/plugins/ixo-chain/kinds/core.ts +246 -0
  45. package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
  46. package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
  47. package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
  48. package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
  49. package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
  50. package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
  51. package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
  52. package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
  53. package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
  54. package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
  55. package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
  56. package/src/plugins/ixo-chain/kinds/types.ts +156 -0
  57. package/src/plugins/ixo-chain/lookup.ts +470 -0
  58. package/src/plugins/ixo-chain/overview.ts +1060 -0
  59. package/src/plugins/ixo-chain/queries.ts +170 -138
  60. package/src/plugins/ixo-chain/rest.ts +135 -54
  61. package/src/plugins/ixo-chain/summaries.ts +3 -2
  62. package/src/plugins/yoma/catalog.ts +780 -0
  63. package/src/plugins/yoma/clef.ts +98 -0
  64. package/src/plugins/yoma/drafts.ts +339 -0
  65. package/src/plugins/yoma/engine.ts +926 -0
  66. package/src/plugins/yoma/index.ts +13 -0
  67. package/src/plugins/yoma/judge.ts +358 -0
  68. package/src/plugins/yoma/permission.ts +62 -0
  69. package/src/plugins/yoma/reads.ts +195 -0
  70. package/src/plugins/yoma/submission.ts +534 -0
  71. package/src/plugins/yoma/text.ts +16 -0
  72. package/src/plugins/yoma/work.ts +190 -0
  73. package/src/plugins/yoma/yoma.plugin.ts +2162 -0
  74. 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 and the retry policy around it. Upstream
4
- * failures become a {@link Failure} the tools hand to the model; nothing
5
- * here throws for them. Only the turn's own abort is rethrown.
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
- export type Source = 'chain' | 'blocksync';
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
- /** Longest `Retry-After` honoured. */
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
- /** A read, or its retry, is not started with less time than this left in the budget. */
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
- /** How long a 429 or 503 answers the isolate's other reads of that upstream without asking it. */
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
- : 'The chain node did not answer in time; try again shortly.',
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
- /** A 429 or 503: the upstream is marked unhealthy for {@link UNHEALTHY_MS}. */
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 or 503 within the last {@link UNHEALTHY_MS},
165
- * keyed by base URL, with the failure that marked them. Per isolate: every
166
- * call of the isolate answers with it instead of asking the upstream again.
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<string, { until: number; failure: Failure }>();
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.failure;
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; a 429 or 503 sets it. */
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` (capped at 5 s) when the upstream sent
202
- * one, else after a uniform 1–3 s. An upstream marked unhealthy is not
203
- * asked at all; the call's own retry is. A transient answer is kept when
204
- * its retry fails. Each failed attempt logs one line, except an expected
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
- `[ixo-chain] ${read.label} not attempted: the upstream answered ${held.reason === 'rate_limited' ? '429' : '503'} moments ago`,
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(`[ixo-chain] ${read.label} attempt 1 failed: ${first.note}`);
310
+ console.warn(`${tag} ${read.label} attempt 1 failed: ${first.note}`);
224
311
  const wait =
225
312
  !first.ok && first.retryAfterMs !== undefined
226
- ? Math.min(first.retryAfterMs, MAX_RETRY_DELAY_MS)
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(`[ixo-chain] ${read.label} attempt 2 failed: ${second.note}`);
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 bounded HTTP exchange: at most `timeoutMs` (and never past the call's
267
- * deadline) including the body read, no redirects followed, the body cut
268
- * off at `maxBytes`; the answer is the body text. Only a 2xx body is read.
269
- * 429/502/503/504 and network errors ask for a retry; anything else is a
270
- * failure the retry cannot fix. A 404 is `unavailable` (a wrong route or
271
- * gateway) unless the caller reads a resource that may be absent
272
- * (`notFoundOn404`): then it is a quiet `not_found`.
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: { timeoutMs: number; maxBytes: number; notFoundOn404?: boolean },
494
+ opts: ExchangeOptions,
280
495
  ): Promise<Attempt<string>> {
281
496
  throwIfAborted(call.signal);
282
- const budget = Math.min(opts.timeoutMs, call.deadline - Date.now());
283
- if (budget <= 0)
284
- return {
285
- ok: false,
286
- failure: timedOut(source),
287
- retry: false,
288
- note: 'no budget left',
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
- await response.body?.cancel().catch(() => undefined);
304
- if (status === 429)
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: rateLimited(source),
553
+ failure:
554
+ status === 429
555
+ ? rateLimited(source)
556
+ : unavailable(source, `HTTP ${status}`, true),
308
557
  retry: true,
309
558
  unhealthy: true,
310
- note: 'HTTP 429',
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
- ...optionalRetryAfter(response.headers.get('retry-after')),
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
- ok: false,
353
- failure: timedOut(source),
354
- retry: false,
355
- note: `timeout after ${budget} ms`,
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
- }