@oxy.so/core 1.6.1 → 1.7.1

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 (39) hide show
  1. package/LICENSE +675 -201
  2. package/NOTICE +7 -2
  3. package/dist/cjs/.tsbuildinfo +1 -1
  4. package/dist/cjs/HttpService.js +146 -23
  5. package/dist/cjs/crypto/registrationPow.js +53 -0
  6. package/dist/cjs/mixins/OxyServices.auth.js +6 -0
  7. package/dist/cjs/mixins/OxyServices.user.js +8 -1
  8. package/dist/cjs/utils/asyncUtils.js +50 -9
  9. package/dist/cjs/utils/errorUtils.js +53 -3
  10. package/dist/cjs/utils/requestUtils.js +96 -14
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/HttpService.js +147 -24
  13. package/dist/esm/crypto/registrationPow.js +50 -0
  14. package/dist/esm/mixins/OxyServices.auth.js +8 -2
  15. package/dist/esm/mixins/OxyServices.identityBackup.js +1 -1
  16. package/dist/esm/mixins/OxyServices.user.js +8 -1
  17. package/dist/esm/utils/asyncUtils.js +50 -9
  18. package/dist/esm/utils/errorUtils.js +51 -3
  19. package/dist/esm/utils/requestUtils.js +96 -14
  20. package/dist/types/.tsbuildinfo +1 -1
  21. package/dist/types/HttpService.d.ts +19 -0
  22. package/dist/types/crypto/registrationPow.d.ts +22 -0
  23. package/dist/types/mixins/OxyServices.user.d.ts +10 -2
  24. package/dist/types/utils/asyncUtils.d.ts +30 -1
  25. package/dist/types/utils/errorUtils.d.ts +36 -0
  26. package/dist/types/utils/requestUtils.d.ts +2 -2
  27. package/package.json +2 -2
  28. package/src/HttpService.ts +181 -31
  29. package/src/__tests__/fixEsmImports.test.ts +132 -0
  30. package/src/__tests__/httpServiceAbort.test.ts +308 -0
  31. package/src/crypto/__tests__/registrationPow.test.ts +61 -0
  32. package/src/crypto/registrationPow.ts +60 -0
  33. package/src/mixins/OxyServices.auth.ts +7 -0
  34. package/src/mixins/OxyServices.user.ts +12 -1
  35. package/src/utils/__tests__/asyncUtils.test.ts +93 -1
  36. package/src/utils/__tests__/requestUtilsAbort.test.ts +155 -0
  37. package/src/utils/asyncUtils.ts +97 -9
  38. package/src/utils/errorUtils.ts +78 -3
  39. package/src/utils/requestUtils.ts +104 -13
@@ -15,7 +15,7 @@
15
15
  import { TTLCache, registerCacheForCleanup } from './utils/cache.js';
16
16
  import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils.js';
17
17
  import { retryAsync } from './utils/asyncUtils.js';
18
- import { handleHttpError, parseHttpErrorBody } from './utils/errorUtils.js';
18
+ import { createCancelledError, ErrorCodes, handleHttpError, parseHttpErrorBody } from './utils/errorUtils.js';
19
19
  import { jwtDecode } from 'jwt-decode';
20
20
  import { isNative, getPlatformOS } from './utils/platform.js';
21
21
  import { isReactNative } from '@oxy.so/protocol';
@@ -170,6 +170,41 @@ class TokenStore {
170
170
  this.csrfTokenFetchPromise = null;
171
171
  }
172
172
  }
