@oxyhq/core 1.11.11 → 1.11.12
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 +13 -18
- package/dist/cjs/crypto/signatureService.js +11 -11
- package/dist/cjs/mixins/OxyServices.user.js +11 -0
- package/dist/cjs/utils/asyncUtils.js +34 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +13 -18
- package/dist/esm/crypto/keyManager.js +3 -3
- package/dist/esm/crypto/polyfill.js +1 -1
- package/dist/esm/crypto/signatureService.js +12 -12
- package/dist/esm/mixins/OxyServices.language.js +1 -1
- package/dist/esm/mixins/OxyServices.user.js +11 -0
- package/dist/esm/utils/asyncUtils.js +34 -5
- package/dist/esm/utils/deviceManager.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +12 -0
- package/dist/types/utils/asyncUtils.d.ts +6 -2
- package/package.json +1 -1
- package/src/HttpService.ts +13 -11
- package/src/crypto/keyManager.ts +3 -3
- package/src/crypto/polyfill.ts +1 -1
- package/src/crypto/signatureService.ts +13 -12
- package/src/mixins/OxyServices.language.ts +1 -1
- package/src/mixins/OxyServices.user.ts +18 -0
- package/src/utils/__tests__/asyncUtils.test.ts +187 -0
- package/src/utils/asyncUtils.ts +39 -9
- package/src/utils/deviceManager.ts +1 -1
|
@@ -10,6 +10,18 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
10
10
|
* Get profile by username
|
|
11
11
|
*/
|
|
12
12
|
getProfileByUsername(username: string): Promise<User>;
|
|
13
|
+
/**
|
|
14
|
+
* Lightweight username lookup for login flows.
|
|
15
|
+
* Returns minimal public info: exists, color, avatar, displayName.
|
|
16
|
+
* Faster than getProfileByUsername — no stats, no formatting.
|
|
17
|
+
*/
|
|
18
|
+
lookupUsername(username: string): Promise<{
|
|
19
|
+
exists: boolean;
|
|
20
|
+
username: string;
|
|
21
|
+
color: string | null;
|
|
22
|
+
avatar: string | null;
|
|
23
|
+
displayName: string;
|
|
24
|
+
}>;
|
|
13
25
|
/**
|
|
14
26
|
* Search user profiles
|
|
15
27
|
*/
|
|
@@ -13,8 +13,12 @@ export declare function parallelWithErrorHandling<T>(operations: (() => Promise<
|
|
|
13
13
|
/**
|
|
14
14
|
* Retry an async operation with exponential backoff
|
|
15
15
|
*
|
|
16
|
-
* By default, does not retry on 4xx errors (client errors).
|
|
17
|
-
*
|
|
16
|
+
* By default, does not retry on 4xx errors (client errors). The default
|
|
17
|
+
* predicate accepts both the axios-style `error.response.status` and the
|
|
18
|
+
* flat `error.status` shape produced by {@link handleHttpError}, so callers
|
|
19
|
+
* never accidentally retry a deterministic client failure.
|
|
20
|
+
*
|
|
21
|
+
* Use the `shouldRetry` callback to customize retry behavior.
|
|
18
22
|
*/
|
|
19
23
|
export declare function retryAsync<T>(operation: () => Promise<T>, maxRetries?: number, baseDelay?: number, shouldRetry?: (error: any) => boolean): Promise<T>;
|
|
20
24
|
/**
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -17,7 +17,6 @@ import { TTLCache, registerCacheForCleanup } from './utils/cache';
|
|
|
17
17
|
import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils';
|
|
18
18
|
import { retryAsync } from './utils/asyncUtils';
|
|
19
19
|
import { handleHttpError } from './utils/errorUtils';
|
|
20
|
-
import { isDev } from './shared/utils/debugUtils';
|
|
21
20
|
import { jwtDecode } from 'jwt-decode';
|
|
22
21
|
import { isNative, getPlatformOS } from './utils/platform';
|
|
23
22
|
import type { OxyConfig } from './models/interfaces';
|
|
@@ -281,9 +280,12 @@ export class HttpService {
|
|
|
281
280
|
headers['X-Native-App'] = 'true';
|
|
282
281
|
}
|
|
283
282
|
|
|
284
|
-
// Debug logging for CSRF issues
|
|
285
|
-
|
|
286
|
-
|
|
283
|
+
// Debug logging for CSRF issues — routed through the SimpleLogger so
|
|
284
|
+
// it only fires when consumers opt in via `enableLogging`. Previously
|
|
285
|
+
// this was a bare console.log that leaked noise into every host app's
|
|
286
|
+
// stdout in development.
|
|
287
|
+
if (isStateChangingMethod) {
|
|
288
|
+
this.logger.debug('CSRF Debug:', {
|
|
287
289
|
url,
|
|
288
290
|
method,
|
|
289
291
|
isNativeApp,
|
|
@@ -524,14 +526,14 @@ export class HttpService {
|
|
|
524
526
|
// Return cached token if available
|
|
525
527
|
const cachedToken = this.tokenStore.getCsrfToken();
|
|
526
528
|
if (cachedToken) {
|
|
527
|
-
|
|
529
|
+
this.logger.debug('Using cached CSRF token');
|
|
528
530
|
return cachedToken;
|
|
529
531
|
}
|
|
530
532
|
|
|
531
533
|
// Deduplicate concurrent CSRF token fetches
|
|
532
534
|
const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
|
|
533
535
|
if (existingPromise) {
|
|
534
|
-
|
|
536
|
+
this.logger.debug('Waiting for existing CSRF fetch');
|
|
535
537
|
return existingPromise;
|
|
536
538
|
}
|
|
537
539
|
|
|
@@ -539,7 +541,7 @@ export class HttpService {
|
|
|
539
541
|
const maxAttempts = 2;
|
|
540
542
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
541
543
|
try {
|
|
542
|
-
|
|
544
|
+
this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
|
|
543
545
|
|
|
544
546
|
// Use AbortController for timeout (more compatible than AbortSignal.timeout)
|
|
545
547
|
const controller = new AbortController();
|
|
@@ -554,11 +556,11 @@ export class HttpService {
|
|
|
554
556
|
|
|
555
557
|
clearTimeout(timeoutId);
|
|
556
558
|
|
|
557
|
-
|
|
559
|
+
this.logger.debug('CSRF fetch response:', response.status, response.ok);
|
|
558
560
|
|
|
559
561
|
if (response.ok) {
|
|
560
562
|
const data = await response.json() as { csrfToken?: string };
|
|
561
|
-
|
|
563
|
+
this.logger.debug('CSRF response data:', data);
|
|
562
564
|
const token = data.csrfToken || null;
|
|
563
565
|
this.tokenStore.setCsrfToken(token);
|
|
564
566
|
this.logger.debug('CSRF token fetched');
|
|
@@ -573,10 +575,10 @@ export class HttpService {
|
|
|
573
575
|
return headerToken;
|
|
574
576
|
}
|
|
575
577
|
|
|
576
|
-
|
|
578
|
+
this.logger.debug('CSRF fetch failed with status:', response.status);
|
|
577
579
|
this.logger.warn('Failed to fetch CSRF token:', response.status);
|
|
578
580
|
} catch (error) {
|
|
579
|
-
|
|
581
|
+
this.logger.debug('CSRF fetch error:', error);
|
|
580
582
|
this.logger.warn('CSRF token fetch error:', error);
|
|
581
583
|
}
|
|
582
584
|
// Wait before retry (500ms)
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -52,7 +52,7 @@ async function initSecureStore(): Promise<typeof import('expo-secure-store')> {
|
|
|
52
52
|
try {
|
|
53
53
|
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
54
54
|
const moduleName = 'expo-secure-store';
|
|
55
|
-
SecureStore = await import(moduleName);
|
|
55
|
+
SecureStore = await import(/* @vite-ignore */ moduleName);
|
|
56
56
|
} catch (error) {
|
|
57
57
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
58
58
|
throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
|
|
@@ -76,7 +76,7 @@ async function initExpoCrypto(): Promise<typeof import('expo-crypto')> {
|
|
|
76
76
|
if (!ExpoCrypto) {
|
|
77
77
|
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
78
78
|
const moduleName = 'expo-crypto';
|
|
79
|
-
ExpoCrypto = await import(moduleName);
|
|
79
|
+
ExpoCrypto = await import(/* @vite-ignore */ moduleName);
|
|
80
80
|
}
|
|
81
81
|
return ExpoCrypto!;
|
|
82
82
|
}
|
|
@@ -105,7 +105,7 @@ async function getSecureRandomBytes(length: number): Promise<Uint8Array> {
|
|
|
105
105
|
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
106
106
|
try {
|
|
107
107
|
const cryptoModuleName = 'crypto';
|
|
108
|
-
const nodeCrypto = await import(cryptoModuleName);
|
|
108
|
+
const nodeCrypto = await import(/* @vite-ignore */ cryptoModuleName);
|
|
109
109
|
return new Uint8Array(nodeCrypto.randomBytes(length));
|
|
110
110
|
} catch (error) {
|
|
111
111
|
// Fallback to expo-crypto if Node crypto fails
|
package/src/crypto/polyfill.ts
CHANGED
|
@@ -44,7 +44,7 @@ function startExpoCryptoLoad(): void {
|
|
|
44
44
|
expoCryptoLoadPromise = (async () => {
|
|
45
45
|
try {
|
|
46
46
|
const moduleName = 'expo-crypto';
|
|
47
|
-
expoCryptoModule = await import(moduleName);
|
|
47
|
+
expoCryptoModule = await import(/* @vite-ignore */ moduleName);
|
|
48
48
|
} catch {
|
|
49
49
|
// expo-crypto not available — expected in non-RN environments
|
|
50
50
|
}
|
|
@@ -9,23 +9,28 @@ import { ec as EC } from 'elliptic';
|
|
|
9
9
|
import { KeyManager } from './keyManager';
|
|
10
10
|
import { isReactNative, isNodeJS } from '../utils/platform';
|
|
11
11
|
|
|
12
|
-
// Lazy
|
|
12
|
+
// Lazy imports for platform-specific crypto
|
|
13
13
|
let ExpoCrypto: typeof import('expo-crypto') | null = null;
|
|
14
|
+
let NodeCrypto: typeof import('crypto') | null = null;
|
|
14
15
|
|
|
15
16
|
const ec = new EC('secp256k1');
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
* Initialize expo-crypto module
|
|
19
|
-
*/
|
|
20
18
|
async function initExpoCrypto(): Promise<typeof import('expo-crypto')> {
|
|
21
19
|
if (!ExpoCrypto) {
|
|
22
|
-
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
23
20
|
const moduleName = 'expo-crypto';
|
|
24
|
-
ExpoCrypto = await import(moduleName);
|
|
21
|
+
ExpoCrypto = await import(/* @vite-ignore */ moduleName);
|
|
25
22
|
}
|
|
26
23
|
return ExpoCrypto!;
|
|
27
24
|
}
|
|
28
25
|
|
|
26
|
+
async function initNodeCrypto(): Promise<typeof import('crypto')> {
|
|
27
|
+
if (!NodeCrypto) {
|
|
28
|
+
const moduleName = 'crypto';
|
|
29
|
+
NodeCrypto = await import(/* @vite-ignore */ moduleName);
|
|
30
|
+
}
|
|
31
|
+
return NodeCrypto!;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
/**
|
|
30
35
|
* Compute SHA-256 hash of a string
|
|
31
36
|
*/
|
|
@@ -39,10 +44,9 @@ async function sha256(message: string): Promise<string> {
|
|
|
39
44
|
);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
// In Node.js, use Node's crypto module
|
|
43
47
|
if (isNodeJS()) {
|
|
44
48
|
try {
|
|
45
|
-
const nodeCrypto = await
|
|
49
|
+
const nodeCrypto = await initNodeCrypto();
|
|
46
50
|
return nodeCrypto.createHash('sha256').update(message).digest('hex');
|
|
47
51
|
} catch {
|
|
48
52
|
// Fall through to Web Crypto API
|
|
@@ -85,12 +89,9 @@ export class SignatureService {
|
|
|
85
89
|
.join('');
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
// In Node.js, use Node's crypto module
|
|
89
|
-
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
90
92
|
if (isNodeJS()) {
|
|
91
93
|
try {
|
|
92
|
-
const
|
|
93
|
-
const nodeCrypto = await import(cryptoModuleName);
|
|
94
|
+
const nodeCrypto = await initNodeCrypto();
|
|
94
95
|
return nodeCrypto.randomBytes(32).toString('hex');
|
|
95
96
|
} catch {
|
|
96
97
|
// Fall through to Web Crypto API
|
|
@@ -25,7 +25,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
25
25
|
try {
|
|
26
26
|
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
27
27
|
const moduleName = '@react-native-async-storage/async-storage';
|
|
28
|
-
const asyncStorageModule = await import(moduleName);
|
|
28
|
+
const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
|
|
29
29
|
const storage = asyncStorageModule.default as unknown as { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; removeItem: (key: string) => Promise<void> };
|
|
30
30
|
return {
|
|
31
31
|
getItem: storage.getItem.bind(storage),
|
|
@@ -24,6 +24,24 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Lightweight username lookup for login flows.
|
|
29
|
+
* Returns minimal public info: exists, color, avatar, displayName.
|
|
30
|
+
* Faster than getProfileByUsername — no stats, no formatting.
|
|
31
|
+
*/
|
|
32
|
+
async lookupUsername(username: string): Promise<{
|
|
33
|
+
exists: boolean;
|
|
34
|
+
username: string;
|
|
35
|
+
color: string | null;
|
|
36
|
+
avatar: string | null;
|
|
37
|
+
displayName: string;
|
|
38
|
+
}> {
|
|
39
|
+
return await this.makeRequest('GET', `/auth/lookup/${encodeURIComponent(username)}`, undefined, {
|
|
40
|
+
cache: true,
|
|
41
|
+
cacheTTL: 60 * 1000, // 1 minute cache
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
/**
|
|
28
46
|
* Search user profiles
|
|
29
47
|
*/
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { retryAsync } from '../asyncUtils';
|
|
2
|
+
import { handleHttpError } from '../errorUtils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression coverage for the 1.11.11 retry storm:
|
|
6
|
+
*
|
|
7
|
+
* HttpService wraps fetch errors through handleHttpError before rethrowing.
|
|
8
|
+
* handleHttpError returns a flat ApiError ({ message, code, status }) without
|
|
9
|
+
* a nested `.response` field. Prior to the fix, retryAsync's default
|
|
10
|
+
* shouldRetry predicate only inspected `error.response.status`, so every 4xx
|
|
11
|
+
* response was treated as retryable. That turned ~5ms 404 lookups into 8-10s
|
|
12
|
+
* stalls because every Mention endpoint hitting Oxy for a missing
|
|
13
|
+
* user/topic hit the full retry+backoff schedule.
|
|
14
|
+
*
|
|
15
|
+
* These tests lock the fix in place: both the nested and flat shapes MUST
|
|
16
|
+
* short-circuit retries for 4xx, and 5xx/network errors MUST still retry.
|
|
17
|
+
*/
|
|
18
|
+
describe('retryAsync default shouldRetry predicate', () => {
|
|
19
|
+
it('does not retry on a flat ApiError-shaped 404 (handleHttpError output)', async () => {
|
|
20
|
+
let attempts = 0;
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
const apiError = { message: 'Not found', code: 'NOT_FOUND', status: 404 };
|
|
23
|
+
|
|
24
|
+
await expect(
|
|
25
|
+
retryAsync(async () => {
|
|
26
|
+
attempts++;
|
|
27
|
+
throw apiError;
|
|
28
|
+
}, 3, 50)
|
|
29
|
+
).rejects.toBe(apiError);
|
|
30
|
+
|
|
31
|
+
expect(attempts).toBe(1);
|
|
32
|
+
// Sanity: we should NOT have slept through any backoff windows.
|
|
33
|
+
expect(Date.now() - started).toBeLessThan(100);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does not retry on an axios-style nested 404 (response.status)', async () => {
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
const axiosError = {
|
|
39
|
+
message: 'Not found',
|
|
40
|
+
response: { status: 404, statusText: 'Not Found' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
retryAsync(async () => {
|
|
45
|
+
attempts++;
|
|
46
|
+
throw axiosError;
|
|
47
|
+
}, 3, 50)
|
|
48
|
+
).rejects.toBe(axiosError);
|
|
49
|
+
|
|
50
|
+
expect(attempts).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not retry on any 4xx flat-shape (400/401/403/422)', async () => {
|
|
54
|
+
for (const status of [400, 401, 403, 422]) {
|
|
55
|
+
let attempts = 0;
|
|
56
|
+
await expect(
|
|
57
|
+
retryAsync(async () => {
|
|
58
|
+
attempts++;
|
|
59
|
+
throw { message: 'client', code: 'X', status };
|
|
60
|
+
}, 2, 10)
|
|
61
|
+
).rejects.toBeDefined();
|
|
62
|
+
expect(attempts).toBe(1);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('retries on flat-shape 500 errors until maxRetries', async () => {
|
|
67
|
+
let attempts = 0;
|
|
68
|
+
await expect(
|
|
69
|
+
retryAsync(async () => {
|
|
70
|
+
attempts++;
|
|
71
|
+
throw { message: 'boom', code: 'INTERNAL_ERROR', status: 500 };
|
|
72
|
+
}, 2, 1)
|
|
73
|
+
).rejects.toBeDefined();
|
|
74
|
+
expect(attempts).toBe(3); // initial + 2 retries
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('retries on nested-shape 503 errors until maxRetries', async () => {
|
|
78
|
+
let attempts = 0;
|
|
79
|
+
await expect(
|
|
80
|
+
retryAsync(async () => {
|
|
81
|
+
attempts++;
|
|
82
|
+
throw { message: 'unavailable', response: { status: 503 } };
|
|
83
|
+
}, 2, 1)
|
|
84
|
+
).rejects.toBeDefined();
|
|
85
|
+
expect(attempts).toBe(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('retries on network-style errors without any status (TypeError)', async () => {
|
|
89
|
+
let attempts = 0;
|
|
90
|
+
await expect(
|
|
91
|
+
retryAsync(async () => {
|
|
92
|
+
attempts++;
|
|
93
|
+
throw new TypeError('Failed to fetch');
|
|
94
|
+
}, 2, 1)
|
|
95
|
+
).rejects.toBeDefined();
|
|
96
|
+
expect(attempts).toBe(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns the successful result without extra attempts', async () => {
|
|
100
|
+
let attempts = 0;
|
|
101
|
+
const result = await retryAsync(async () => {
|
|
102
|
+
attempts++;
|
|
103
|
+
return 'ok' as const;
|
|
104
|
+
}, 3, 1);
|
|
105
|
+
expect(result).toBe('ok');
|
|
106
|
+
expect(attempts).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('recovers after a transient 5xx followed by success', async () => {
|
|
110
|
+
let attempts = 0;
|
|
111
|
+
const result = await retryAsync(async () => {
|
|
112
|
+
attempts++;
|
|
113
|
+
if (attempts < 2) {
|
|
114
|
+
throw { message: 'transient', status: 502 };
|
|
115
|
+
}
|
|
116
|
+
return 'recovered' as const;
|
|
117
|
+
}, 3, 1);
|
|
118
|
+
expect(result).toBe('recovered');
|
|
119
|
+
expect(attempts).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('honours a custom shouldRetry predicate even when default would retry', async () => {
|
|
123
|
+
let attempts = 0;
|
|
124
|
+
await expect(
|
|
125
|
+
retryAsync(
|
|
126
|
+
async () => {
|
|
127
|
+
attempts++;
|
|
128
|
+
throw { message: 'nope', status: 500 };
|
|
129
|
+
},
|
|
130
|
+
5,
|
|
131
|
+
1,
|
|
132
|
+
() => false
|
|
133
|
+
)
|
|
134
|
+
).rejects.toBeDefined();
|
|
135
|
+
expect(attempts).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('ignores non-numeric status fields instead of treating them as 4xx', async () => {
|
|
139
|
+
let attempts = 0;
|
|
140
|
+
await expect(
|
|
141
|
+
retryAsync(async () => {
|
|
142
|
+
attempts++;
|
|
143
|
+
throw { message: 'weird', status: 'oops' as unknown as number };
|
|
144
|
+
}, 2, 1)
|
|
145
|
+
).rejects.toBeDefined();
|
|
146
|
+
// Non-numeric status must NOT be interpreted as 4xx — should retry normally.
|
|
147
|
+
expect(attempts).toBe(3);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* handleHttpError is the wire between fetch-thrown errors and retryAsync.
|
|
153
|
+
* Lock in that it exposes the HTTP status at the top level so the retry
|
|
154
|
+
* predicate above can see it.
|
|
155
|
+
*/
|
|
156
|
+
describe('handleHttpError preserves HTTP status for retry predicates', () => {
|
|
157
|
+
it('flattens a fetch-style error with .response.status into ApiError.status', () => {
|
|
158
|
+
const fetchError = Object.assign(new Error('Not found'), {
|
|
159
|
+
status: 404,
|
|
160
|
+
response: { status: 404, statusText: 'Not Found' },
|
|
161
|
+
});
|
|
162
|
+
const result = handleHttpError(fetchError);
|
|
163
|
+
expect(result.status).toBe(404);
|
|
164
|
+
expect(result.code).toBe('NOT_FOUND');
|
|
165
|
+
expect(result.message).toBe('Not found');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('preserves 401 status from fetch errors', () => {
|
|
169
|
+
const fetchError = Object.assign(new Error('Unauthorized'), {
|
|
170
|
+
status: 401,
|
|
171
|
+
response: { status: 401, statusText: 'Unauthorized' },
|
|
172
|
+
});
|
|
173
|
+
const result = handleHttpError(fetchError);
|
|
174
|
+
expect(result.status).toBe(401);
|
|
175
|
+
expect(result.code).toBe('UNAUTHORIZED');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('maps 500 to INTERNAL_ERROR with status preserved', () => {
|
|
179
|
+
const fetchError = Object.assign(new Error('boom'), {
|
|
180
|
+
status: 500,
|
|
181
|
+
response: { status: 500, statusText: 'Internal Server Error' },
|
|
182
|
+
});
|
|
183
|
+
const result = handleHttpError(fetchError);
|
|
184
|
+
expect(result.status).toBe(500);
|
|
185
|
+
expect(result.code).toBe('INTERNAL_ERROR');
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/utils/asyncUtils.ts
CHANGED
|
@@ -47,11 +47,38 @@ export async function parallelWithErrorHandling<T>(
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Extract an HTTP status code from an error value, tolerating both the
|
|
52
|
+
* axios-style nested shape (`error.response.status`) and the flat shape
|
|
53
|
+
* produced by {@link handleHttpError} / fetch-based clients (`error.status`).
|
|
54
|
+
*
|
|
55
|
+
* Centralising this lookup prevents retry predicates from silently falling
|
|
56
|
+
* through when one of the two shapes is missing, which previously caused
|
|
57
|
+
* @oxyhq/core to retry 4xx responses and turn sub-10ms failures into
|
|
58
|
+
* multi-second stalls for every missing-resource lookup.
|
|
59
|
+
*/
|
|
60
|
+
function extractHttpStatus(error: unknown): number | undefined {
|
|
61
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
62
|
+
const candidate = error as {
|
|
63
|
+
status?: unknown;
|
|
64
|
+
response?: { status?: unknown } | null;
|
|
65
|
+
};
|
|
66
|
+
const flat = candidate.status;
|
|
67
|
+
if (typeof flat === 'number' && Number.isFinite(flat)) return flat;
|
|
68
|
+
const nested = candidate.response?.status;
|
|
69
|
+
if (typeof nested === 'number' && Number.isFinite(nested)) return nested;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/**
|
|
51
74
|
* Retry an async operation with exponential backoff
|
|
52
|
-
*
|
|
53
|
-
* By default, does not retry on 4xx errors (client errors).
|
|
54
|
-
*
|
|
75
|
+
*
|
|
76
|
+
* By default, does not retry on 4xx errors (client errors). The default
|
|
77
|
+
* predicate accepts both the axios-style `error.response.status` and the
|
|
78
|
+
* flat `error.status` shape produced by {@link handleHttpError}, so callers
|
|
79
|
+
* never accidentally retry a deterministic client failure.
|
|
80
|
+
*
|
|
81
|
+
* Use the `shouldRetry` callback to customize retry behavior.
|
|
55
82
|
*/
|
|
56
83
|
export async function retryAsync<T>(
|
|
57
84
|
operation: () => Promise<T>,
|
|
@@ -60,16 +87,19 @@ export async function retryAsync<T>(
|
|
|
60
87
|
shouldRetry?: (error: any) => boolean
|
|
61
88
|
): Promise<T> {
|
|
62
89
|
let lastError: any;
|
|
63
|
-
|
|
64
|
-
// Default shouldRetry: don't retry on 4xx errors
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
|
|
91
|
+
// Default shouldRetry: don't retry on 4xx errors (client errors).
|
|
92
|
+
// Checks BOTH `error.status` (flat shape from handleHttpError / fetch
|
|
93
|
+
// clients) AND `error.response.status` (axios-style shape) so neither
|
|
94
|
+
// representation can leak a client error into the retry loop.
|
|
95
|
+
const defaultShouldRetry = (error: unknown): boolean => {
|
|
96
|
+
const status = extractHttpStatus(error);
|
|
97
|
+
if (status !== undefined && status >= 400 && status < 500) {
|
|
68
98
|
return false;
|
|
69
99
|
}
|
|
70
100
|
return true;
|
|
71
101
|
};
|
|
72
|
-
|
|
102
|
+
|
|
73
103
|
const retryCheck = shouldRetry || defaultShouldRetry;
|
|
74
104
|
|
|
75
105
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -44,7 +44,7 @@ export class DeviceManager {
|
|
|
44
44
|
try {
|
|
45
45
|
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
46
46
|
const moduleName = '@react-native-async-storage/async-storage';
|
|
47
|
-
const asyncStorageModule = await import(moduleName);
|
|
47
|
+
const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
|
|
48
48
|
const storage = asyncStorageModule.default as unknown as { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; removeItem: (key: string) => Promise<void> };
|
|
49
49
|
return {
|
|
50
50
|
getItem: storage.getItem.bind(storage),
|