@oxy.so/core 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +146 -23
- 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/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/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 +1 -1
- package/src/HttpService.ts +181 -31
- package/src/__tests__/httpServiceAbort.test.ts +308 -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
|
@@ -36,6 +36,25 @@ export interface RequestOptions {
|
|
|
36
36
|
deduplicate?: boolean;
|
|
37
37
|
retry?: boolean;
|
|
38
38
|
maxRetries?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Retry an attempt that hit {@link timeout}. Default `false`.
|
|
41
|
+
*
|
|
42
|
+
* Off by default because a timeout usually means the SERVER is slow, so more
|
|
43
|
+
* attempts add load to the thing already struggling — and because retrying
|
|
44
|
+
* one behind a short per-attempt timeout is how a single slow endpoint used
|
|
45
|
+
* to cost ~28s of wall clock. Turn it on for an idempotent read where a
|
|
46
|
+
* retry is genuinely likely to help, and pair it with {@link deadline}.
|
|
47
|
+
*/
|
|
48
|
+
retryOnTimeout?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Wall-clock budget in ms for the WHOLE call, retries and backoff included.
|
|
51
|
+
*
|
|
52
|
+
* {@link timeout} bounds one attempt; this bounds the call. Without it the
|
|
53
|
+
* total is `attempts x timeout + backoff`, which is arithmetic nobody does
|
|
54
|
+
* at the call site — so a caller who wants "never more than 8 seconds" can
|
|
55
|
+
* say that instead of deriving it.
|
|
56
|
+
*/
|
|
57
|
+
deadline?: number;
|
|
39
58
|
timeout?: number;
|
|
40
59
|
signal?: AbortSignal;
|
|
41
60
|
headers?: Record<string, string>;
|
|
@@ -127,8 +127,16 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
127
127
|
}>;
|
|
128
128
|
/**
|
|
129
129
|
* Search user profiles
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
*
|
|
131
|
+
* Takes an `AbortSignal` because people search is typed into: without one
|
|
132
|
+
* every keystroke started a search that ran to completion and had its
|
|
133
|
+
* result discarded, while holding one of the request queue's slots. It was
|
|
134
|
+
* the only search lane in Mention that could not be cancelled, purely
|
|
135
|
+
* because this signature had no way to say so.
|
|
136
|
+
*/
|
|
137
|
+
searchProfiles(query: string, pagination?: PaginationParams, options?: {
|
|
138
|
+
signal?: AbortSignal;
|
|
139
|
+
}): Promise<SearchProfilesResponse>;
|
|
132
140
|
/**
|
|
133
141
|
* Resolve a fediverse handle to an Oxy user profile.
|
|
134
142
|
* Performs WebFinger discovery and returns the user, or null if not found.
|
|
@@ -20,7 +20,36 @@ export declare function parallelWithErrorHandling<T>(operations: (() => Promise<
|
|
|
20
20
|
*
|
|
21
21
|
* Use the `shouldRetry` callback to customize retry behavior.
|
|
22
22
|
*/
|
|
23
|
-
export
|
|
23
|
+
export interface RetryOptions {
|
|
24
|
+
maxRetries?: number;
|
|
25
|
+
baseDelay?: number;
|
|
26
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Retry a request that hit its own per-attempt timeout. Default `false`.
|
|
29
|
+
*
|
|
30
|
+
* Retrying a timeout is defensible for an idempotent GET and indefensible
|
|
31
|
+
* with the old defaults: a 5s per-attempt timeout with 3 retries and
|
|
32
|
+
* exponential backoff spends ~28s before reporting a failure, all of it after
|
|
33
|
+
* the caller stopped waiting, while holding a request-queue slot throughout.
|
|
34
|
+
* A timeout also usually means the SERVER is slow, so three more attempts add
|
|
35
|
+
* load to the thing already struggling.
|
|
36
|
+
*
|
|
37
|
+
* Opt in per call, and pair it with {@link deadline} so the total is bounded
|
|
38
|
+
* by wall clock rather than by attempts x timeout + backoff.
|
|
39
|
+
*/
|
|
40
|
+
retryOnTimeout?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Absolute wall-clock budget for ALL attempts, as an epoch milliseconds
|
|
43
|
+
* timestamp (`Date.now() + budget`). Once passed, no further attempt starts
|
|
44
|
+
* and the last error is thrown.
|
|
45
|
+
*
|
|
46
|
+
* This is what makes a pathological total unreachable BY CONSTRUCTION rather
|
|
47
|
+
* than by arithmetic on the other knobs: a deadline per call, a timeout per
|
|
48
|
+
* attempt, and jittered retries inside the deadline.
|
|
49
|
+
*/
|
|
50
|
+
deadline?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function retryAsync<T>(operation: () => Promise<T>, maxRetriesOrOptions?: number | RetryOptions, baseDelayArg?: number, shouldRetryArg?: (error: any) => boolean): Promise<T>;
|
|
24
53
|
/**
|
|
25
54
|
* Debounce async function calls
|
|
26
55
|
*/
|
|
@@ -22,6 +22,19 @@ export declare const ErrorCodes: {
|
|
|
22
22
|
readonly TIMEOUT: "TIMEOUT";
|
|
23
23
|
readonly NETWORK_ERROR: "NETWORK_ERROR";
|
|
24
24
|
readonly CONNECTION_FAILED: "CONNECTION_FAILED";
|
|
25
|
+
/**
|
|
26
|
+
* The CALLER cancelled this request — it did not fail.
|
|
27
|
+
*
|
|
28
|
+
* Distinct from {@link ErrorCodes.TIMEOUT} on purpose, and the distinction is
|
|
29
|
+
* load-bearing rather than cosmetic. Both arrive from `fetch` as an
|
|
30
|
+
* `AbortError` carrying no status, so for a long time both collapsed to
|
|
31
|
+
* `TIMEOUT` with `status: 0` — and since `status: 0` is not 4xx, the retry
|
|
32
|
+
* predicate treated a cancellation as a transient failure and re-issued the
|
|
33
|
+
* request the caller had just abandoned. A cancellation is an INSTRUCTION;
|
|
34
|
+
* nothing may retry it. See `retryAsync`'s default predicate, which refuses
|
|
35
|
+
* this code specifically.
|
|
36
|
+
*/
|
|
37
|
+
readonly CANCELLED: "CANCELLED";
|
|
25
38
|
};
|
|
26
39
|
/**
|
|
27
40
|
* The `Error` shape the SDK rejects with when an HTTP request fails.
|
|
@@ -94,6 +107,29 @@ export declare function parseHttpErrorBody(body: unknown): ParsedHttpErrorBody;
|
|
|
94
107
|
* Create a standardized API error
|
|
95
108
|
*/
|
|
96
109
|
export declare function createApiError(message: string, code?: string, status?: number, details?: Record<string, unknown>): ApiError;
|
|
110
|
+
/**
|
|
111
|
+
* The error a CALLER-cancelled request rejects with.
|
|
112
|
+
*
|
|
113
|
+
* Carries both identities on purpose, because consumers check for
|
|
114
|
+
* cancellation two different ways and both are legitimate:
|
|
115
|
+
*
|
|
116
|
+
* - `name: 'AbortError'` is what every `fetch`-era consumer tests, and what
|
|
117
|
+
* React Query and friends recognise. Dropping it would silently turn
|
|
118
|
+
* cancellations into errors in every app that already handles aborts.
|
|
119
|
+
* - `code: ErrorCodes.CANCELLED` / `cancelled: true` is what code inside this
|
|
120
|
+
* SDK tests, because `handleHttpError` returns PLAIN objects (not `Error`s)
|
|
121
|
+
* for most failures, so `name` is not a reliable discriminator across the
|
|
122
|
+
* whole surface.
|
|
123
|
+
*
|
|
124
|
+
* It is an `Error` rather than a bare `ApiError` object so a stack survives and
|
|
125
|
+
* `instanceof Error` holds — the shape the abort path always looked like it
|
|
126
|
+
* produced, and never did.
|
|
127
|
+
*/
|
|
128
|
+
export type CancelledError = Error & ApiError & {
|
|
129
|
+
cancelled: true;
|
|
130
|
+
};
|
|
131
|
+
export declare function createCancelledError(message?: string): CancelledError;
|
|
132
|
+
export declare function isCancelledError(error: unknown): boolean;
|
|
97
133
|
/**
|
|
98
134
|
* Handle common HTTP errors and convert to ApiError
|
|
99
135
|
*/
|
|
@@ -27,7 +27,7 @@ export declare class RequestDeduplicator {
|
|
|
27
27
|
* @param requestFn Function that returns a promise
|
|
28
28
|
* @returns Promise that will be shared if key already exists
|
|
29
29
|
*/
|
|
30
|
-
deduplicate<T>(key: string, requestFn: () => Promise<T
|
|
30
|
+
deduplicate<T>(key: string, requestFn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
31
31
|
/**
|
|
32
32
|
* Clear all pending requests
|
|
33
33
|
*/
|
|
@@ -69,7 +69,7 @@ export declare class RequestQueue {
|
|
|
69
69
|
* @param requestFn Function that returns a promise
|
|
70
70
|
* @returns Promise that resolves when request completes
|
|
71
71
|
*/
|
|
72
|
-
enqueue<T>(requestFn: () => Promise<T
|
|
72
|
+
enqueue<T>(requestFn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
73
73
|
/**
|
|
74
74
|
* Process queued requests
|
|
75
75
|
*/
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { TTLCache, registerCacheForCleanup } from './utils/cache';
|
|
17
17
|
import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils';
|
|
18
18
|
import { retryAsync } from './utils/asyncUtils';
|
|
19
|
-
import { handleHttpError, parseHttpErrorBody } from './utils/errorUtils';
|
|
19
|
+
import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody } from './utils/errorUtils';
|
|
20
20
|
import { jwtDecode } from 'jwt-decode';
|
|
21
21
|
import { isNative, getPlatformOS } from './utils/platform';
|
|
22
22
|
import { isReactNative } from '@oxy.so/protocol';
|
|
@@ -85,6 +85,25 @@ export interface RequestOptions {
|
|
|
85
85
|
deduplicate?: boolean;
|
|
86
86
|
retry?: boolean;
|
|
87
87
|
maxRetries?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Retry an attempt that hit {@link timeout}. Default `false`.
|
|
90
|
+
*
|
|
91
|
+
* Off by default because a timeout usually means the SERVER is slow, so more
|
|
92
|
+
* attempts add load to the thing already struggling — and because retrying
|
|
93
|
+
* one behind a short per-attempt timeout is how a single slow endpoint used
|
|
94
|
+
* to cost ~28s of wall clock. Turn it on for an idempotent read where a
|
|
95
|
+
* retry is genuinely likely to help, and pair it with {@link deadline}.
|
|
96
|
+
*/
|
|
97
|
+
retryOnTimeout?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Wall-clock budget in ms for the WHOLE call, retries and backoff included.
|
|
100
|
+
*
|
|
101
|
+
* {@link timeout} bounds one attempt; this bounds the call. Without it the
|
|
102
|
+
* total is `attempts x timeout + backoff`, which is arithmetic nobody does
|
|
103
|
+
* at the call site — so a caller who wants "never more than 8 seconds" can
|
|
104
|
+
* say that instead of deriving it.
|
|
105
|
+
*/
|
|
106
|
+
deadline?: number;
|
|
88
107
|
timeout?: number;
|
|
89
108
|
signal?: AbortSignal;
|
|
90
109
|
headers?: Record<string, string>;
|
|
@@ -288,9 +307,48 @@ class TokenStore {
|
|
|
288
307
|
}
|
|
289
308
|
}
|
|
290
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Propagate `parent`'s abort to `child`, returning a disposer.
|
|
312
|
+
*
|
|
313
|
+
* Three things a bare `parent.addEventListener('abort', ...)` gets wrong, all
|
|
314
|
+
* of which this exists to stop repeating:
|
|
315
|
+
*
|
|
316
|
+
* 1. **An already-aborted parent never fires again.** `abort` is a once-only
|
|
317
|
+
* event, so linking to a signal that has already fired silently links to
|
|
318
|
+
* nothing — which is how a cancelled request got re-issued on every retry.
|
|
319
|
+
* The `aborted` pre-check is the whole point.
|
|
320
|
+
* 2. **The listener outlives the request.** A long-lived caller signal (one
|
|
321
|
+
* React Query owns for a query's lifetime, say) accumulates one listener per
|
|
322
|
+
* attempt per request with nothing ever removing them.
|
|
323
|
+
* 3. **The reason is dropped**, so the child cannot tell WHY it was aborted.
|
|
324
|
+
*
|
|
325
|
+
* `AbortSignal.any` does all this natively and is not used: Hermes support is
|
|
326
|
+
* not safe to assume, and this SDK ships to React Native. The pattern is the
|
|
327
|
+
* one `uploadViaXHR` already had right; this is that code, shared.
|
|
328
|
+
*/
|
|
329
|
+
function isAbortLike(error: unknown): boolean {
|
|
330
|
+
return (
|
|
331
|
+
typeof error === 'object' &&
|
|
332
|
+
error !== null &&
|
|
333
|
+
(error as { name?: unknown }).name === 'AbortError'
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function linkAbort(parent: AbortSignal, child: AbortController): () => void {
|
|
338
|
+
if (parent.aborted) {
|
|
339
|
+
child.abort((parent as AbortSignal & { reason?: unknown }).reason);
|
|
340
|
+
return () => { /* nothing was ever attached */ };
|
|
341
|
+
}
|
|
342
|
+
const onAbort = (): void => {
|
|
343
|
+
child.abort((parent as AbortSignal & { reason?: unknown }).reason);
|
|
344
|
+
};
|
|
345
|
+
parent.addEventListener('abort', onAbort, { once: true });
|
|
346
|
+
return () => parent.removeEventListener('abort', onAbort);
|
|
347
|
+
}
|
|
348
|
+
|
|
291
349
|
/**
|
|
292
350
|
* Unified HTTP Service
|
|
293
|
-
*
|
|
351
|
+
*
|
|
294
352
|
* Consolidates HttpClient + RequestManager into a single efficient class.
|
|
295
353
|
* Uses native fetch instead of axios for smaller bundle size.
|
|
296
354
|
*/
|
|
@@ -543,9 +601,58 @@ export class HttpService {
|
|
|
543
601
|
const edgeRegionHeader = await getBrowserEdgeRegionHeader();
|
|
544
602
|
const activityIdHeader = getBrowserActivityIdHeader();
|
|
545
603
|
|
|
604
|
+
// A request the caller has ALREADY abandoned takes no queue slot and makes
|
|
605
|
+
// no network call. Checked here, before enqueue, because a slot is the
|
|
606
|
+
// scarce resource: ten of them, and a dead request holding one is a live
|
|
607
|
+
// request waiting for nothing.
|
|
608
|
+
if (signal?.aborted) {
|
|
609
|
+
throw createCancelledError('Request cancelled before it was sent');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ONE abort domain for the whole call, linked to the caller's signal ONCE.
|
|
613
|
+
//
|
|
614
|
+
// The bug this replaces: the controller used to be built INSIDE `requestFn`
|
|
615
|
+
// — the function `retryAsync` re-invokes — and the caller's signal was
|
|
616
|
+
// linked to each new controller with a bare `addEventListener`. An `abort`
|
|
617
|
+
// event fires once, so on attempt 2 the caller's already-fired signal could
|
|
618
|
+
// not abort the freshly built controller, and the request the caller had
|
|
619
|
+
// cancelled went out for real. Hoisting the linkage means a cancellation
|
|
620
|
+
// arrives once and is then true for every attempt, including ones not yet
|
|
621
|
+
// started.
|
|
622
|
+
const callController = new AbortController();
|
|
623
|
+
const disposeCallerLink = signal ? linkAbort(signal, callController) : undefined;
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Which of our own timers fired, so a timeout can be told apart from a
|
|
627
|
+
* cancellation. Both reach us from `fetch` as an indistinguishable
|
|
628
|
+
* `AbortError`, and collapsing them (as this used to) is what let the retry
|
|
629
|
+
* predicate treat an abandoned request as a transient failure.
|
|
630
|
+
*/
|
|
631
|
+
let timedOut = false;
|
|
632
|
+
|
|
546
633
|
// Request function
|
|
547
634
|
const requestFn = async (): Promise<T> => {
|
|
548
635
|
const startTime = Date.now();
|
|
636
|
+
// Re-checked per attempt so no retry can issue a network call after the
|
|
637
|
+
// caller gave up — belt to `retryAsync`'s braces, and the one that holds
|
|
638
|
+
// if a custom `shouldRetry` ever says yes to a cancellation.
|
|
639
|
+
if (callController.signal.aborted) {
|
|
640
|
+
throw createCancelledError('Request cancelled');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// This attempt's controller carries its own timeout and inherits the
|
|
644
|
+
// call-wide abort domain. Declared OUTSIDE the `try` so the `finally`
|
|
645
|
+
// that releases them can see them — the timer and the listener are
|
|
646
|
+
// exactly what leaked when they lived inside it.
|
|
647
|
+
const controller = new AbortController();
|
|
648
|
+
const disposeCallLink = linkAbort(callController.signal, controller);
|
|
649
|
+
const timeoutId = timeout
|
|
650
|
+
? setTimeout(() => {
|
|
651
|
+
timedOut = true;
|
|
652
|
+
controller.abort();
|
|
653
|
+
}, timeout)
|
|
654
|
+
: null;
|
|
655
|
+
|
|
549
656
|
try {
|
|
550
657
|
// Build URL with params
|
|
551
658
|
const fullUrl = this.buildURL(url, params);
|
|
@@ -554,14 +661,6 @@ export class HttpService {
|
|
|
554
661
|
const isFormData = this.isFormData(data);
|
|
555
662
|
const isUrlEncoded = this.isUrlSearchParams(data);
|
|
556
663
|
|
|
557
|
-
// Make fetch request
|
|
558
|
-
const controller = new AbortController();
|
|
559
|
-
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
|
|
560
|
-
|
|
561
|
-
if (signal) {
|
|
562
|
-
signal.addEventListener('abort', () => controller.abort());
|
|
563
|
-
}
|
|
564
|
-
|
|
565
664
|
// Build headers - start with defaults
|
|
566
665
|
const headers: Record<string, string> = {
|
|
567
666
|
'Accept': 'application/json',
|
|
@@ -657,6 +756,8 @@ export class HttpService {
|
|
|
657
756
|
credentials: this.getCredentialsMode(fullUrl),
|
|
658
757
|
});
|
|
659
758
|
|
|
759
|
+
// Cleared as early as possible; the `finally` below is what GUARANTEES
|
|
760
|
+
// it, including on every throw between here and there.
|
|
660
761
|
if (timeoutId) clearTimeout(timeoutId);
|
|
661
762
|
|
|
662
763
|
// Handle response
|
|
@@ -787,41 +888,90 @@ export class HttpService {
|
|
|
787
888
|
this.updateMetrics(false, duration);
|
|
788
889
|
this.config.onRequestEnd?.(url, method, duration, false);
|
|
789
890
|
this.config.onRequestError?.(url, method, error instanceof Error ? error : new Error(String(error)));
|
|
790
|
-
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
891
|
+
|
|
892
|
+
// An abort is the one failure whose CAUSE the error cannot carry on its
|
|
893
|
+
// own: `fetch` reports a caller cancellation and our own timeout as the
|
|
894
|
+
// same `AbortError`. `timedOut` is set by the timer callback, so it is
|
|
895
|
+
// the only thing here that knows which happened — and getting this
|
|
896
|
+
// wrong is not cosmetic. Both used to collapse to `TIMEOUT` with
|
|
897
|
+
// `status: 0`, and because `status: 0` is not 4xx the retry predicate
|
|
898
|
+
// read every cancellation as a transient failure worth another try.
|
|
899
|
+
// Tested by NAME, not `instanceof Error`. `fetch` rejects an abort with a
|
|
900
|
+
// `DOMException`, and `instanceof` is realm-bound: the exception can be
|
|
901
|
+
// constructed in a different realm from the `Error` this module closes
|
|
902
|
+
// over (a Jest VM context, a worker, an iframe), in which case the check
|
|
903
|
+
// silently fails and the abort is misreported as a generic 500. That was
|
|
904
|
+
// happening; a structural check cannot be fooled that way.
|
|
905
|
+
if (isAbortLike(error)) {
|
|
906
|
+
if (timedOut) {
|
|
907
|
+
const timeoutError = new Error(`Request timed out after ${timeout}ms`) as Error & {
|
|
908
|
+
status?: number;
|
|
909
|
+
code?: string;
|
|
910
|
+
timeout?: boolean;
|
|
911
|
+
};
|
|
912
|
+
timeoutError.status = 0;
|
|
913
|
+
timeoutError.code = ErrorCodes.TIMEOUT;
|
|
914
|
+
timeoutError.timeout = true;
|
|
915
|
+
throw timeoutError;
|
|
916
|
+
}
|
|
917
|
+
throw createCancelledError('Request cancelled');
|
|
794
918
|
}
|
|
795
|
-
|
|
919
|
+
|
|
796
920
|
throw handleHttpError(error);
|
|
921
|
+
} finally {
|
|
922
|
+
// Both of these leaked before: `clearTimeout` sat on the success path
|
|
923
|
+
// only, so any throw between the fetch and the return (a 404, a parse
|
|
924
|
+
// failure) left the timer armed; and nothing ever removed the abort
|
|
925
|
+
// listener, so a long-lived caller signal collected one per attempt.
|
|
926
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
927
|
+
disposeCallLink();
|
|
797
928
|
}
|
|
798
929
|
};
|
|
799
930
|
|
|
800
|
-
// Wrap with retry if enabled
|
|
931
|
+
// Wrap with retry if enabled.
|
|
932
|
+
//
|
|
933
|
+
// `retryOnTimeout` defaults to false in `retryAsync`, which is what makes
|
|
934
|
+
// the pathological total unreachable: a timeout now costs ONE attempt
|
|
935
|
+
// instead of four-plus-backoff (~28s at the 5s default), and a cancellation
|
|
936
|
+
// costs none. `deadline` bounds whatever retries do happen by wall clock
|
|
937
|
+
// rather than by attempts x timeout + backoff.
|
|
801
938
|
const requestWithRetry = retry
|
|
802
|
-
? () => retryAsync(requestFn,
|
|
939
|
+
? () => retryAsync(requestFn, {
|
|
940
|
+
maxRetries,
|
|
941
|
+
baseDelay: this.config.retryDelay || 1000,
|
|
942
|
+
retryOnTimeout: config.retryOnTimeout ?? false,
|
|
943
|
+
deadline: config.deadline !== undefined ? Date.now() + config.deadline : undefined,
|
|
944
|
+
})
|
|
803
945
|
: requestFn;
|
|
804
946
|
|
|
805
947
|
// Wrap with deduplication if enabled (use optimized key generation)
|
|
806
948
|
const dedupeKey = deduplicate ? this.generateCacheKey(method, url, data || params) : null;
|
|
807
949
|
const finalRequest = dedupeKey
|
|
808
|
-
? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry)
|
|
950
|
+
? () => this.deduplicator.deduplicate(dedupeKey, requestWithRetry, callController.signal)
|
|
809
951
|
: requestWithRetry;
|
|
810
952
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
953
|
+
try {
|
|
954
|
+
// Execute the request. Control-plane calls the auth lane depends on
|
|
955
|
+
// (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
|
|
956
|
+
// could never acquire a slot when every slot is parked awaiting it.
|
|
957
|
+
const result = config.bypassQueue
|
|
958
|
+
? await finalRequest()
|
|
959
|
+
: await this.requestQueue.enqueue(finalRequest, callController.signal);
|
|
960
|
+
|
|
961
|
+
// Cache the result if caching is enabled
|
|
962
|
+
if (cache && cacheKey && result) {
|
|
963
|
+
this.cache.set(cacheKey, result, cacheTTL);
|
|
964
|
+
this.warnIfCacheOversized();
|
|
965
|
+
}
|
|
823
966
|
|
|
824
|
-
|
|
967
|
+
return result;
|
|
968
|
+
} finally {
|
|
969
|
+
// The call-wide link to the CALLER's signal, released once the call is
|
|
970
|
+
// over however it ended. Without this a caller signal that outlives the
|
|
971
|
+
// request — the normal case for a React Query query signal — accrues one
|
|
972
|
+
// listener per request for its whole lifetime.
|
|
973
|
+
disposeCallerLink?.();
|
|
974
|
+
}
|
|
825
975
|
}
|
|
826
976
|
|
|
827
977
|
/**
|