@ixo/oracle-runtime-workers 0.29.1 → 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.
Files changed (34) hide show
  1. package/package.json +3 -3
  2. package/src/plugins/ixo-chain/balances.ts +141 -0
  3. package/src/plugins/ixo-chain/blocksync.ts +11 -6
  4. package/src/plugins/ixo-chain/denoms.ts +84 -36
  5. package/src/plugins/ixo-chain/http.ts +300 -63
  6. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
  7. package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
  8. package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
  9. package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
  10. package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
  11. package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
  12. package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
  13. package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
  14. package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
  15. package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
  16. package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
  17. package/src/plugins/ixo-chain/kinds/core.ts +246 -0
  18. package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
  19. package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
  20. package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
  21. package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
  22. package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
  23. package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
  24. package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
  25. package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
  26. package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
  27. package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
  28. package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
  29. package/src/plugins/ixo-chain/kinds/types.ts +156 -0
  30. package/src/plugins/ixo-chain/lookup.ts +470 -0
  31. package/src/plugins/ixo-chain/overview.ts +1060 -0
  32. package/src/plugins/ixo-chain/queries.ts +170 -138
  33. package/src/plugins/ixo-chain/rest.ts +129 -54
  34. 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 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,
@@ -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
- /** Longest `Retry-After` honoured. */
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
- /** A read, or its retry, is not started with less time than this left in the budget. */
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
- /** How long a 429 or 503 answers the isolate's other reads of that upstream without asking it. */
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
- /** A 429 or 503: the upstream is marked unhealthy for {@link UNHEALTHY_MS}. */
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 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.
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<string, { until: number; failure: Failure }>();
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.failure;
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; a 429 or 503 sets it. */
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` (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.
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 answered ${held.reason === 'rate_limited' ? '429' : '503'} moments ago`,
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
- ? Math.min(first.retryAfterMs, MAX_RETRY_DELAY_MS)
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 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`.
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: { timeoutMs: number; maxBytes: number; notFoundOn404?: boolean },
486
+ opts: ExchangeOptions,
280
487
  ): Promise<Attempt<string>> {
281
488
  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
- };
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
- await response.body?.cancel().catch(() => undefined);
304
- if (status === 429)
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: rateLimited(source),
545
+ failure:
546
+ status === 429
547
+ ? rateLimited(source)
548
+ : unavailable(source, `HTTP ${status}`, true),
308
549
  retry: true,
309
550
  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 } : {}),
551
+ status,
319
552
  note: `HTTP ${status}`,
320
- ...optionalRetryAfter(response.headers.get('retry-after')),
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
- ok: false,
353
- failure: timedOut(source),
354
- retry: false,
355
- note: `timeout after ${budget} ms`,
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
- }