@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/esm/HttpService.js
CHANGED
|
@@ -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
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
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,
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration proof-of-work — the CLIENT half of the anti-automation nonce
|
|
3
|
+
* `register()` (`mixins/OxyServices.auth.ts`) submits alongside the
|
|
4
|
+
* registration signature. The message format, difficulty and the full design
|
|
5
|
+
* rationale (why 16 bits, why sequential nonces are fine, what this is and
|
|
6
|
+
* isn't a defense against) live in `@oxy.so/protocol`'s
|
|
7
|
+
* `auth/registrationPow.ts` — this file is only the solve loop.
|
|
8
|
+
*/
|
|
9
|
+
import './polyfill.js';
|
|
10
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
11
|
+
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
|
|
12
|
+
import { meetsRegistrationPowDifficulty, registrationPowMessage, REGISTRATION_POW_DIFFICULTY_BITS, } from '@oxy.so/protocol';
|
|
13
|
+
/**
|
|
14
|
+
* How many attempts the grind loop runs before yielding to the event loop.
|
|
15
|
+
*
|
|
16
|
+
* A synchronous hot loop long enough to matter (an average solve at 16 bits
|
|
17
|
+
* is 65,536 attempts) would otherwise block the JS thread for its entire
|
|
18
|
+
* duration — on a mobile app that means no touch response, no animation, for
|
|
19
|
+
* up to roughly a second. Yielding periodically keeps the UI responsive
|
|
20
|
+
* during the grind at the cost of a handful of event-loop round trips, which
|
|
21
|
+
* is imperceptible next to the grind itself.
|
|
22
|
+
*/
|
|
23
|
+
const YIELD_EVERY_N_ATTEMPTS = 2000;
|
|
24
|
+
function yieldToEventLoop() {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Grind a nonce for `publicKey`/`timestamp` that clears
|
|
29
|
+
* {@link REGISTRATION_POW_DIFFICULTY_BITS}, returned as a decimal counter.
|
|
30
|
+
*
|
|
31
|
+
* Hashes with `@noble/hashes/sha256` — pure JS, synchronous, identical output
|
|
32
|
+
* on web/Node/React Native with no native module and no per-attempt bridge
|
|
33
|
+
* crossing (see the difficulty constant's own doc for why that distinction is
|
|
34
|
+
* the whole point). Nonces are sequential (0, 1, 2, …) rather than random: the
|
|
35
|
+
* candidate space a hash function spreads solutions across is dense either
|
|
36
|
+
* way, so a counter finds one exactly as reliably as randomness would, without
|
|
37
|
+
* needing a randomness source at all.
|
|
38
|
+
*/
|
|
39
|
+
export async function solveRegistrationPow(publicKey, timestamp) {
|
|
40
|
+
for (let nonce = 0;; nonce += 1) {
|
|
41
|
+
const candidate = String(nonce);
|
|
42
|
+
const digest = bytesToHex(sha256(utf8ToBytes(registrationPowMessage(publicKey, timestamp, candidate))));
|
|
43
|
+
if (meetsRegistrationPowDifficulty(digest, REGISTRATION_POW_DIFFICULTY_BITS)) {
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
if (nonce % YIELD_EVERY_N_ATTEMPTS === YIELD_EVERY_N_ATTEMPTS - 1) {
|
|
47
|
+
await yieldToEventLoop();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -2,6 +2,7 @@ import { loginResultSchema, safeParseContract } from '@oxy.so/contracts';
|
|
|
2
2
|
export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from '../utils/commonsApproval.js';
|
|
3
3
|
import { OxyAuthenticationError } from '../OxyServices.errors.js';
|
|
4
4
|
import { KeyManager } from '../crypto/keyManager.js';
|
|
5
|
+
import { solveRegistrationPow } from '../crypto/registrationPow.js';
|
|
5
6
|
import { SignatureService } from '../crypto/signatureService.js';
|
|
6
7
|
import { loadNodeCrypto } from '@oxy.so/protocol';
|
|
7
8
|
import { logger } from '../logger/index.js';
|
|
@@ -269,7 +270,7 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
269
270
|
if (typeof process === 'undefined' || !process.versions?.node)
|
|
270
271
|
return false;
|
|
271
272
|
try {
|
|
272
|
-
const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity');
|
|
273
|
+
const { canAttestWorkloadIdentity } = await import('../server/workloadIdentity.js');
|
|
273
274
|
return canAttestWorkloadIdentity();
|
|
274
275
|
}
|
|
275
276
|
catch {
|
|
@@ -297,7 +298,7 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
297
298
|
return entry.token;
|
|
298
299
|
if (entry?.pending)
|
|
299
300
|
return entry.pending;
|
|
300
|
-
const { requestWorkloadServiceToken } = await import('../server/workloadIdentity');
|
|
301
|
+
const { requestWorkloadServiceToken } = await import('../server/workloadIdentity.js');
|
|
301
302
|
const seeded = entry ?? {
|
|
302
303
|
token: '',
|
|
303
304
|
expiresAt: 0,
|
|
@@ -432,10 +433,15 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
432
433
|
*/
|
|
433
434
|
async register(publicKey, signature, timestamp) {
|
|
434
435
|
try {
|
|
436
|
+
// Advisory for now (server soft-enforces — see
|
|
437
|
+
// `SessionController.register`), but solved unconditionally so every
|
|
438
|
+
// client is already sending it once the server starts requiring it.
|
|
439
|
+
const powNonce = await solveRegistrationPow(publicKey, timestamp);
|
|
435
440
|
const res = await this.makeRequest('POST', '/auth/register', {
|
|
436
441
|
publicKey,
|
|
437
442
|
signature,
|
|
438
443
|
timestamp,
|
|
444
|
+
powNonce,
|
|
439
445
|
}, { cache: false, skipAuth: true });
|
|
440
446
|
if (!res || (typeof res === 'object' && Object.keys(res).length === 0)) {
|
|
441
447
|
throw new OxyAuthenticationError('Registration failed', 'REGISTER_FAILED', 400);
|
|
@@ -118,7 +118,7 @@ export function OxyServicesIdentityBackupMixin(Base) {
|
|
|
118
118
|
*
|
|
119
119
|
* NATIVE-ONLY persistence: `KeyManager.importKeyPair` throws on web. It also
|
|
120
120
|
* refuses to clobber a DIFFERENT existing on-device identity unless
|
|
121
|
-
* `overwrite: true` — the {@link import('../crypto/keyManager').IdentityAlreadyExistsError}
|
|
121
|
+
* `overwrite: true` — the {@link import('../crypto/keyManager.js').IdentityAlreadyExistsError}
|
|
122
122
|
* propagates to the caller (never swallowed) so the UI can confirm before
|
|
123
123
|
* overwriting.
|
|
124
124
|
*
|
|
@@ -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
|
-
*
|
|
57
|
+
* Whether an error came from a request hitting its own timeout.
|
|
57
58
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.)
|