@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
package/dist/cjs/HttpService.js
CHANGED
|
@@ -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
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
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,
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
718
|
+
try {
|
|
719
|
+
// Execute the request. Control-plane calls the auth lane depends on
|
|
720
|
+
// (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
|
|
721
|
+
// could never acquire a slot when every slot is parked awaiting it.
|
|
722
|
+
const result = config.bypassQueue
|
|
723
|
+
? await finalRequest()
|
|
724
|
+
: await this.requestQueue.enqueue(finalRequest, callController.signal);
|
|
725
|
+
// Cache the result if caching is enabled
|
|
726
|
+
if (cache && cacheKey && result) {
|
|
727
|
+
this.cache.set(cacheKey, result, cacheTTL);
|
|
728
|
+
this.warnIfCacheOversized();
|
|
729
|
+
}
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
// The call-wide link to the CALLER's signal, released once the call is
|
|
734
|
+
// over however it ended. Without this a caller signal that outlives the
|
|
735
|
+
// request — the normal case for a React Query query signal — accrues one
|
|
736
|
+
// listener per request for its whole lifetime.
|
|
737
|
+
disposeCallerLink?.();
|
|
614
738
|
}
|
|
615
|
-
return result;
|
|
616
739
|
}
|
|
617
740
|
/**
|
|
618
741
|
* Soft cache-size guard. Emits a single throttled telemetry warning when the
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Registration proof-of-work — the CLIENT half of the anti-automation nonce
|
|
4
|
+
* `register()` (`mixins/OxyServices.auth.ts`) submits alongside the
|
|
5
|
+
* registration signature. The message format, difficulty and the full design
|
|
6
|
+
* rationale (why 16 bits, why sequential nonces are fine, what this is and
|
|
7
|
+
* isn't a defense against) live in `@oxy.so/protocol`'s
|
|
8
|
+
* `auth/registrationPow.ts` — this file is only the solve loop.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.solveRegistrationPow = solveRegistrationPow;
|
|
12
|
+
require("./polyfill");
|
|
13
|
+
const sha256_1 = require("@noble/hashes/sha256");
|
|
14
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
15
|
+
const protocol_1 = require("@oxy.so/protocol");
|
|
16
|
+
/**
|
|
17
|
+
* How many attempts the grind loop runs before yielding to the event loop.
|
|
18
|
+
*
|
|
19
|
+
* A synchronous hot loop long enough to matter (an average solve at 16 bits
|
|
20
|
+
* is 65,536 attempts) would otherwise block the JS thread for its entire
|
|
21
|
+
* duration — on a mobile app that means no touch response, no animation, for
|
|
22
|
+
* up to roughly a second. Yielding periodically keeps the UI responsive
|
|
23
|
+
* during the grind at the cost of a handful of event-loop round trips, which
|
|
24
|
+
* is imperceptible next to the grind itself.
|
|
25
|
+
*/
|
|
26
|
+
const YIELD_EVERY_N_ATTEMPTS = 2000;
|
|
27
|
+
function yieldToEventLoop() {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Grind a nonce for `publicKey`/`timestamp` that clears
|
|
32
|
+
* {@link REGISTRATION_POW_DIFFICULTY_BITS}, returned as a decimal counter.
|
|
33
|
+
*
|
|
34
|
+
* Hashes with `@noble/hashes/sha256` — pure JS, synchronous, identical output
|
|
35
|
+
* on web/Node/React Native with no native module and no per-attempt bridge
|
|
36
|
+
* crossing (see the difficulty constant's own doc for why that distinction is
|
|
37
|
+
* the whole point). Nonces are sequential (0, 1, 2, …) rather than random: the
|
|
38
|
+
* candidate space a hash function spreads solutions across is dense either
|
|
39
|
+
* way, so a counter finds one exactly as reliably as randomness would, without
|
|
40
|
+
* needing a randomness source at all.
|
|
41
|
+
*/
|
|
42
|
+
async function solveRegistrationPow(publicKey, timestamp) {
|
|
43
|
+
for (let nonce = 0;; nonce += 1) {
|
|
44
|
+
const candidate = String(nonce);
|
|
45
|
+
const digest = (0, utils_1.bytesToHex)((0, sha256_1.sha256)((0, utils_1.utf8ToBytes)((0, protocol_1.registrationPowMessage)(publicKey, timestamp, candidate))));
|
|
46
|
+
if ((0, protocol_1.meetsRegistrationPowDifficulty)(digest, protocol_1.REGISTRATION_POW_DIFFICULTY_BITS)) {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
if (nonce % YIELD_EVERY_N_ATTEMPTS === YIELD_EVERY_N_ATTEMPTS - 1) {
|
|
50
|
+
await yieldToEventLoop();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -41,6 +41,7 @@ Object.defineProperty(exports, "getCommonsApprovalBlockingReason", { enumerable:
|
|
|
41
41
|
Object.defineProperty(exports, "parseCommonsApprovalExpiresAt", { enumerable: true, get: function () { return commonsApproval_1.parseCommonsApprovalExpiresAt; } });
|
|
42
42
|
const OxyServices_errors_1 = require("../OxyServices.errors");
|
|
43
43
|
const keyManager_1 = require("../crypto/keyManager");
|
|
44
|
+
const registrationPow_1 = require("../crypto/registrationPow");
|
|
44
45
|
const signatureService_1 = require("../crypto/signatureService");
|
|
45
46
|
const protocol_1 = require("@oxy.so/protocol");
|
|
46
47
|
const logger_1 = require("../logger");
|
|
@@ -472,10 +473,15 @@ function OxyServicesAuthMixin(Base) {
|
|
|
472
473
|
*/
|
|
473
474
|
async register(publicKey, signature, timestamp) {
|
|
474
475
|
try {
|
|
476
|
+
// Advisory for now (server soft-enforces — see
|
|
477
|
+
// `SessionController.register`), but solved unconditionally so every
|
|
478
|
+
// client is already sending it once the server starts requiring it.
|
|
479
|
+
const powNonce = await (0, registrationPow_1.solveRegistrationPow)(publicKey, timestamp);
|
|
475
480
|
const res = await this.makeRequest('POST', '/auth/register', {
|
|
476
481
|
publicKey,
|
|
477
482
|
signature,
|
|
478
483
|
timestamp,
|
|
484
|
+
powNonce,
|
|
479
485
|
}, { cache: false, skipAuth: true });
|
|
480
486
|
if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
|
|
481
487
|
throw new OxyServices_errors_1.OxyAuthenticationError('Registration failed', 'REGISTER_FAILED', 400);
|
|
@@ -67,12 +67,19 @@ function OxyServicesUserMixin(Base) {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Search user profiles
|
|
70
|
+
*
|
|
71
|
+
* Takes an `AbortSignal` because people search is typed into: without one
|
|
72
|
+
* every keystroke started a search that ran to completion and had its
|
|
73
|
+
* result discarded, while holding one of the request queue's slots. It was
|
|
74
|
+
* the only search lane in Mention that could not be cancelled, purely
|
|
75
|
+
* because this signature had no way to say so.
|
|
70
76
|
*/
|
|
71
|
-
async searchProfiles(query, pagination) {
|
|
77
|
+
async searchProfiles(query, pagination, options) {
|
|
72
78
|
try {
|
|
73
79
|
const response = await this.makeRequest('GET', '/profiles/search', (0, apiUtils_1.buildQueryParams)({ query, ...pagination }), {
|
|
74
80
|
cache: true,
|
|
75
81
|
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
82
|
+
signal: options?.signal,
|
|
76
83
|
});
|
|
77
84
|
if (typeof response !== 'object' ||
|
|
78
85
|
response === null ||
|
|
@@ -16,6 +16,7 @@ exports.withTimeout = withTimeout;
|
|
|
16
16
|
exports.withLoadingState = withLoadingState;
|
|
17
17
|
exports.retryOnError = retryOnError;
|
|
18
18
|
const logger_1 = require("../logger");
|
|
19
|
+
const errorUtils_1 = require("./errorUtils");
|
|
19
20
|
/**
|
|
20
21
|
* Wrapper for async operations with automatic error handling
|
|
21
22
|
* Returns null on error instead of throwing
|
|
@@ -67,29 +68,55 @@ function extractHttpStatus(error) {
|
|
|
67
68
|
return undefined;
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
|
-
*
|
|
71
|
+
* Whether an error came from a request hitting its own timeout.
|
|
71
72
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
}
|