173
+ /**
174
+ * Propagate `parent`'s abort to `child`, returning a disposer.
175
+ *
176
+ * Three things a bare `parent.addEventListener('abort', ...)` gets wrong, all
177
+ * of which this exists to stop repeating:
178
+ *
179
+ * 1. **An already-aborted parent never fires again.** `abort` is a once-only
180
+ * event, so linking to a signal that has already fired silently links to
181
+ * nothing — which is how a cancelled request got re-issued on every retry.
182
+ * The `aborted` pre-check is the whole point.
183
+ * 2. **The listener outlives the request.** A long-lived caller signal (one
184
+ * React Query owns for a query's lifetime, say) accumulates one listener per
185
+ * attempt per request with nothing ever removing them.
186
+ * 3. **The reason is dropped**, so the child cannot tell WHY it was aborted.
187
+ *
188
+ * `AbortSignal.any` does all this natively and is not used: Hermes support is
189
+ * not safe to assume, and this SDK ships to React Native. The pattern is the
190
+ * one `uploadViaXHR` already had right; this is that code, shared.
191
+ */
192
+ function isAbortLike(error) {
193
+ return (typeof error === 'object' &&
194
+ error !== null &&
195
+ error.name === 'AbortError');
196
+ }
197
+ function linkAbort(parent, child) {
198
+ if (parent.aborted) {
199
+ child.abort(parent.reason);
200
+ return () => { };
201
+ }
202
+ const onAbort = () => {
203
+ child.abort(parent.reason);
204
+ };
205
+ parent.addEventListener('abort', onAbort, { once: true });
206
+ return () => parent.removeEventListener('abort', onAbort);
207
+ }
173
208
  /**
174
209
  * Unified HTTP Service
175
210
  *
@@ -365,21 +400,59 @@ export class HttpService {
365
400
  const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
366
401
  const edgeRegionHeader = await getBrowserEdgeRegionHeader();
367
402
  const activityIdHeader = getBrowserActivityIdHeader();
403
+ // A request the caller has ALREADY abandoned takes no queue slot and makes
404
+ // no network call. Checked here, before enqueue, because a slot is the
405
+ // scarce resource: ten of them, and a dead request holding one is a live
406
+ // request waiting for nothing.
407
+ if (signal?.aborted) {
408
+ throw createCancelledError('Request cancelled before it was sent');
409
+ }
410
+ // ONE abort domain for the whole call, linked to the caller's signal ONCE.
411
+ //
412
+ // The bug this replaces: the controller used to be built INSIDE `requestFn`
413
+ // — the function `retryAsync` re-invokes — and the caller's signal was
414
+ // linked to each new controller with a bare `addEventListener`. An `abort`
415
+ // event fires once, so on attempt 2 the caller's already-fired signal could
416
+ // not abort the freshly built controller, and the request the caller had
417
+ // cancelled went out for real. Hoisting the linkage means a cancellation
418
+ // arrives once and is then true for every attempt, including ones not yet
419
+ // started.
420
+ const callController = new AbortController();
421
+ const disposeCallerLink = signal ? linkAbort(signal, callController) : undefined;
422
+ /**
423
+ * Which of our own timers fired, so a timeout can be told apart from a
424
+ * cancellation. Both reach us from `fetch` as an indistinguishable
425
+ * `AbortError`, and collapsing them (as this used to) is what let the retry
426
+ * predicate treat an abandoned request as a transient failure.
427
+ */
428
+ let timedOut = false;
368
429
  // Request function
