@oxy.so/core 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- searchProfiles(query: string, pagination?: PaginationParams): Promise<SearchProfilesResponse>;
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 declare function retryAsync<T>(operation: () => Promise<T>, maxRetries?: number, baseDelay?: number, shouldRetry?: (error: any) => boolean): Promise<T>;
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>): 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>): 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -118,7 +118,7 @@
118
118
  "@oxy.so/telemetry": "^1.1.1",
119
119
  "@noble/ciphers": "^1.3.0",
120
120
  "@noble/hashes": "^1.8.0",
121
- "@oxy.so/contracts": "^1.2.0",
121
+ "@oxy.so/contracts": "^1.3.0",
122
122
  "@oxy.so/protocol": "^1.0.1",
123
123
  "@scure/bip39": "^1.6.0",
124
124
  "buffer": "^6.0.3",
@@ -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
- // Handle AbortError specifically for better error messages
792
- if (error instanceof Error && error.name === 'AbortError') {
793
- throw handleHttpError(error);
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, maxRetries, this.config.retryDelay || 1000)
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
- // Execute the request. Control-plane calls the auth lane depends on
812
- // (`bypassQueue`, e.g. the device-secret mint) run DIRECTLY — a queued mint
813
- // could never acquire a slot when every slot is parked awaiting it.
814
- const result = config.bypassQueue
815
- ? await finalRequest()
816
- : await this.requestQueue.enqueue(finalRequest);
817
-
818
- // Cache the result if caching is enabled
819
- if (cache && cacheKey && result) {
820
- this.cache.set(cacheKey, result, cacheTTL);
821
- this.warnIfCacheOversized();
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
- return result;
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
  /**