Skip to content

Commit 4587e22

Browse files
committed
test: add unit tests for apiCall function to validate retry logic and error handling for network errors
1 parent a48ad01 commit 4587e22

File tree

2 files changed

+237
-7
lines changed

2 files changed

+237
-7
lines changed

src/ax/util/apicall.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import {
4+
apiCall,
5+
AxAIServiceNetworkError,
6+
type AxAPIConfig,
7+
} from './apicall.js';
8+
9+
describe('apiCall', () => {
10+
describe('retry logic for network errors', () => {
11+
it('should retry on raw TypeError from fetch (e.g., TLS connection errors)', async () => {
12+
// Simulate TLS connection error like "peer closed connection without sending TLS close_notify"
13+
const tlsError = new TypeError(
14+
'peer closed connection without sending TLS close_notify'
15+
);
16+
17+
const mockFetch = vi
18+
.fn()
19+
.mockRejectedValueOnce(tlsError)
20+
.mockRejectedValueOnce(tlsError)
21+
.mockResolvedValueOnce(
22+
new Response(JSON.stringify({ result: 'success' }), {
23+
status: 200,
24+
headers: { 'content-type': 'application/json' },
25+
})
26+
);
27+
28+
const config: AxAPIConfig = {
29+
url: 'https://api.example.com/test',
30+
fetch: mockFetch,
31+
retry: {
32+
maxRetries: 3,
33+
initialDelayMs: 1, // Use tiny delays for tests
34+
backoffFactor: 1,
35+
maxDelayMs: 10,
36+
},
37+
};
38+
39+
const result = await apiCall(config, { test: 'data' });
40+
41+
expect(mockFetch).toHaveBeenCalledTimes(3);
42+
expect(result).toEqual({ result: 'success' });
43+
});
44+
45+
it('should wrap raw TypeError in AxAIServiceNetworkError when all retries exhausted', async () => {
46+
const tlsError = new TypeError(
47+
'peer closed connection without sending TLS close_notify'
48+
);
49+
50+
const mockFetch = vi.fn().mockRejectedValue(tlsError);
51+
52+
const config: AxAPIConfig = {
53+
url: 'https://api.example.com/test',
54+
fetch: mockFetch,
55+
retry: {
56+
maxRetries: 2,
57+
initialDelayMs: 1,
58+
backoffFactor: 1,
59+
maxDelayMs: 10,
60+
},
61+
};
62+
63+
await expect(apiCall(config, { test: 'data' })).rejects.toThrow(
64+
AxAIServiceNetworkError
65+
);
66+
await expect(apiCall(config, { test: 'data' })).rejects.toThrow(
67+
/peer closed connection without sending TLS close_notify/
68+
);
69+
});
70+
71+
it('should retry on DNS resolution errors', async () => {
72+
const dnsError = new TypeError('getaddrinfo ENOTFOUND api.example.com');
73+
74+
const mockFetch = vi
75+
.fn()
76+
.mockRejectedValueOnce(dnsError)
77+
.mockResolvedValueOnce(
78+
new Response(JSON.stringify({ result: 'success' }), {
79+
status: 200,
80+
headers: { 'content-type': 'application/json' },
81+
})
82+
);
83+
84+
const config: AxAPIConfig = {
85+
url: 'https://api.example.com/test',
86+
fetch: mockFetch,
87+
retry: {
88+
maxRetries: 3,
89+
initialDelayMs: 1,
90+
backoffFactor: 1,
91+
maxDelayMs: 10,
92+
},
93+
};
94+
95+
const result = await apiCall(config, { test: 'data' });
96+
97+
expect(mockFetch).toHaveBeenCalledTimes(2);
98+
expect(result).toEqual({ result: 'success' });
99+
});
100+
101+
it('should retry on connection reset errors', async () => {
102+
const connectionError = new Error('socket hang up');
103+
104+
const mockFetch = vi
105+
.fn()
106+
.mockRejectedValueOnce(connectionError)
107+
.mockResolvedValueOnce(
108+
new Response(JSON.stringify({ result: 'success' }), {
109+
status: 200,
110+
headers: { 'content-type': 'application/json' },
111+
})
112+
);
113+
114+
const config: AxAPIConfig = {
115+
url: 'https://api.example.com/test',
116+
fetch: mockFetch,
117+
retry: {
118+
maxRetries: 3,
119+
initialDelayMs: 1,
120+
backoffFactor: 1,
121+
maxDelayMs: 10,
122+
},
123+
};
124+
125+
const result = await apiCall(config, { test: 'data' });
126+
127+
expect(mockFetch).toHaveBeenCalledTimes(2);
128+
expect(result).toEqual({ result: 'success' });
129+
});
130+
131+
it('should not retry beyond maxRetries', async () => {
132+
const networkError = new TypeError('network error');
133+
134+
const mockFetch = vi.fn().mockRejectedValue(networkError);
135+
136+
const config: AxAPIConfig = {
137+
url: 'https://api.example.com/test',
138+
fetch: mockFetch,
139+
retry: {
140+
maxRetries: 1,
141+
initialDelayMs: 1,
142+
backoffFactor: 1,
143+
maxDelayMs: 10,
144+
},
145+
};
146+
147+
await expect(apiCall(config, { test: 'data' })).rejects.toThrow(
148+
AxAIServiceNetworkError
149+
);
150+
151+
// Initial call + 1 retry = 2 total calls
152+
expect(mockFetch).toHaveBeenCalledTimes(2);
153+
});
154+
155+
it('should preserve original error information in wrapped AxAIServiceNetworkError', async () => {
156+
const originalError = new TypeError(
157+
'peer closed connection without sending TLS close_notify'
158+
);
159+
160+
const mockFetch = vi.fn().mockRejectedValue(originalError);
161+
162+
const config: AxAPIConfig = {
163+
url: 'https://api.example.com/test',
164+
fetch: mockFetch,
165+
retry: {
166+
maxRetries: 0,
167+
initialDelayMs: 1,
168+
backoffFactor: 1,
169+
maxDelayMs: 10,
170+
},
171+
};
172+
173+
try {
174+
await apiCall(config, { test: 'data' });
175+
expect.fail('Should have thrown');
176+
} catch (error) {
177+
expect(error).toBeInstanceOf(AxAIServiceNetworkError);
178+
const networkError = error as AxAIServiceNetworkError;
179+
expect(networkError.message).toContain(
180+
'peer closed connection without sending TLS close_notify'
181+
);
182+
expect(networkError.url).toBe('https://api.example.com/test');
183+
expect(networkError.context.originalErrorName).toBe('TypeError');
184+
}
185+
});
186+
187+
it('should include retry count in metrics', async () => {
188+
const networkError = new TypeError('network error');
189+
190+
const mockFetch = vi.fn().mockRejectedValue(networkError);
191+
192+
const config: AxAPIConfig = {
193+
url: 'https://api.example.com/test',
194+
fetch: mockFetch,
195+
retry: {
196+
maxRetries: 2,
197+
initialDelayMs: 1,
198+
backoffFactor: 1,
199+
maxDelayMs: 10,
200+
},
201+
};
202+
203+
try {
204+
await apiCall(config, { test: 'data' });
205+
expect.fail('Should have thrown');
206+
} catch (error) {
207+
expect(error).toBeInstanceOf(AxAIServiceNetworkError);
208+
const networkError = error as AxAIServiceNetworkError;
209+
expect(networkError.context.metrics).toBeDefined();
210+
const metrics = networkError.context.metrics as {
211+
retryCount: number;
212+
};
213+
expect(metrics.retryCount).toBe(2);
214+
}
215+
});
216+
});
217+
});

