react-native-ai-hooks 0.3.0 → 0.5.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.
Files changed (37) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/CONTRIBUTING.md +122 -0
  3. package/README.md +73 -20
  4. package/docs/ARCHITECTURE.md +301 -0
  5. package/docs/ARCHITECTURE_GUIDE.md +467 -0
  6. package/docs/IMPLEMENTATION_COMPLETE.md +349 -0
  7. package/docs/README.md +17 -0
  8. package/docs/TECHNICAL_SPECIFICATION.md +748 -0
  9. package/example/App.tsx +95 -0
  10. package/example/README.md +27 -0
  11. package/example/index.js +5 -0
  12. package/example/package.json +22 -0
  13. package/example/src/components/ProviderPicker.tsx +62 -0
  14. package/example/src/context/APIKeysContext.tsx +96 -0
  15. package/example/src/screens/ChatScreen.tsx +205 -0
  16. package/example/src/screens/SettingsScreen.tsx +124 -0
  17. package/example/tsconfig.json +7 -0
  18. package/jest.config.cjs +7 -0
  19. package/jest.setup.ts +28 -0
  20. package/package.json +17 -3
  21. package/src/hooks/__tests__/useAIForm.test.ts +345 -0
  22. package/src/hooks/__tests__/useAIStream.test.ts +427 -0
  23. package/src/hooks/useAIChat.ts +111 -51
  24. package/src/hooks/useAICode.ts +8 -0
  25. package/src/hooks/useAIForm.ts +92 -202
  26. package/src/hooks/useAIStream.ts +114 -58
  27. package/src/hooks/useAISummarize.ts +8 -0
  28. package/src/hooks/useAITranslate.ts +9 -0
  29. package/src/hooks/useAIVoice.ts +8 -0
  30. package/src/hooks/useImageAnalysis.ts +134 -79
  31. package/src/index.ts +25 -1
  32. package/src/types/index.ts +178 -4
  33. package/src/utils/__tests__/fetchWithRetry.test.ts +168 -0
  34. package/src/utils/__tests__/providerFactory.test.ts +493 -0
  35. package/src/utils/fetchWithRetry.ts +100 -0
  36. package/src/utils/index.ts +8 -0
  37. package/src/utils/providerFactory.ts +288 -0
@@ -1,47 +1,20 @@
1
- import { useCallback, useState } from 'react';
2
-
3
- interface UseImageAnalysisOptions {
4
- apiKey: string;
5
- model?: string;
6
- maxTokens?: number;
7
- system?: string;
8
- uriToBase64?: (uri: string) => Promise<string>;
9
- }
10
-
11
- interface AnalyzeImageInput {
12
- image: string;
13
- mediaType?: string;
14
- prompt?: string;
15
- }
1
+ import { useCallback, useState, useRef, useMemo } from 'react';
2
+ import type { UseImageAnalysisOptions, UseImageAnalysisReturn } from '../types';
3
+ import { createProvider } from '../utils/providerFactory';
16
4
 
17
- interface UseImageAnalysisReturn {
18
- description: string;
19
- isLoading: boolean;
20
- error: string | null;
21
- analyzeImage: (input: AnalyzeImageInput) => Promise<string | null>;
22
- clearDescription: () => void;
23
- }
24
-
25
- interface ParsedImageData {
26
- base64: string;
27
- mediaType: string;
28
- }
5
+ const DEFAULT_MODEL_MAP = {
6
+ anthropic: 'claude-sonnet-4-20250514',
7
+ openai: 'gpt-4-vision',
8
+ gemini: 'gemini-pro-vision',
9
+ };
29
10
 
30
11
  const DATA_URI_REGEX = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/;
31
12
 