369
430
  const requestFn = async () => {
370
431
  const startTime = Date.now();
432
+ // Re-checked per attempt so no retry can issue a network call after the
433
+ // caller gave up — belt to `retryAsync`'s braces, and the one that holds
434
+ // if a custom `shouldRetry` ever says yes to a cancellation.
435
+ if (callController.signal.aborted) {
436
+ throw createCancelledError('Request cancelled');
437
+ }
438
+ // This attempt's controller carries its own timeout and inherits the
439
+ // call-wide abort domain. Declared OUTSIDE the `try` so the `finally`
440
+ // that releases them can see them — the timer and the listener are
441
+ // exactly what leaked when they lived inside it.
442
+ const controller = new AbortController();
443
+ const disposeCallLink = linkAbort(callController.signal, controller);
444
+ const timeoutId = timeout
445
+ ? setTimeout(() => {
446
+ timedOut = true;
447
+ controller.abort();
448
+ }, timeout)
449
+ : null;
371
450
  try {
372
451
  // Build URL with params
373
452
  const fullUrl = this.buildURL(url, params);
374
453
  // Determine if data is FormData using robust detection
375
454
  const isFormData = this.isFormData(data);
376
455
  const isUrlEncoded = this.isUrlSearchParams(data);
377
- // Make fetch request
378
- const controller = new AbortController();
379
- const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
380
- if (signal) {
381
- signal.addEventListener('abort', () => controller.abort());
382
- }
383
456
  // Build headers - start with defaults
384
457
  const headers = {
385
458
  'Accept': 'application/json',
@@ -457,6 +530,8 @@ export class HttpService {
457
530
  signal: controller.signal,
458
531
  credentials: this.getCredentialsMode(fullUrl),
459
532
  });
533
+ // Cleared as early as possible; the `finally` below is what GUARANTEES
534
+ // it, including on every throw between here and there.
460
535
  if (timeoutId)
461
536
  clearTimeout(timeoutId);
462
537
  // Handle response
@@ -582,34 +657,82 @@ export class HttpService {
582
657
  this.updateMetrics(false, duration);
583
658
  this.config.onRequestEnd?.(url, method, duration, false);
584
659
  this.config.onRequestError?.(url, method, error instanceof Error ? error : new Error(String(error)));
585
- // Handle AbortError specifically for better error messages
586
- if (error instanceof Error && error.name === 'AbortError') {
587
- throw handleHttpError(error);
660
+ // An abort is the one failure whose CAUSE the error cannot carry on its
661
+ // own: `fetch` reports a caller cancellation and our own timeout as the
662
+ // same `AbortError`. `timedOut` is set by the timer callback, so it is
663
+ // the only thing here that knows which happened — and getting this
664
+ // wrong is not cosmetic. Both used to collapse to `TIMEOUT` with
665
+ // `status: 0`, and because `status: 0` is not 4xx the retry predicate
666
+ // read every cancellation as a transient failure worth another try.
667
+ // Tested by NAME, not `instanceof Error`. `fetch` rejects an abort with a
668
+ // `DOMException`, and `instanceof` is realm-bound: the exception can be
669
+ // constructed in a different realm from the `Error` this module closes
670
+ // over (a Jest VM context, a worker, an iframe), in which case the check
671
+ // silently fails and the abort is misreported as a generic 500. That was
672
+ // happening; a structural check cannot be fooled that way.
673
+ if (isAbortLike(error)) {
674
+ if (timedOut) {
675
+ const timeoutError = new Error(`Request timed out after ${timeout}ms`);
676
+ timeoutError.status = 0;
677
+ timeoutError.code = ErrorCodes.TIMEOUT;
678
+ timeoutError.timeout = true;
679
+ throw timeoutError;
680
+ }
681
+ throw createCancelledError('Request cancelled');
588
682
  }
589
683
  throw handleHttpError(error);
590
684
  }
685
+ finally {
686
+ // Both of these leaked before: `clearTimeout` sat on the success path
687
+ // only, so any throw between the fetch and the return (a 404, a parse
688
+ // failure) left the timer armed; and nothing ever removed the abort
689
+ // listener, so a long-lived caller signal collected one per attempt.
690
+ if (timeoutId)
691
+ clearTimeout(timeoutId);
692
+ disposeCallLink();
693
+ }
591
694
  };
592
- // Wrap with retry if enabled
695
+ // Wrap with retry if enabled.
696
+ //
697
+ // `retryOnTimeout` defaults to false in `retryAsync`, which is what makes
698
+ // the pathological total unreachable: a timeout now costs ONE attempt
699
+ // instead of four-plus-backoff (~28s at the 5s default), and a cancellation
700
+ // costs none. `deadline` bounds whatever retries do happen by wall clock
701
+ // rather than by attempts x timeout + backoff.
593
702
  const requestWithRetry = retry
594
- ? () => retryAsync(requestFn, maxRetries, this.config.retryDelay || 1000)
703
+ ? () => retryAsync(requestFn, {
704
+ maxRetries,
705
+ baseDelay: this.config.retryDelay || 1000,
706
+ retryOnTimeout: config.retryOnTimeout ?? false,
707
+ deadline: config.deadline !== undefined ? Date.now() + config.deadline : undefined,
708
+ })
595
709
  : requestFn;
596
710
  // Wrap with deduplication if enabled (use optimized key generation)
597
711
  const dedupeKey = deduplicate ? this.generateCacheKey(method, url, data || params) : null;
598
712
  const finalRequest = dedupeKey
599
- ? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry)
713
+ ? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry, callController.signal)
600
714
  : requestWithRetry;
