@oxy.so/core 1.6.0 → 1.7.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.
@@ -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
@@ -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 ||
@@ -5,6 +5,12 @@ const trafficNodeHttp_1 = require("./trafficNodeHttp");
5
5
  const socket_1 = require("@oxy.so/telemetry/socket");
6
6
  const node_crypto_1 = require("node:crypto");
7
7
  const OxyServices_1 = require("../OxyServices");
8
+ // Static, unlike the lazy import in `mixins/OxyServices.auth.ts`: that module is
9
+ // reachable from the root barrel, which ships to React Native, so pulling
10
+ // `node:crypto` in eagerly there would cost every mobile bundle. This file is
11
+ // server-only and the server barrel already exports `./workloadIdentity`, so
12
+ // there is nothing here for a phone to load.
13
+ const workloadIdentity_1 = require("./workloadIdentity");
8
14
  const collector_1 = require("@oxy.so/telemetry/collector");
9
15
  /** Install once per service process, before registering HTTP routes. */
10
16
  function createEcosystemTraffic(options) {
@@ -17,9 +23,30 @@ function createEcosystemTraffic(options) {
17
23
  }
18
24
  const apiKey = activityKey || process.env.OXY_SERVICE_API_KEY;
19
25
  const apiSecret = activitySecret || process.env.OXY_SERVICE_API_SECRET;
20
- if (!options.credential && (!apiKey || !apiSecret))
21
- throw new Error('Ecosystem activity requires OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET');
26
+ /**
27
+ * Having no key pair is a legitimate configuration now — but only where this
28
+ * process can prove what it IS (ADR 0026).
29
+ *
30
+ * This used to throw on a missing pair, full stop. Every service in the fleet
31
+ * boots with `OXY_ECOSYSTEM_ACTIVITY_ENABLED=true`, so deleting
32
+ * `OXY_SERVICE_API_KEY` and `OXY_SERVICE_API_SECRET` from a task definition —
33
+ * which is the entire migration ADR 0026 asks for — killed the process at
34
+ * boot. The ECS circuit breaker then rolled the deploy back and reported the
35
+ * service stable, so the operator saw a rollback with nothing naming its
36
+ * cause. That is what one line cost, multiplied by fourteen services holding
37
+ * a secret they no longer need.
38
+ *
39
+ * What is still refused: nothing to authenticate with AND nothing to attest.
40
+ * That is a laptop or a CI box, where the container credentials endpoint is
41
+ * not going to turn up later, and the honest answer is to say so at boot
42
+ * rather than to fail on the first heartbeat.
43
+ */
44
+ if (!options.credential && (!apiKey || !apiSecret) && !(0, workloadIdentity_1.canAttestWorkloadIdentity)()) {
45
+ throw new Error('Ecosystem activity needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity this process can attest (ADR 0026).');
46
+ }
22
47
  const oxy = new OxyServices_1.OxyServices({ baseURL });
48
+ // Left unconfigured when there is no pair on purpose: `getServiceToken()`
49
+ // attests instead of being handed half a credential to fail on.
23
50
  if (apiKey && apiSecret)
24
51
  oxy.configureServiceAuth(apiKey, apiSecret);
25
52
  const credential = options.credential ?? (() => oxy.getServiceToken());
@@ -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
  }