32
13
  function getMediaTypeFromUri(uri: string): string {
33
14
  const normalized = uri.toLowerCase();
34
-
35
- if (normalized.includes('.png')) {
36
- return 'image/png';
37
- }
38
- if (normalized.includes('.webp')) {
39
- return 'image/webp';
40
- }
41
- if (normalized.includes('.gif')) {
42
- return 'image/gif';
43
- }
44
-
15
+ if (normalized.includes('.png')) return 'image/png';
16
+ if (normalized.includes('.webp')) return 'image/webp';
17
+ if (normalized.includes('.gif')) return 'image/gif';
45
18
  return 'image/jpeg';
46
19
  }
47
20
 
@@ -56,7 +29,7 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
56
29
  }
57
30
 
58
31
  if (typeof btoa !== 'function') {
59
- throw new Error('Base64 conversion is unavailable. Provide uriToBase64 in hook options.');
32
+ throw new Error('Base64 conversion unavailable. Provide uriToBase64 in hook options.');
60
33
  }
61
34
 
62
35
  return btoa(binary);
@@ -72,26 +45,44 @@ async function defaultUriToBase64(uri: string): Promise<string> {
72
45
  return arrayBufferToBase64(imageBuffer);
73
46
  }
74
47
 
48
+ /**
49
+ * Analyzes an image with a vision-capable model and stores the generated description.
50
+ *
51
+ * @param options Vision configuration including provider credentials, model selection,
52
+ * request limits, and optional URI-to-base64 conversion for React Native environments.
53
+ * @returns Image analysis state with the latest description, loading/error flags, and
54
+ * actions to analyze a new image or clear the stored description.
55
+ */
75
56
  export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnalysisReturn {
76
57
  const [description, setDescription] = useState('');
77
58
  const [isLoading, setIsLoading] = useState(false);
78
59
  const [error, setError] = useState<string | null>(null);
79
60
 
61
+ const isMountedRef = useRef(true);
62
+
63
+ const providerConfig = useMemo(
64
+ () => ({
65
+ provider: (options.provider || 'anthropic') as 'anthropic' | 'openai' | 'gemini',
66
+ apiKey: options.apiKey,
67
+ model: options.model || DEFAULT_MODEL_MAP[options.provider || 'anthropic'],
68
+ baseUrl: options.baseUrl,
69
+ timeout: options.timeout,
70
+ maxRetries: options.maxRetries,
71
+ }),
72
+ [options],
73
+ );
74
+
80
75
  const normalizeImage = useCallback(
81
- async (image: string, mediaType?: string): Promise<ParsedImageData> => {
76
+ async (image: string, mediaType?: string): Promise<{ base64: string; mediaType: string }> => {
82
77
  const dataUriMatch = image.match(DATA_URI_REGEX);
83
78
  if (dataUriMatch) {
84
- const matchedMediaType = dataUriMatch[1];
85
- const base64Data = dataUriMatch[2];
86
79
  return {
87
- base64: base64Data,
88
- mediaType: mediaType || matchedMediaType,
80
+ base64: dataUriMatch[2],
81
+ mediaType: mediaType || dataUriMatch[1],
89
82
  };
90
83
  }
91
84
 
92
- const isLikelyUri = /^(https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|assets-library:\/\/)/i.test(
93
- image,
94
- );
85
+ const isLikelyUri = /^(https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|assets-library:\/\/)/i.test(image);
95
86
 
96
87
  if (isLikelyUri) {
97
88
  const toBase64 = options.uriToBase64 || defaultUriToBase64;
@@ -116,24 +107,39 @@ export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnal
116
107
  }, []);
117
108
 
118
109
  const analyzeImage = useCallback(
119
- async (input: AnalyzeImageInput) => {
120
- setIsLoading(true);
110
+ async (uri: string, prompt?: string) => {
111
+ if (!uri) {
112
+ setError('Image URI is required');
113
+ return null;
114
+ }
115
+
116
+ if (!options.apiKey) {
117
+ setError('Missing API key');
118
+ return null;
119
+ }
120
+
121
121
  setError(null);
122
+ setIsLoading(true);
122
123
 
123
124
  try {
124
- const parsedImage = await normalizeImage(input.image, input.mediaType);
125
+ const imagData = await normalizeImage(uri);
126
+ const analysisPrompt = prompt || 'Describe this image in detail.';
125
127
 
126
- const apiResponse = await fetch('https://api.anthropic.com/v1/messages', {
127
- method: 'POST',
128
- headers: {
129
- 'Content-Type': 'application/json',
130
- 'x-api-key': options.apiKey,
131
- 'anthropic-version': '2023-06-01',
132
- },
133
- body: JSON.stringify({
134
- model: options.model || 'claude-sonnet-4-20250514',
128
+ const base_url = providerConfig.baseUrl || 'https://api.anthropic.com';
129
+ const headers: Record<string, string> = {
130
+ 'Content-Type': 'application/json',
131
+ };
132
+
133
+ let url = '';
134
+ let body: Record<string, unknown> = {};
135
+
136
+ if (providerConfig.provider === 'anthropic') {
137
+ url = `${base_url}/v1/messages`;
138
+ headers['x-api-key'] = options.apiKey;
139
+ headers['anthropic-version'] = '2023-06-01';
140
+ body = {
141
+ model: providerConfig.model,
135
142
  max_tokens: options.maxTokens ?? 1024,
136
- system: options.system,
137
143
  messages: [
138
144
  {
139
145
  role: 'user',
@@ -142,47 +148,96 @@ export function useImageAnalysis(options: UseImageAnalysisOptions): UseImageAnal
142
148
  type: 'image',
143
149
  source: {
144
150
  type: 'base64',
145
- media_type: parsedImage.mediaType,
146
- data: parsedImage.base64,
151
+ media_type: imagData.mediaType,
152
+ data: imagData.base64,
147
153
  },
148
154
  },
149
155
  {
150
156
  type: 'text',
151
- text: input.prompt || 'Describe this image in detail.',
157
+ text: analysisPrompt,
152
158
  },
153
159
  ],
154
160
  },
155
161
  ],
156
- }),
162
+ };
163
+ } else if (providerConfig.provider === 'openai') {
164
+ url = `${providerConfig.baseUrl || 'https://api.openai.com'}/v1/chat/completions`;
165
+ headers.Authorization = `Bearer ${options.apiKey}`;
166
+ body = {
167
+ model: providerConfig.model,
168
+ max_tokens: options.maxTokens ?? 1024,
169
+ messages: [
170
+ {
171
+ role: 'user',
172
+ content: [
173
+ {
174
+ type: 'image_url',
175
+ image_url: {
176
+ url: `data:${imagData.mediaType};base64,${imagData.base64}`,
177
+ },
178
+ },
179
+ {
180
+ type: 'text',
181
+ text: analysisPrompt,
182
+ },
183
+ ],
184
+ },
185
+ ],
186
+ };
187
+ } else {
188
+ throw new Error(`Image analysis not supported for provider: ${providerConfig.provider}`);
189
+ }
190
+
191
+ const response = await fetch(url, {
192
+ method: 'POST',
193
+ headers,
194
+ body: JSON.stringify(body),
157
195
  });
158
196
 
159
- if (!apiResponse.ok) {
160
- const errorText = await apiResponse.text();
161
- throw new Error(errorText || `Claude API error: ${apiResponse.status}`);
197
+ if (!response.ok) {
198
+ const errorText = await response.text();
199
+ throw new Error(errorText || `API error: ${response.status}`);
162
200
  }
163
201
 
164
- const data = await apiResponse.json();
165
- const textResult = data?.content?.find?.(
166
- (item: { type?: string; text?: string }) => item?.type === 'text',
167
- )?.text;
202
+ const data = await response.json();
203
+ let resultText = '';
204
+
205
+ if (providerConfig.provider === 'anthropic') {
206
+ resultText = data?.content?.[0]?.text || '';
207
+ } else if (providerConfig.provider === 'openai') {
208
+ resultText = data?.choices?.[0]?.message?.content || '';
209
+ }
168
210
 
169
- if (!textResult) {
170
- throw new Error('No description returned from Claude vision API.');
211
+ if (!resultText) {
212
+ throw new Error('No description returned by vision API');
171
213
  }
172
214
 
173
- setDescription(textResult);
174
- return textResult;
215
+ if (isMountedRef.current) {
216
+ setDescription(resultText);
217
+ }
218
+ return resultText;
175
219
  } catch (err) {
176
- const message = (err as Error).message || 'Failed to analyze image';
177
- setError(message);
220
+ if (isMountedRef.current) {
221
+ const message = err instanceof Error ? err.message : 'Failed to analyze image';
222
+ setError(message);
223
+ }
178
224
  return null;
179
225
  } finally {
180
- setIsLoading(false);
226
+ if (isMountedRef.current) {
227
+ setIsLoading(false);
228
+ }
181
229
  }
182
230
  },
183
- [normalizeImage, options],
231
+ [normalizeImage, options, providerConfig],
184
232
  );
185
233
 
234
+ useState(() => {
235
+ isMountedRef.current = true;
236
+ return () => {
237
+ isMountedRef.current = false;
238
+ };
239
+ }, []);
240
+
186
241
  return {
187
242
  description,
188
243
  isLoading,
package/src/index.ts CHANGED
@@ -5,4 +5,28 @@ export { useAIForm } from './hooks/useAIForm';
5
5
  export { useAIVoice } from './hooks/useAIVoice';
6
6
  export { useAITranslate } from './hooks/useAITranslate';
7
7
  export { useAISummarize } from './hooks/useAISummarize';
8
- export { useAICode } from './hooks/useAICode';
8
+ export { useAICode } from './hooks/useAICode';
9
+
10
+ // Type exports
11
+ export type {
12
+ Message,
13
+ AIProviderType,
14
+ ProviderConfig,
15
+ AIResponse,
16
+ AIRequestOptions,
17
+ UseAIChatOptions,
18
+ UseAIChatReturn,
19
+ UseAIStreamOptions,
20
+ UseAIStreamReturn,
21
+ UseImageAnalysisOptions,
22
+ UseImageAnalysisReturn,
23
+ UseAIFormOptions,
24
+ UseAIFormReturn,
25
+ FormValidationRequest,
26
+ FormValidationResult,
27
+ } from './types';
28
+
29
+ // Utility exports for advanced use cases
30
+ export { createProvider, ProviderFactory } from './utils/providerFactory';
31
+ export { fetchWithRetry } from './utils/fetchWithRetry';
32
+ export type { AIResponse, AIRequestOptions } from './utils/providerFactory';
@@ -1,15 +1,189 @@
1
+ /**
2
+ * Core Message Types
3
+ */
1
4
  export interface Message {
2
5
  role: 'user' | 'assistant';
3
6
  content: string;
7
+ timestamp?: number;
4
8
  }
5
9
 
6
- export interface AIProvider {
10
+ /**
11
+ * AI Provider Types
12
+ */
13
+ export type AIProviderType = 'anthropic' | 'openai' | 'gemini';
14
+
15
+ export interface ProviderConfig {
16
+ provider: AIProviderType;
7
17
  apiKey: string;
8
- provider?: 'claude' | 'openai' | 'gemini';
9
- model?: string;
18
+ model: string;
19
+ baseUrl?: string;
20
+ timeout?: number;
21
+ maxRetries?: number;
10
22
  }
11
23
 
24
+ /**
25
+ * Standardized API Response
26
+ */
12
27
  export interface AIResponse {
13
- content: string;
28
+ text: string;
29
+ raw: Record<string, unknown>;
30
+ usage?: {
31
+ inputTokens?: number;
32
+ outputTokens?: number;
33
+ totalTokens?: number;
34
+ };
14
35
  error?: string;
36
+ }
37
+
38
+ /**
39
+ * API Request Options
40
+ */
41
+ export interface AIRequestOptions {
42
+ system?: string;
43
+ temperature?: number;
44
+ maxTokens?: number;
45
+ topP?: number;
46
+ stopSequences?: string[];
47
+ }
48
+
49
+ /**
50
+ * Hook Options Interface
51
+ */
52
+ export interface UseAIChatOptions {
53
+ apiKey: string;
54
+ provider?: AIProviderType;
55
+ model?: string;
56
+ system?: string;
57
+ temperature?: number;
58
+ maxTokens?: number;
59
+ baseUrl?: string;
60
+ timeout?: number;
61
+ maxRetries?: number;
62
+ }
63
+
64
+ export interface UseAIStreamOptions extends UseAIChatOptions {}
65
+
66
+ export interface UseAIFormOptions {
67
+ apiKey: string;
68
+ provider?: AIProviderType;
69
+ model?: string;
70
+ system?: string;
71
+ temperature?: number;
72
+ maxTokens?: number;
73
+ baseUrl?: string;
74
+ timeout?: number;
75
+ maxRetries?: number;
76
+ }
77
+
78
+ export interface UseImageAnalysisOptions {
79
+ apiKey: string;
80
+ provider?: AIProviderType;
81
+ model?: string;
82
+ system?: string;
83
+ maxTokens?: number;
84
+ baseUrl?: string;
85
+ timeout?: number;
86
+ maxRetries?: number;
87
+ uriToBase64?: (uri: string) => Promise<string>;
88
+ }
89
+
90
+ /**
91
+ * Normalized Internal API Structures
92
+ */
93
+ export interface NormalizedMessage {
94
+ role: 'user' | 'assistant';
95
+ content: NormalizedContent;
96
+ }
97
+
98
+ export type NormalizedContent =
99
+ | { type: 'text'; text: string }
100
+ | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
101
+ | Array<NormalizedContent>;
102
+
103
+ /**
104
+ * Provider-Specific Raw Responses
105
+ */
106
+ export interface AnthropicResponse {
107
+ content?: Array<{ type: string; text?: string }>;
108
+ error?: { type: string; message?: string };
109
+ usage?: {
110
+ input_tokens?: number;
111
+ output_tokens?: number;
112
+ };
113
+ }
114
+
115
+ export interface OpenAIResponse {
116
+ choices?: Array<{ message?: { content?: string } }>;
117
+ error?: { message?: string };
118
+ usage?: {
119
+ prompt_tokens?: number;
120
+ completion_tokens?: number;
121
+ total_tokens?: number;
122
+ };
123
+ }
124
+
125
+ export interface GeminiResponse {
126
+ candidates?: Array<{
127
+ content?: {
128
+ parts?: Array<{ text?: string }>;
129
+ };
130
+ }>;
131
+ error?: { message?: string };
132
+ usageMetadata?: {
133
+ promptTokenCount?: number;
134
+ candidatesTokenCount?: number;
135
+ totalTokenCount?: number;
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Form Validation Input/Output
141
+ */
142
+ export interface FormValidationRequest {
143
+ formData: Record<string, unknown>;
144
+ validationSchema?: Record<string, string>;
145
+ customInstructions?: string;
146
+ }
147
+
148
+ export interface FormValidationResult {
149
+ isValid: boolean;
150
+ errors: Record<string, string>;
151
+ raw: unknown;
152
+ }
153
+
154
+ /**
155
+ * Generic Hook Return Types
156
+ */
157
+ export interface UseAIChatReturn {
158
+ messages: Message[];
159
+ isLoading: boolean;
160
+ error: string | null;
161
+ sendMessage: (content: string) => Promise<void>;
162
+ abort: () => void;
163
+ clearMessages: () => void;
164
+ }
165
+
166
+ export interface UseAIStreamReturn {
167
+ response: string;
168
+ isLoading: boolean;
169
+ error: string | null;
170
+ streamResponse: (prompt: string) => Promise<void>;
171
+ abort: () => void;
172
+ clearResponse: () => void;
173
+ }
174
+
175
+ export interface UseImageAnalysisReturn {
176
+ description: string;
177
+ isLoading: boolean;
178
+ error: string | null;
179
+ analyzeImage: (uri: string, prompt?: string) => Promise<string | null>;
180
+ clearDescription: () => void;
181
+ }
182
+
183
+ export interface UseAIFormReturn {
184
+ validationResult: FormValidationResult | null;
185
+ isLoading: boolean;
186
+ error: string | null;
187
+ validateForm: (input: FormValidationRequest) => Promise<FormValidationResult | null>;
188
+ clearValidation: () => void;
15
189
  }
@@ -0,0 +1,168 @@
1
+ import { fetchWithRetry } from '../fetchWithRetry';
2
+
3
+ describe('fetchWithRetry', () => {
4
+ let fetchSpy: jest.SpyInstance<Promise<Response>, Parameters<typeof fetch>>;
5
+
6
+ const createResponse = (status: number, body: unknown, retryAfter?: string): Response => {
7
+ return {
8
+ ok: status >= 200 && status < 300,
9
+ status,
10
+ headers: {
11
+ get: (name: string) => {
12
+ if (name.toLowerCase() === 'retry-after') {
13
+ return retryAfter ?? null;
14
+ }
15
+ return null;
16
+ },
17
+ },
18
+ json: async () => body,
19
+ text: async () => JSON.stringify(body),
20
+ } as unknown as Response;
21
+ };
22
+
23
+ beforeEach(() => {
24
+ fetchSpy = jest.spyOn(global, 'fetch');
25
+ fetchSpy.mockReset();
26
+ });
27
+
28
+ afterEach(() => {
29
+ fetchSpy.mockRestore();
30
+ });
31
+
32
+ it('retries 429 responses with exponential backoff delays', async () => {
33
+ const allDelays: number[] = [];
34
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
35
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((callback: TimerHandler, delay?: number) => {
36
+ allDelays.push(Number(delay));
37
+ if (typeof callback === 'function') {
38
+ callback();
39
+ }
40
+ return 0 as unknown as ReturnType<typeof setTimeout>;
41
+ }) as typeof setTimeout);
42
+
43
+ fetchSpy
44
+ .mockResolvedValueOnce(createResponse(429, { error: 'rate_limited' }, '1'))
45
+ .mockResolvedValueOnce(createResponse(429, { error: 'rate_limited' }))
46
+ .mockResolvedValueOnce(createResponse(200, { ok: true }));
47
+
48
+ const response = await fetchWithRetry(
49
+ 'https://api.example.com/test',
50
+ { method: 'POST' },
51
+ { maxRetries: 2, baseDelay: 1000, backoffMultiplier: 2, timeout: 1000 },
52
+ );
53
+
54
+ expect(response.status).toBe(200);
55
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
56
+ // allDelays contains: [abort_timeout, retry_delay, abort_timeout, retry_delay, abort_timeout]
57
+ // Extract only the odd-indexed elements (1, 3) which are the retry delays
58
+ const retryDelays = [allDelays[1], allDelays[3]];
59
+ expect(retryDelays).toEqual([1000, 2000]);
60
+
61
+ randomSpy.mockRestore();
62
+ setTimeoutSpy.mockRestore();
63
+ });
64
+
65
+ it('uses default retry options when none are provided', async () => {
66
+ fetchSpy.mockResolvedValueOnce(createResponse(200, { ok: true }));
67
+
68
+ const response = await fetchWithRetry('https://api.example.com/default-options');
69
+
70
+ expect(response.status).toBe(200);
71
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('retries 5xx responses with jittered backoff and then succeeds', async () => {
75
+ const allDelays: number[] = [];
76
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
77
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((callback: TimerHandler, delay?: number) => {
78
+ allDelays.push(Number(delay));
79
+ if (typeof callback === 'function') {
80
+ callback();
81
+ }
82
+ return 0 as unknown as ReturnType<typeof setTimeout>;
83
+ }) as typeof setTimeout);
84
+
85
+ fetchSpy
86
+ .mockResolvedValueOnce(createResponse(500, { error: 'server_error' }))
87
+ .mockResolvedValueOnce(createResponse(200, { ok: true }));
88
+
89
+ const response = await fetchWithRetry(
90
+ 'https://api.example.com/test',
91
+ { method: 'POST' },
92
+ { maxRetries: 1, baseDelay: 1000, timeout: 30000 },
93
+ );
94
+
95
+ expect(response.status).toBe(200);
96
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
97
+ // [attempt1 timeout, retry delay, attempt2 timeout]
98
+ expect(allDelays[1]).toBe(1000);
99
+
100
+ randomSpy.mockRestore();
101
+ setTimeoutSpy.mockRestore();
102
+ });
103
+
104
+ it('retries AbortError failures and then succeeds', async () => {
105
+ const allDelays: number[] = [];
106
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
107
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((callback: TimerHandler, delay?: number) => {
108
+ allDelays.push(Number(delay));
109
+ if (typeof callback === 'function') {
110
+ callback();
111
+ }
112
+ return 0 as unknown as ReturnType<typeof setTimeout>;
113
+ }) as typeof setTimeout);
114
+
115
+ const abortError = Object.assign(new Error('timeout'), { name: 'AbortError' });
116
+
117
+ fetchSpy
118
+ .mockRejectedValueOnce(abortError)
119
+ .mockResolvedValueOnce(createResponse(200, { ok: true }));
120
+
121
+ const response = await fetchWithRetry(
122
+ 'https://api.example.com/test',
123
+ { method: 'GET' },
124
+ { maxRetries: 1, baseDelay: 500, timeout: 1234 },
125
+ );
126
+
127
+ expect(response.status).toBe(200);
128
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
129
+ // [attempt1 timeout, retry delay, attempt2 timeout]
130
+ expect(allDelays[1]).toBe(500);
131
+
132
+ randomSpy.mockRestore();
133
+ setTimeoutSpy.mockRestore();
134
+ });
135
+
136
+ it('throws immediately when an error is explicitly marked non-retryable', async () => {
137
+ const nonRetryableError = Object.assign(new Error('do not retry'), { isRetryable: false });
138
+
139
+ fetchSpy.mockRejectedValueOnce(nonRetryableError);
140
+
141
+ await expect(
142
+ fetchWithRetry('https://api.example.com/test', { method: 'GET' }, { maxRetries: 3 }),
143
+ ).rejects.toThrow('do not retry');
144
+
145
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
146
+ });
147
+
148
+ it('retries unknown errors until max retries are exhausted', async () => {
149
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
150
+ const retryError = new Error('network down');
151
+
152
+ fetchSpy.mockRejectedValue(retryError);
153
+
154
+ await expect(
155
+ fetchWithRetry('https://api.example.com/test', { method: 'GET' }, { maxRetries: 1, baseDelay: 1, timeout: 1 }),
156
+ ).rejects.toThrow('network down');
157
+
158
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
159
+
160
+ randomSpy.mockRestore();
161
+ });
162
+
163
+ it('throws a fallback error when retries are disabled before entering the loop', async () => {
164
+ await expect(
165
+ fetchWithRetry('https://api.example.com/test', { method: 'GET' }, { maxRetries: -1 }),
166
+ ).rejects.toThrow('Fetch failed after retries');
167
+ });
168
+ });