601
- // Execute the request. Control-plane calls the auth lane depends on
602
- // (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
603
- // could never acquire a slot when every slot is parked awaiting it.
604
- const result = config.bypassQueue
605
- ? await finalRequest()
606
- : await this.requestQueue.enqueue(finalRequest);
607
- // Cache the result if caching is enabled
608
- if (cache && cacheKey && result) {
609
- this.cache.set(cacheKey, result, cacheTTL);
610
- this.warnIfCacheOversized();
715
+ try {
716
+ // Execute the request. Control-plane calls the auth lane depends on
717
+ // (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
718
+ // could never acquire a slot when every slot is parked awaiting it.
719
+ const result = config.bypassQueue
720
+ ? await finalRequest()
721
+ : await this.requestQueue.enqueue(finalRequest, callController.signal);
722
+ // Cache the result if caching is enabled
723
+ if (cache && cacheKey && result) {
724
+ this.cache.set(cacheKey, result, cacheTTL);
725
+ this.warnIfCacheOversized();
726
+ }
727
+ return result;
728
+ }
729
+ finally {
730
+ // The call-wide link to the CALLER's signal, released once the call is
731
+ // over however it ended. Without this a caller signal that outlives the
732
+ // request — the normal case for a React Query query signal — accrues one
733
+ // listener per request for its whole lifetime.
734
+ disposeCallerLink?.();
611
735
  }
612
- return result;
613
736
  }
614
737
  /**
615
738
  * Soft cache-size guard. Emits a single throttled telemetry warning when the
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Registration proof-of-work — the CLIENT half of the anti-automation nonce
3
+ * `register()` (`mixins/OxyServices.auth.ts`) submits alongside the
4
+ * registration signature. The message format, difficulty and the full design
5
+ * rationale (why 16 bits, why sequential nonces are fine, what this is and
6
+ * isn't a defense against) live in `@oxy.so/protocol`'s
7
+ * `auth/registrationPow.ts` — this file is only the solve loop.
8
+ */
9
+ import './polyfill.js';
10
+ import { sha256 } from '@noble/hashes/sha256';
11
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
12
+ import { meetsRegistrationPowDifficulty, registrationPowMessage, REGISTRATION_POW_DIFFICULTY_BITS, } from '@oxy.so/protocol';
13
+ /**
14
+ * How many attempts the grind loop runs before yielding to the event loop.
15
+ *
16
+ * A synchronous hot loop long enough to matter (an average solve at 16 bits
17
+ * is 65,536 attempts) would otherwise block the JS thread for its entire
18
+ * duration — on a mobile app that means no touch response, no animation, for
19
+ * up to roughly a second. Yielding periodically keeps the UI responsive
20
+ * during the grind at the cost of a handful of event-loop round trips, which
21
+ * is imperceptible next to the grind itself.
22
+ */
23
+ const YIELD_EVERY_N_ATTEMPTS = 2000;
24
+ function yieldToEventLoop() {
25
+ return new Promise((resolve) => setTimeout(resolve, 0));
26
+ }
27
+ /**
28
+ * Grind a nonce for `publicKey`/`timestamp` that clears
29
+ * {@link REGISTRATION_POW_DIFFICULTY_BITS}, returned as a decimal counter.
30
+ *
31
+ * Hashes with `@noble/hashes/sha256` — pure JS, synchronous, identical output
32
+ * on web/Node/React Native with no native module and no per-attempt bridge
33
+ * crossing (see the difficulty constant's own doc for why that distinction is
34
+ * the whole point). Nonces are sequential (0, 1, 2, …) rather than random: the
35
+ * candidate space a hash function spreads solutions across is dense either
36
+ * way, so a counter finds one exactly as reliably as randomness would, without
37
+ * needing a randomness source at all.
38
+ */
39
+ export async function solveRegistrationPow(publicKey, timestamp) {
40
+ for (let nonce = 0;; nonce += 1) {
41
+ const candidate = String(nonce);
42
+ const digest = bytesToHex(sha256(utf8ToBytes(registrationPowMessage(publicKey, timestamp, candidate))));
43
+ if (meetsRegistrationPowDifficulty(digest, REGISTRATION_POW_DIFFICULTY_BITS)) {
44
+ return candidate;
45
+ }
46
+ if (nonce % YIELD_EVERY_N_ATTEMPTS === YIELD_EVERY_N_ATTEMPTS - 1) {
47
+ await yieldToEventLoop();
48
+ }
49
+ }
50
+ }
@@ -2,6 +2,7 @@ import { loginResultSchema, safeParseContract } from '@oxy.so/contracts';
2
2
  export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from '../utils/commonsApproval.js';
