@oxy.so/core 1.6.1 → 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.
@@ -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
@@ -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.)
@@ -3,6 +3,26 @@
3
3
  *
4
4
  * Provides reusable components for request deduplication, queuing, and logging
5
5
  */
6
+ import { createCancelledError } from './errorUtils.js';
7
+ /**
8
+ * The error to reject an aborted request with.
9
+ *
10
+ * ALWAYS the SDK's own cancellation error, with the signal's `reason` preserved
11
+ * as `cause` rather than substituted for it. The tempting version —
12
+ * `reason ?? createCancelledError()` — is wrong twice: `controller.abort()`
13
+ * always populates `reason` with a default `DOMException`, so the fallback
14
+ * would never run, and that `DOMException` carries none of the fields the retry
15
+ * predicates and consumers key off (`code: 'CANCELLED'`, `cancelled: true`).
16
+ * A cancellation that is not recognisable as one is the whole defect.
17
+ */
18
+ function abortReason(signal) {
19
+ const reason = signal.reason;
20
+ const error = createCancelledError('Request cancelled');
21
+ if (reason !== undefined) {
22
+ error.cause = reason;
23
+ }
24
+ return error;
25
+ }
6
26
  /**
7
27
  * Request deduplication - prevents duplicate concurrent requests
8
28
  *
@@ -29,17 +49,39 @@ export class RequestDeduplicator {
29
49
  * @param requestFn Function that returns a promise
30
50
  * @returns Promise that will be shared if key already exists
31
51
  */
32
- async deduplicate(key, requestFn) {
33
- const existing = this.pendingRequests.get(key);
34
- if (existing) {
35
- return existing;
52
+ async deduplicate(key, requestFn, signal) {
53
+ if (signal?.aborted) {
54
+ throw abortReason(signal);
55
+ }
56
+ let promise = this.pendingRequests.get(key);
57
+ if (!promise) {
58
+ promise = requestFn().finally(() => {
59
+ this.pendingRequests.delete(key);
60
+ });
61
+ this.pendingRequests.set(key, promise);
36
62
  }
37
- const promise = requestFn()
38
- .finally(() => {
39
- this.pendingRequests.delete(key);
63
+ // Callers SHARE the work but own their cancellation separately.
64
+ //
65
+ // Returning the shared promise directly meant one caller's abort rejected
66
+ // every other caller on the same key — including ones that never cancelled
67
+ // anything, and which had no way to tell that the failure was not theirs.
68
+ // Racing each caller's own signal against the shared work keeps the single
69
+ // in-flight request (the point of deduplication) while making cancellation
70
+ // per-caller. The shared promise is left running: another caller may still
71
+ // want it, and if nobody does its own abort domain ends it.
72
+ if (!signal) {
73
+ return promise;
74
+ }
75
+ // `promise` is already owned by the map's `finally`, so attach a no-op
76
+ // catch to the copy we race: without it, a rejection settled by the race's
77
+ // loser surfaces as an unhandled rejection.
78
+ const shared = promise;
79
+ shared.catch(() => { });
80
+ return new Promise((resolve, reject) => {
81
+ const onAbort = () => reject(abortReason(signal));
82
+ signal.addEventListener('abort', onAbort, { once: true });
83
+ shared.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
40
84
  });
41
- this.pendingRequests.set(key, promise);
42
- return promise;
43
85
  }
44
86
  /**
45
87
  * Clear all pending requests
@@ -87,20 +129,60 @@ export class RequestQueue {
87
129
  * @param requestFn Function that returns a promise
88
130
  * @returns Promise that resolves when request completes
89
131
  */
90
- async enqueue(requestFn) {
132
+ async enqueue(requestFn, signal) {
91
133
  if (this.queue.length >= this.maxQueueSize) {
92
134
  throw new Error('Request queue is full');
93
135
  }
136
+ // A slot is the scarce resource — ten by default — so a request whose
137
+ // caller has already given up must never consume one. Without this, a
138
+ // burst of cancelled work (a search box being typed into, say) keeps every
139
+ // slot busy on results nobody will read, and the ONE request the user is
140
+ // waiting on queues behind them. This is the change that actually returns
141
+ // the slots; refusing to retry a cancellation only stops making more.
142
+ if (signal?.aborted) {
143
+ throw abortReason(signal);
144
+ }
94
145
  return new Promise((resolve, reject) => {
95
- this.queue.push(async () => {
146
+ let settled = false;
147
+ const finish = (fn) => {
148
+ if (settled)
149
+ return;
150
+ settled = true;
151
+ signal?.removeEventListener('abort', onAbort);
152
+ fn();
153
+ };
154
+ const entry = async () => {
155
+ // Re-checked at the moment of execution, not just at enqueue: a request
156
+ // can sit in the queue for as long as the slots stay busy, and the
157
+ // caller may well have moved on in the meantime.
158
+ if (signal?.aborted) {
159
+ finish(() => reject(abortReason(signal)));
160
+ return;
161
+ }
96
162
  try {
97
163
  const result = await requestFn();
98
- resolve(result);
164
+ finish(() => resolve(result));
99
165
  }
100
166
  catch (error) {
101
- reject(error);
167
+ finish(() => reject(error));
102
168
  }
103
- });
169
+ };
170
+ const abortingSignal = signal;
171
+ function onAbort() {
172
+ if (!abortingSignal)
173
+ return;
174
+ // Drop it from the queue if it has not started. An entry already
175
+ // running is left to its own abort domain — the queue must still see it
176
+ // finish, or `running` never decrements and the slot leaks for good.
177
+ const index = queueRef.indexOf(entry);
178
+ if (index !== -1) {
179
+ queueRef.splice(index, 1);
180
+ }
181
+ finish(() => reject(abortReason(abortingSignal)));
182
+ }
183
+ const queueRef = this.queue;
184
+ signal?.addEventListener('abort', onAbort, { once: true });
185
+ this.queue.push(entry);
104
186
  this.process();
105
187
  });
106
188
  }