src/ax/util/apicall.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -924,8 +924,21 @@ export const apiCall = async <TRequest = unknown, TResponse = unknown>(
924924
});
925925
}
926926

927+
// Wrap raw network errors from fetch() in AxAIServiceNetworkError
928+
// This ensures errors like TLS connection failures, DNS errors, etc. are retried
929+
let wrappedError: Error = error as Error;
930+
if (!(error instanceof AxAIServiceError) && error instanceof Error) {
931+
wrappedError = new AxAIServiceNetworkError(
932+
error,
933+
apiUrl.href,
934+
json,
935+
undefined,
936+
{ metrics }
937+
);
938+
}
939+
927940
if (api.span?.isRecording()) {
928-
api.span.recordException(error as Error);
941+
api.span.recordException(wrappedError);
929942
api.span.setAttributes({
930943
'error.time': Date.now() - metrics.startTime,
931944
'error.retries': metrics.retryCount,
@@ -934,8 +947,8 @@ export const apiCall = async <TRequest = unknown, TResponse = unknown>(
934947

935948
// Handle retryable network errors
936949
if (
937-
error instanceof AxAIServiceNetworkError &&
938-
shouldRetry(error, undefined, attempt, retryConfig)
950+
wrappedError instanceof AxAIServiceNetworkError &&
951+
shouldRetry(wrappedError, undefined, attempt, retryConfig)
939952
) {
940953
const delay = calculateRetryDelay(attempt, retryConfig);
941954
attempt++;
@@ -944,7 +957,7 @@ export const apiCall = async <TRequest = unknown, TResponse = unknown>(
944957
api.span?.addEvent('retry', {
945958
attempt,
946959
delay,
947-
error: error.message,
960+
error: wrappedError.message,
948961
'metrics.startTime': metrics.startTime,
949962
'metrics.retryCount': metrics.retryCount,
950963
'metrics.lastRetryTime': metrics.lastRetryTime,
@@ -954,11 +967,11 @@ export const apiCall = async <TRequest = unknown, TResponse = unknown>(
954967
continue;
955968
}
956969

957-
if (error instanceof AxAIServiceError) {
958-
error.context.metrics = metrics;
970+
if (wrappedError instanceof AxAIServiceError) {
971+
wrappedError.context.metrics = metrics;
959972
}
960973

961-
throw error;
974+
throw wrappedError;
962975
} finally {
963976
if (timeoutId !== undefined) {
964977
clearTimeout(timeoutId);

0 commit comments

Comments
 (0)