@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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Abort awareness in the request queue and the deduplicator.
3
+ *
4
+ * Neither used to know about cancellation, and each turned that into a
5
+ * different bug:
6
+ *
7
+ * - `RequestQueue` never dropped a queued entry whose caller had cancelled, so
8
+ * a burst of abandoned work (a search box being typed into) occupied every
9
+ * one of the ten slots while the ONE request the viewer was waiting on
10
+ * queued behind it. Refusing to RETRY a cancellation stops making more
11
+ * zombies; this is what returns the slots.
12
+ * - `RequestDeduplicator` handed every caller on a key the same promise, so one
13
+ * caller's abort rejected all the others — including ones that never
14
+ * cancelled anything and had no way to know the failure was not theirs.
15
+ */
16
+ import { RequestDeduplicator, RequestQueue } from '../requestUtils';
17
+ import { ErrorCodes } from '../errorUtils';
18
+
19
+ const aborted = (): AbortSignal => {
20
+ const controller = new AbortController();
21
+ controller.abort();
22
+ return controller.signal;
23
+ };
24
+
25
+ describe('RequestQueue abort awareness', () => {
26
+ it('never runs a request whose caller already cancelled', async () => {
27
+ const queue = new RequestQueue(2);
28
+ const run = jest.fn(async () => 'ran');
29
+
30
+ await expect(queue.enqueue(run, aborted())).rejects.toMatchObject({
31
+ code: ErrorCodes.CANCELLED,
32
+ });
33
+ expect(run).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('keeps slots free for a live request when the queue is full of cancelled ones', async () => {
37
+ const queue = new RequestQueue(2);
38
+ let liveRan = false;
39
+
40
+ // Six abandoned requests against a two-slot pool. If any of them took a
41
+ // slot and held it, the live request below could never run.
42
+ const dead = Array.from({ length: 6 }, () =>
43
+ queue
44
+ .enqueue(() => new Promise<string>(() => { /* would never settle */ }), aborted())
45
+ .catch(() => 'cancelled'),
46
+ );
47
+ await Promise.all(dead);
48
+
49
+ await expect(
50
+ queue.enqueue(async () => { liveRan = true; return 'live'; }),
51
+ ).resolves.toBe('live');
52
+ expect(liveRan).toBe(true);
53
+ });
54
+
55
+ it('drops a queued entry that is cancelled while it waits for a slot', async () => {
56
+ const queue = new RequestQueue(1);
57
+ const started: string[] = [];
58
+ let releaseFirst: (() => void) | undefined;
59
+
60
+ const first = queue.enqueue(async () => {
61
+ started.push('first');
62
+ await new Promise<void>((resolve) => { releaseFirst = resolve; });
63
+ return 'first';
64
+ });
65
+
66
+ // The pool is full, so this one sits in the queue rather than starting.
67
+ const controller = new AbortController();
68
+ const second = queue.enqueue(async () => {
69
+ started.push('second');
70
+ return 'second';
71
+ }, controller.signal);
72
+
73
+ await new Promise((resolve) => setTimeout(resolve, 10));
74
+ expect(started).toEqual(['first']);
75
+
76
+ // Cancelled while queued: it must never start, not merely be ignored once
77
+ // it finishes. A request can sit here as long as the slots stay busy, which
78
+ // is exactly when the caller is most likely to have moved on.
79
+ controller.abort();
80
+ await expect(second).rejects.toMatchObject({ code: ErrorCodes.CANCELLED });
81
+
82
+ releaseFirst?.();
83
+ await expect(first).resolves.toBe('first');
84
+ expect(started).toEqual(['first']);
85
+ });
86
+
87
+ it('still decrements its running count when a started request is aborted', async () => {
88
+ const queue = new RequestQueue(1);
89
+ const controller = new AbortController();
90
+
91
+ // An entry that has already started is left to its own abort domain: the
92
+ // queue must still observe it finishing, or `running` never comes back down
93
+ // and the slot is leaked permanently.
94
+ const inflight = queue.enqueue(
95
+ () => new Promise<string>((_resolve, reject) => {
96
+ controller.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
97
+ }),
98
+ controller.signal,
99
+ );
100
+ await new Promise((resolve) => setTimeout(resolve, 10));
101
+ controller.abort();
102
+ await expect(inflight).rejects.toBeDefined();
103
+
104
+ await expect(queue.enqueue(async () => 'after')).resolves.toBe('after');
105
+ });
106
+ });
107
+
108
+ describe('RequestDeduplicator abort awareness', () => {
109
+ it('shares one in-flight request across callers', async () => {
110
+ const dedupe = new RequestDeduplicator();
111
+ const work = jest.fn(async () => {
112
+ await new Promise((resolve) => setTimeout(resolve, 10));
113
+ return 'shared';
114
+ });
115
+
116
+ const [a, b] = await Promise.all([
117
+ dedupe.deduplicate('key', work),
118
+ dedupe.deduplicate('key', work),
119
+ ]);
120
+
121
+ expect(a).toBe('shared');
122
+ expect(b).toBe('shared');
123
+ expect(work).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it('lets one caller cancel without rejecting the others', async () => {
127
+ const dedupe = new RequestDeduplicator();
128
+ const work = jest.fn(async () => {
129
+ await new Promise((resolve) => setTimeout(resolve, 30));
130
+ return 'shared';
131
+ });
132
+
133
+ const cancelling = new AbortController();
134
+ const first = dedupe.deduplicate('key', work, cancelling.signal);
135
+ const second = dedupe.deduplicate('key', work, new AbortController().signal);
136
+
137
+ const firstAssertion = expect(first).rejects.toMatchObject({ code: ErrorCodes.CANCELLED });
138
+ cancelling.abort();
139
+ await firstAssertion;
140
+
141
+ // The caller that never cancelled anything still gets its answer.
142
+ await expect(second).resolves.toBe('shared');
143
+ expect(work).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ it('refuses a caller whose signal is already aborted without starting work', async () => {
147
+ const dedupe = new RequestDeduplicator();
148
+ const work = jest.fn(async () => 'shared');
149
+
150
+ await expect(dedupe.deduplicate('key', work, aborted())).rejects.toMatchObject({
151
+ code: ErrorCodes.CANCELLED,
152
+ });
153
+ expect(work).not.toHaveBeenCalled();
154
+ });
155
+ });
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { logger } from '../logger';
6
+ import { ErrorCodes, isCancelledError } from './errorUtils';
6
7
 
7
8
  /**
8
9
  * Wrapper for async operations with automatic error handling
@@ -79,12 +80,72 @@ function extractHttpStatus(error: unknown): number | undefined {
79
80
  *
80
81
  * Use the `shouldRetry` callback to customize retry behavior.
81
82
  */
83
+ export interface RetryOptions {
84
+ maxRetries?: number;
85
+ baseDelay?: number;
86
+ shouldRetry?: (error: unknown) => boolean;
87
+ /**
88
+ * Retry a request that hit its own per-attempt timeout. Default `false`.
89
+ *
90
+ * Retrying a timeout is defensible for an idempotent GET and indefensible
91
+ * with the old defaults: a 5s per-attempt timeout with 3 retries and
92
+ * exponential backoff spends ~28s before reporting a failure, all of it after
93
+ * the caller stopped waiting, while holding a request-queue slot throughout.
94
+ * A timeout also usually means the SERVER is slow, so three more attempts add
95
+ * load to the thing already struggling.
96
+ *
97
+ * Opt in per call, and pair it with {@link deadline} so the total is bounded
98
+ * by wall clock rather than by attempts x timeout + backoff.
99
+ */
100
+ retryOnTimeout?: boolean;
101
+ /**
102
+ * Absolute wall-clock budget for ALL attempts, as an epoch milliseconds
103
+ * timestamp (`Date.now() + budget`). Once passed, no further attempt starts
104
+ * and the last error is thrown.
105
+ *
106
+ * This is what makes a pathological total unreachable BY CONSTRUCTION rather
107
+ * than by arithmetic on the other knobs: a deadline per call, a timeout per
108
+ * attempt, and jittered retries inside the deadline.
109
+ */
110
+ deadline?: number;
111
+ }
112
+
113
+ /**
114
+ * Whether an error came from a request hitting its own timeout.
115
+ *
116
+ * A timeout and a caller cancellation both surface from `fetch` as an
117
+ * `AbortError`, so this only recognises one that has already been CLASSIFIED
118
+ * (by `HttpService`, which knows which of its own timers fired). An
119
+ * unclassified abort is treated as a cancellation, which is the safe
120
+ * direction: refusing to retry something that was abandoned costs nothing,
121
+ * while retrying it is the defect this whole option exists to fix.
122
+ */
123
+ function isTimeoutError(error: unknown): boolean {
124
+ if (!error || typeof error !== 'object') return false;
125
+ const candidate = error as { code?: unknown; timeout?: unknown };
126
+ return candidate.timeout === true || candidate.code === ErrorCodes.TIMEOUT;
127
+ }
128
+
82
129
  export async function retryAsync<T>(
83
130
  operation: () => Promise<T>,
84
- maxRetries = 3,
85
- baseDelay = 1000,
86
- shouldRetry?: (error: any) => boolean
131
+ maxRetriesOrOptions: number | RetryOptions = 3,
132
+ baseDelayArg = 1000,
133
+ shouldRetryArg?: (error: any) => boolean
87
134
  ): Promise<T> {
135
+ // An options-object overload rather than a fifth positional parameter:
136
+ // `retryOnError` below and other in-tree callers pass positionally, and a
137
+ // boolean wedged between `baseDelay` and `shouldRetry` is the kind of
138
+ // signature that gets called wrong exactly once and then silently.
139
+ const options: RetryOptions =
140
+ typeof maxRetriesOrOptions === 'object' && maxRetriesOrOptions !== null
141
+ ? maxRetriesOrOptions
142
+ : { maxRetries: maxRetriesOrOptions, baseDelay: baseDelayArg, shouldRetry: shouldRetryArg };
143
+
144
+ const maxRetries = options.maxRetries ?? 3;
145
+ const baseDelay = options.baseDelay ?? 1000;
146
+ const retryOnTimeout = options.retryOnTimeout ?? false;
147
+ const deadline = options.deadline;
148
+
88
149
  let lastError: any;
89
150
 
90
151
  // Default shouldRetry: don't retry on 4xx errors (client errors).
@@ -92,6 +153,15 @@ export async function retryAsync<T>(
92
153
  // clients) AND `error.response.status` (axios-style shape) so neither
93
154
  // representation can leak a client error into the retry loop.
94
155
  const defaultShouldRetry = (error: unknown): boolean => {
156
+ // A cancellation is an INSTRUCTION, not a transient failure, and is
157
+ // refused here rather than only in HttpService so that every `retryAsync`
158
+ // consumer inherits it. This was the defect: an `AbortError` carries no
159
+ // HTTP status, `status: 0` is not 4xx, so the status check below passed it
160
+ // straight into the retry loop and the SDK re-issued requests their
161
+ // callers had already abandoned.
162
+ if (isCancelledError(error)) {
163
+ return false;
164
+ }
95
165
  const status = extractHttpStatus(error);
96
166
  if (status !== undefined && status >= 400 && status < 500) {
97
167
  return false;
@@ -99,28 +169,46 @@ export async function retryAsync<T>(
99
169
  return true;
100
170
  };
101
171
 
102
- const retryCheck = shouldRetry || defaultShouldRetry;
103
-
172
+ const retryCheck = options.shouldRetry || defaultShouldRetry;
173
+
104
174
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
105
175
  try {
106
176
  return await operation();
107
177
  } catch (error) {
108
178
  lastError = error;
109
-
179
+
110
180
  if (attempt === maxRetries) {
111
181
  break;
112
182
  }
113
-
183
+
184
+ // A cancellation is never retryable, whatever `shouldRetry` says: a
185
+ // custom predicate is for deciding which FAILURES are worth another
186
+ // attempt, and it has no business overriding an explicit abort.
187
+ if (isCancelledError(error)) {
188
+ break;
189
+ }
190
+
191
+ if (!retryOnTimeout && isTimeoutError(error)) {
192
+ break;
193
+ }
194
+
114
195
  if (!retryCheck(error)) {
115
196
  break;
116
197
  }
117
-
198
+
118
199
  // Calculate delay with exponential backoff and jitter
119
200
  const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
201
+
202
+ // Nothing may start, or even sleep, past the deadline — otherwise the
203
+ // budget bounds the attempts but not the wall clock the caller feels.
204
+ if (deadline !== undefined && Date.now() + delay >= deadline) {
205
+ break;
206
+ }
207
+
120
208
  await new Promise(resolve => setTimeout(resolve, delay));
121
209
  }
122
210
  }
123
-
211
+
124
212
  throw lastError;
125
213
  }
126
214
 
@@ -33,7 +33,21 @@ export const ErrorCodes = {
33
33
 
34
34
  // Network errors
35
35
  NETWORK_ERROR: 'NETWORK_ERROR',
36
- CONNECTION_FAILED: 'CONNECTION_FAILED'
36
+ CONNECTION_FAILED: 'CONNECTION_FAILED',
37
+
38
+ /**
39
+ * The CALLER cancelled this request — it did not fail.
40
+ *
41
+ * Distinct from {@link ErrorCodes.TIMEOUT} on purpose, and the distinction is
42
+ * load-bearing rather than cosmetic. Both arrive from `fetch` as an
43
+ * `AbortError` carrying no status, so for a long time both collapsed to
44
+ * `TIMEOUT` with `status: 0` — and since `status: 0` is not 4xx, the retry
45
+ * predicate treated a cancellation as a transient failure and re-issued the
46
+ * request the caller had just abandoned. A cancellation is an INSTRUCTION;
47
+ * nothing may retry it. See `retryAsync`'s default predicate, which refuses
48
+ * this code specifically.
49
+ */
50
+ CANCELLED: 'CANCELLED'
37
51
  } as const;
38
52
 
39
53
  /**
@@ -157,6 +171,59 @@ export function createApiError(
157
171
  };
158
172
  }
159
173
 
174
+ /**
175
+ * The error a CALLER-cancelled request rejects with.
176
+ *
177
+ * Carries both identities on purpose, because consumers check for
178
+ * cancellation two different ways and both are legitimate:
179
+ *
180
+ * - `name: 'AbortError'` is what every `fetch`-era consumer tests, and what
181
+ * React Query and friends recognise. Dropping it would silently turn
182
+ * cancellations into errors in every app that already handles aborts.
183
+ * - `code: ErrorCodes.CANCELLED` / `cancelled: true` is what code inside this
184
+ * SDK tests, because `handleHttpError` returns PLAIN objects (not `Error`s)
185
+ * for most failures, so `name` is not a reliable discriminator across the
186
+ * whole surface.
187
+ *
188
+ * It is an `Error` rather than a bare `ApiError` object so a stack survives and
189
+ * `instanceof Error` holds — the shape the abort path always looked like it
190
+ * produced, and never did.
191
+ */
192
+ export type CancelledError = Error & ApiError & { cancelled: true };
193
+
194
+ export function createCancelledError(message = 'Request cancelled'): CancelledError {
195
+ const error = new Error(message) as CancelledError;
196
+ error.name = 'AbortError';
197
+ error.code = ErrorCodes.CANCELLED;
198
+ error.status = 0;
199
+ error.cancelled = true;
200
+ return error;
201
+ }
202
+
203
+ /**
204
+ * Whether an error represents a CALLER cancellation rather than a failure.
205
+ *
206
+ * Structural, never message-based: a message is localizable and rewordable,
207
+ * and a retry predicate that keys off one is a bug waiting for a copy edit.
208
+ */
209
+ function isAbortLike(error: unknown): boolean {
210
+ return (
211
+ typeof error === 'object' &&
212
+ error !== null &&
213
+ (error as { name?: unknown }).name === 'AbortError'
214
+ );
215
+ }
216
+
217
+ export function isCancelledError(error: unknown): boolean {
218
+ if (!error || typeof error !== 'object') return false;
219
+ const candidate = error as { code?: unknown; cancelled?: unknown; name?: unknown };
220
+ return (
221
+ candidate.cancelled === true ||
222
+ candidate.code === ErrorCodes.CANCELLED ||
223
+ candidate.name === 'AbortError'
224
+ );
225
+ }
226
+
160
227
  /**
161
228
  * Handle common HTTP errors and convert to ApiError
162
229
  */
@@ -174,8 +241,16 @@ export function handleHttpError(error: unknown): ApiError {
174
241
  return apiError;
175
242
  }
176
243
 
177
- // Handle AbortError (timeout or cancelled requests)
178
- if (error instanceof Error && error.name === 'AbortError') {
244
+ // Handle AbortError (timeout or cancelled requests).
245
+ //
246
+ // Matched by NAME rather than `instanceof Error`, because `fetch` rejects an
247
+ // abort with a `DOMException` and `instanceof` is realm-bound — an exception
248
+ // built in another realm (a test VM, a worker, an iframe) fails the check and
249
+ // falls through to the generic 500 branch below. `HttpService` classifies its
250
+ // own aborts before reaching here, so this is the fallback for aborts raised
251
+ // anywhere else, and it stays TIMEOUT: a caller that never passed a signal
252
+ // cannot have cancelled anything.
253
+ if (isAbortLike(error)) {
179
254
  return createApiError(
180
255
  'Request timeout or cancelled',
181
256
  ErrorCodes.TIMEOUT,
@@ -4,6 +4,28 @@
4
4
  * Provides reusable components for request deduplication, queuing, and logging
5
5
  */
6
6
 
7
+ import { createCancelledError } from './errorUtils';
8
+
9
+ /**
10
+ * The error to reject an aborted request with.
11
+ *
12
+ * ALWAYS the SDK's own cancellation error, with the signal's `reason` preserved
13
+ * as `cause` rather than substituted for it. The tempting version —
14
+ * `reason ?? createCancelledError()` — is wrong twice: `controller.abort()`
15
+ * always populates `reason` with a default `DOMException`, so the fallback
16
+ * would never run, and that `DOMException` carries none of the fields the retry
17
+ * predicates and consumers key off (`code: 'CANCELLED'`, `cancelled: true`).
18
+ * A cancellation that is not recognisable as one is the whole defect.
19
+ */
20
+ function abortReason(signal: AbortSignal): unknown {
21
+ const reason = (signal as AbortSignal & { reason?: unknown }).reason;
22
+ const error = createCancelledError('Request cancelled') as Error & { cause?: unknown };
23
+ if (reason !== undefined) {
24
+ error.cause = reason;
25
+ }
26
+ return error;
27
+ }
28
+
7
29
  /**
8
30
  * Request deduplication - prevents duplicate concurrent requests
9
31
  *
@@ -31,20 +53,48 @@ export class RequestDeduplicator {
31
53
  */
32
54
  async deduplicate<T>(
33
55
  key: string,
34
- requestFn: () => Promise<T>
56
+ requestFn: () => Promise<T>,
57
+ signal?: AbortSignal
35
58
  ): Promise<T> {
36
- const existing = this.pendingRequests.get(key);
37
- if (existing) {
38
- return existing;
59
+ if (signal?.aborted) {
60
+ throw abortReason(signal);
39
61
  }
40
62
 
41
- const promise = requestFn()
42
- .finally(() => {
63
+ let promise = this.pendingRequests.get(key) as Promise<T> | undefined;
64
+ if (!promise) {
65
+ promise = requestFn().finally(() => {
43
66
  this.pendingRequests.delete(key);
44
67
  });
68
+ this.pendingRequests.set(key, promise);
69
+ }
70
+
71
+ // Callers SHARE the work but own their cancellation separately.
72
+ //
73
+ // Returning the shared promise directly meant one caller's abort rejected
74
+ // every other caller on the same key — including ones that never cancelled
75
+ // anything, and which had no way to tell that the failure was not theirs.
76
+ // Racing each caller's own signal against the shared work keeps the single
77
+ // in-flight request (the point of deduplication) while making cancellation
78
+ // per-caller. The shared promise is left running: another caller may still
79
+ // want it, and if nobody does its own abort domain ends it.
80
+ if (!signal) {
81
+ return promise;
82
+ }
83
+
84
+ // `promise` is already owned by the map's `finally`, so attach a no-op
85
+ // catch to the copy we race: without it, a rejection settled by the race's
86
+ // loser surfaces as an unhandled rejection.
87
+ const shared = promise;
88
+ shared.catch(() => { /* ownership stays with the caller(s) awaiting it */ });
45
89
 
46
- this.pendingRequests.set(key, promise);
47
- return promise;
90
+ return new Promise<T>((resolve, reject) => {
91
+ const onAbort = (): void => reject(abortReason(signal));
92
+ signal.addEventListener('abort', onAbort, { once: true });
93
+ shared.then(
94
+ (value) => { signal.removeEventListener('abort', onAbort); resolve(value); },
95
+ (error) => { signal.removeEventListener('abort', onAbort); reject(error); },
96
+ );
97
+ });
48
98
  }
49
99
 
50
100
  /**
@@ -99,21 +149,62 @@ export class RequestQueue {
99
149
  * @param requestFn Function that returns a promise
100
150
  * @returns Promise that resolves when request completes
101
151
  */
102
- async enqueue<T>(requestFn: () => Promise<T>): Promise<T> {
152
+ async enqueue<T>(requestFn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
103
153
  if (this.queue.length >= this.maxQueueSize) {
104
154
  throw new Error('Request queue is full');
105
155
  }
106
156
 
157
+ // A slot is the scarce resource — ten by default — so a request whose
158
+ // caller has already given up must never consume one. Without this, a
159
+ // burst of cancelled work (a search box being typed into, say) keeps every
160
+ // slot busy on results nobody will read, and the ONE request the user is
161
+ // waiting on queues behind them. This is the change that actually returns
162
+ // the slots; refusing to retry a cancellation only stops making more.
163
+ if (signal?.aborted) {
164
+ throw abortReason(signal);
165
+ }
166
+
107
167
  return new Promise<T>((resolve, reject) => {
108
- this.queue.push(async () => {
168
+ let settled = false;
169
+ const finish = (fn: () => void): void => {
170
+ if (settled) return;
171
+ settled = true;
172
+ signal?.removeEventListener('abort', onAbort);
173
+ fn();
174
+ };
175
+
176
+ const entry = async (): Promise<void> => {
177
+ // Re-checked at the moment of execution, not just at enqueue: a request
178
+ // can sit in the queue for as long as the slots stay busy, and the
179
+ // caller may well have moved on in the meantime.
180
+ if (signal?.aborted) {
181
+ finish(() => reject(abortReason(signal)));
182
+ return;
183
+ }
109
184
  try {
110
185
  const result = await requestFn();
111
- resolve(result);
186
+ finish(() => resolve(result));
112
187
  } catch (error) {
113
- reject(error);
188
+ finish(() => reject(error));
114
189
  }
115
- });
190
+ };
191
+
192
+ const abortingSignal = signal;
193
+ function onAbort(this: void): void {
194
+ if (!abortingSignal) return;
195
+ // Drop it from the queue if it has not started. An entry already
196
+ // running is left to its own abort domain — the queue must still see it
197
+ // finish, or `running` never decrements and the slot leaks for good.
198
+ const index = queueRef.indexOf(entry);
199
+ if (index !== -1) {
200
+ queueRef.splice(index, 1);
201
+ }
202
+ finish(() => reject(abortReason(abortingSignal)));
203
+ }
116
204
 
205
+ const queueRef = this.queue;
206
+ signal?.addEventListener('abort', onAbort, { once: true });
207
+ this.queue.push(entry);
117
208
  this.process();
118
209
  });
119
210
  }