3
3
  import { OxyAuthenticationError } from '../OxyServices.errors.js';
4
4
  import { KeyManager } from '../crypto/keyManager.js';
5
+ import { solveRegistrationPow } from '../crypto/registrationPow.js';
5
6
  import { SignatureService } from '../crypto/signatureService.js';
6
7
  import { loadNodeCrypto } from '@oxy.so/protocol';
7
8
  import { logger } from '../logger/index.js';
@@ -269,7 +270,7 @@ export function OxyServicesAuthMixin(Base) {
269
270
  if (typeof process === 'undefined' || !process.versions?.node)
270
271
  return false;
271
272
  try {
272
- const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity');
273
+ const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity.js');
273
274
  return canAttestWorkloadIdentity();
274
275
  }
275
276
  catch {
@@ -297,7 +298,7 @@ export function OxyServicesAuthMixin(Base) {
297
298
  return entry.token;
298
299
  if (entry?.pending)
299
300
  return entry.pending;
300
- const { requestWorkloadServiceToken } = await import('../server/workloadIdentity');
301
+ const { requestWorkloadServiceToken } = await import('../server/workloadIdentity.js');
301
302
  const seeded = entry ?? {
302
303
  token: '',
303
304
  expiresAt: 0,
@@ -432,10 +433,15 @@ export function OxyServicesAuthMixin(Base) {
432
433
  */
433
434
  async register(publicKey, signature, timestamp) {
434
435
  try {
436
+ // Advisory for now (server soft-enforces — see
437
+ // `SessionController.register`), but solved unconditionally so every
438
+ // client is already sending it once the server starts requiring it.
439
+ const powNonce = await solveRegistrationPow(publicKey, timestamp);
435
440
  const res = await this.makeRequest('POST', '/auth/register', {
436
441
  publicKey,
437
442
  signature,
438
443
  timestamp,
444
+ powNonce,
439
445
  }, { cache: false, skipAuth: true });
440
446
  if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
441
447
  throw new OxyAuthenticationError('Registration failed', 'REGISTER_FAILED', 400);
@@ -118,7 +118,7 @@ export function OxyServicesIdentityBackupMixin(Base) {
118
118
  *
119
119
  * NATIVE-ONLY persistence: `KeyManager.importKeyPair` throws on web. It also
120
120
  * refuses to clobber a DIFFERENT existing on-device identity unless
121
- * `overwrite: true` — the {@link import('../crypto/keyManager').IdentityAlreadyExistsError}
121
+ * `overwrite: true` — the {@link import('../crypto/keyManager.js').IdentityAlreadyExistsError}
122
122
  * propagates to the caller (never swallowed) so the UI can confirm before
123
123
  * overwriting.
124
124
  *
@@ -64,12 +64,19 @@ export function OxyServicesUserMixin(Base) {
64
64
  }
65
65
  /**
66
66
  * Search user profiles
67
+ *
68
+ * Takes an `AbortSignal` because people search is typed into: without one
69
+ * every keystroke started a search that ran to completion and had its
70
+ * result discarded, while holding one of the request queue's slots. It was
71
+ * the only search lane in Mention that could not be cancelled, purely
72
+ * because this signature had no way to say so.
67
73
  */
68
- async searchProfiles(query, pagination) {
74
+ async searchProfiles(query, pagination, options) {
69
75
  try {
70
76
  const response = await this.makeRequest('GET', '/profiles/search', buildQueryParams({ query, ...pagination }), {
71
77
  cache: true,
72
78
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
79
+ signal: options?.signal,
73
80
  });
74
81
  if (typeof response !== 'object' ||
75
82
  response === null ||
@@ -2,6 +2,7 @@
2
2
  * Async utilities for common asynchronous patterns and error handling
3
3
  */
4
4
  import { logger } from '../logger/index.js';
5
+ import { ErrorCodes, isCancelledError } from './errorUtils.js';
5
6
  /**
6
7
  * Wrapper for async operations with automatic error handling
7
8
  * Returns null on error instead of throwing
@@ -53,29 +54,55 @@ function extractHttpStatus(error) {
53
54
  return undefined;
54
55
  }
55
56
  /**
56
- * Retry an async operation with exponential backoff
57
+ * Whether an error came from a request hitting its own timeout.
57
58
  *
58
- * By default, does not retry on 4xx errors (client errors). The default
59
- * predicate accepts both the axios-style `error.response.status` and the
60
- * flat `error.status` shape produced by {@link handleHttpError}, so callers
61
- * never accidentally retry a deterministic client failure.
62
- *
63
- * Use the `shouldRetry` callback to customize retry behavior.
59
+ * A timeout and a caller cancellation both surface from `fetch` as an
60
+ * `AbortError`, so this only recognises one that has already been CLASSIFIED
61
+ * (by `HttpService`, which knows which of its own timers fired). An
62
+ * unclassified abort is treated as a cancellation, which is the safe
63
+ * direction: refusing to retry something that was abandoned costs nothing,
64
+ * while retrying it is the defect this whole option exists to fix.
64
65
  */
65
- export async function retryAsync(operation, maxRetries = 3, baseDelay = 1000, shouldRetry) {
66
+ function isTimeoutError(error) {
67
+ if (!error || typeof error !== 'object')
68
+ return false;
69
+ const candidate = error;
70
+ return candidate.timeout === true || candidate.code === ErrorCodes.TIMEOUT;
71
+ }
72
+ export async function retryAsync(operation, maxRetriesOrOptions = 3, baseDelayArg = 1000, shouldRetryArg) {
73
+ // An options-object overload rather than a fifth positional parameter:
74
+ // `retryOnError` below and other in-tree callers pass positionally, and a
75
+ // boolean wedged between `baseDelay` and `shouldRetry` is the kind of
76
+ // signature that gets called wrong exactly once and then silently.
77
+ const options = typeof maxRetriesOrOptions === 'object' && maxRetriesOrOptions !== null
78
+ ? maxRetriesOrOptions
79
+ : { maxRetries: maxRetriesOrOptions, baseDelay: baseDelayArg, shouldRetry: shouldRetryArg };
80
+ const maxRetries = options.maxRetries ?? 3;
81
+ const baseDelay = options.baseDelay ?? 1000;
82
+ const retryOnTimeout = options.retryOnTimeout ?? false;
83
+ const deadline = options.deadline;
66
84
  let lastError;
67
85
  // Default shouldRetry: don't retry on 4xx errors (client errors).
68
86
  // Checks BOTH `error.status` (flat shape from handleHttpError / fetch
69
87
  // clients) AND `error.response.status` (axios-style shape) so neither
70
88
  // representation can leak a client error into the retry loop.
71
89
  const defaultShouldRetry = (error) => {
90
+ // A cancellation is an INSTRUCTION, not a transient failure, and is
91
+ // refused here rather than only in HttpService so that every `retryAsync`
92
+ // consumer inherits it. This was the defect: an `AbortError` carries no
93
+ // HTTP status, `status: 0` is not 4xx, so the status check below passed it
94
+ // straight into the retry loop and the SDK re-issued requests their
95
+ // callers had already abandoned.
96
+ if (isCancelledError(error)) {
97
+ return false;
98
+ }
72
99
  const status = extractHttpStatus(error);
73
100
  if (status !== undefined && status >= 400 && status < 500) {
74
101
  return false;
75
102
  }
76
103
  return true;
77
104
  };
78
- const retryCheck = shouldRetry || defaultShouldRetry;
105
+ const retryCheck = options.shouldRetry || defaultShouldRetry;
79
106
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
80
107
  try {
81
108
  return await operation();
@@ -85,11 +112,25 @@ export async function retryAsync(operation, maxRetries = 3, baseDelay = 1000, sh
85
112
  if (attempt === maxRetries) {
86
113
  break;
87
114
  }
115
+ // A cancellation is never retryable, whatever `shouldRetry` says: a
116
+ // custom predicate is for deciding which FAILURES are worth another
117
+ // attempt, and it has no business overriding an explicit abort.
118
+ if (isCancelledError(error)) {
119
+ break;
120
+ }
121
+ if (!retryOnTimeout && isTimeoutError(error)) {
122
+ break;
123
+ }
88
124
  if (!retryCheck(error)) {
89
125
  break;
90
126
  }
91
127
  // Calculate delay with exponential backoff and jitter
92
128
  const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
129
+ // Nothing may start, or even sleep, past the deadline — otherwise the
130
+ // budget bounds the attempts but not the wall clock the caller feels.
131
+ if (deadline !== undefined && Date.now() + delay >= deadline) {
132
+ break;
133
+ }
93
134
  await new Promise(resolve => setTimeout(resolve, delay));
94
135
  }
95
136
  }
@@ -26,7 +26,20 @@ export const ErrorCodes = {
26
26
  TIMEOUT: 'TIMEOUT',
27
27
  // Network errors
28
28
  NETWORK_ERROR: 'NETWORK_ERROR',
29
- CONNECTION_FAILED: 'CONNECTION_FAILED'
29
+ CONNECTION_FAILED: 'CONNECTION_FAILED',
30
+ /**
31
+ * The CALLER cancelled this request — it did not fail.
32
+ *
33
+ * Distinct from {@link ErrorCodes.TIMEOUT} on purpose, and the distinction is
34
+ * load-bearing rather than cosmetic. Both arrive from `fetch` as an
35
+ * `AbortError` carrying no status, so for a long time both collapsed to
36
+ * `TIMEOUT` with `status: 0` — and since `status: 0` is not 4xx, the retry
37
+ * predicate treated a cancellation as a transient failure and re-issued the
38
+ * request the caller had just abandoned. A cancellation is an INSTRUCTION;
39
+ * nothing may retry it. See `retryAsync`'s default predicate, which refuses
40
+ * this code specifically.
41
+ */
42
+ CANCELLED: 'CANCELLED'
30
43
  };
31
44
  /**
32
45
  * Narrow a caught value to {@link HttpRequestError}.
@@ -96,6 +109,33 @@ export function createApiError(message, code = ErrorCodes.INTERNAL_ERROR, status
96
109
  details
97
110
  };
98
111
  }
112
+ export function createCancelledError(message = 'Request cancelled') {
113
+ const error = new Error(message);
114
+ error.name = 'AbortError';
115
+ error.code = ErrorCodes.CANCELLED;
116
+ error.status = 0;
117
+ error.cancelled = true;
118
+ return error;
119
+ }
120
+ /**
121
+ * Whether an error represents a CALLER cancellation rather than a failure.
122
+ *
123
+ * Structural, never message-based: a message is localizable and rewordable,
124
+ * and a retry predicate that keys off one is a bug waiting for a copy edit.
125
+ */
126
+ function isAbortLike(error) {
127
+ return (typeof error === 'object' &&
128
+ error !== null &&
129
+ error.name === 'AbortError');
130
+ }
131
+ export function isCancelledError(error) {
132
+ if (!error || typeof error !== 'object')
133
+ return false;
134
+ const candidate = error;
135
+ return (candidate.cancelled === true ||
136
+ candidate.code === ErrorCodes.CANCELLED ||
137
+ candidate.name === 'AbortError');
138
+ }
99
139
  /**
100
140
  * Handle common HTTP errors and convert to ApiError
101
141
  */
@@ -112,8 +152,16 @@ export function handleHttpError(error) {
112
152
  }
113
153
  return apiError;
114
154
  }
115
- // Handle AbortError (timeout or cancelled requests)
116
- if (error instanceof Error && error.name === 'AbortError') {
155
+ // Handle AbortError (timeout or cancelled requests).
156
+ //
157
+ // Matched by NAME rather than `instanceof Error`, because `fetch` rejects an
158
+ // abort with a `DOMException` and `instanceof` is realm-bound — an exception
159
+ // built in another realm (a test VM, a worker, an iframe) fails the check and
160
+ // falls through to the generic 500 branch below. `HttpService` classifies its
161
+ // own aborts before reaching here, so this is the fallback for aborts raised
162
+ // anywhere else, and it stays TIMEOUT: a caller that never passed a signal
163
+ // cannot have cancelled anything.
164
+ if (isAbortLike(error)) {
117
165
  return createApiError('Request timeout or cancelled', ErrorCodes.TIMEOUT, 0);
118
166
  }
119
167
  // Handle TypeError (network failures, CORS, etc.)