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