@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.
- package/LICENSE +675 -201
- package/NOTICE +7 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +146 -23
- package/dist/cjs/crypto/registrationPow.js +53 -0
- package/dist/cjs/mixins/OxyServices.auth.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +8 -1
- package/dist/cjs/utils/asyncUtils.js +50 -9
- package/dist/cjs/utils/errorUtils.js +53 -3
- package/dist/cjs/utils/requestUtils.js +96 -14
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +147 -24
- package/dist/esm/crypto/registrationPow.js +50 -0
- package/dist/esm/mixins/OxyServices.auth.js +8 -2
- package/dist/esm/mixins/OxyServices.identityBackup.js +1 -1
- package/dist/esm/mixins/OxyServices.user.js +8 -1
- package/dist/esm/utils/asyncUtils.js +50 -9
- package/dist/esm/utils/errorUtils.js +51 -3
- package/dist/esm/utils/requestUtils.js +96 -14
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +19 -0
- package/dist/types/crypto/registrationPow.d.ts +22 -0
- package/dist/types/mixins/OxyServices.user.d.ts +10 -2
- package/dist/types/utils/asyncUtils.d.ts +30 -1
- package/dist/types/utils/errorUtils.d.ts +36 -0
- package/dist/types/utils/requestUtils.d.ts +2 -2
- package/package.json +2 -2
- package/src/HttpService.ts +181 -31
- package/src/__tests__/fixEsmImports.test.ts +132 -0
- package/src/__tests__/httpServiceAbort.test.ts +308 -0
- package/src/crypto/__tests__/registrationPow.test.ts +61 -0
- package/src/crypto/registrationPow.ts +60 -0
- package/src/mixins/OxyServices.auth.ts +7 -0
- package/src/mixins/OxyServices.user.ts +12 -1
- package/src/utils/__tests__/asyncUtils.test.ts +93 -1
- package/src/utils/__tests__/requestUtilsAbort.test.ts +155 -0
- package/src/utils/asyncUtils.ts +97 -9
- package/src/utils/errorUtils.ts +78 -3
- package/src/utils/requestUtils.ts +104 -13
|
@@ -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
|
+
});
|
package/src/utils/asyncUtils.ts
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
package/src/utils/errorUtils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
return existing;
|
|
59
|
+
if (signal?.aborted) {
|
|
60
|
+
throw abortReason(signal);
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|