Skip to content

Commit ff888ed

Browse files
authored
Merge pull request #116 from shoaib050326/codex/issue-89-api-retry-pr2
feat(frontend): add automatic retry with exponential backoff
2 parents 2892756 + f1dedf1 commit ff888ed

3 files changed

Lines changed: 234 additions & 33 deletions

File tree

frontend/lib/api.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'https://agentpay-backend-mu.vercel.app';
1+
import { apiCall } from '@/lib/api/client';
22

33
export interface VerificationRequest {
44
repositoryUrl: string;
@@ -18,51 +18,34 @@ export const api = {
1818
* AI Work Verification
1919
*/
2020
verifyWork: async (data: VerificationRequest) => {
21-
const response = await fetch(`${BASE_URL}/api/v1/verification/verify`, {
21+
return apiCall('/verification/verify', {
2222
method: 'POST',
2323
headers: {
2424
'Content-Type': 'application/json',
2525
},
2626
body: JSON.stringify(data),
2727
});
28-
29-
if (!response.ok) {
30-
const error = await response.json();
31-
throw new Error(error.message || 'Verification failed');
32-
}
33-
return response.json();
3428
},
3529

3630
/**
3731
* AI Invoice Generation
3832
*/
3933
generateInvoice: async (data: InvoiceRequest) => {
40-
const response = await fetch(`${BASE_URL}/api/v1/invoice/generate`, {
34+
return apiCall('/invoice/generate', {
4135
method: 'POST',
4236
headers: {
4337
'Content-Type': 'application/json',
4438
},
4539
body: JSON.stringify(data),
4640
});
47-
48-
if (!response.ok) {
49-
const error = await response.json();
50-
throw new Error(error.message || 'Invoice generation failed');
51-
}
52-
return response.json();
5341
},
5442

5543
/**
5644
* Get Verification Result
5745
*/
5846
getVerification: async (id: string) => {
59-
const response = await fetch(`${BASE_URL}/api/v1/verification/${id}`, {
47+
return apiCall(`/verification/${id}`, {
6048
method: 'GET',
6149
});
62-
63-
if (!response.ok) {
64-
throw new Error('Failed to fetch verification result');
65-
}
66-
return response.json();
6750
}
6851
};

frontend/lib/api/client.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { ApiError, apiCall } from './client';
3+
4+
describe('apiCall', () => {
5+
const originalFetch = global.fetch;
6+
7+
beforeEach(() => {
8+
vi.useFakeTimers();
9+
vi.spyOn(Math, 'random').mockReturnValue(0.5);
10+
});
11+
12+
afterEach(() => {
13+
vi.restoreAllMocks();
14+
vi.useRealTimers();
15+
global.fetch = originalFetch;
16+
});
17+
18+
it('retries retriable HTTP failures with exponential backoff', async () => {
19+
const fetchMock = vi
20+
.fn()
21+
.mockResolvedValueOnce(
22+
new Response(JSON.stringify({ message: 'Temporary outage' }), {
23+
status: 503,
24+
statusText: 'Service Unavailable',
25+
headers: { 'Content-Type': 'application/json' },
26+
})
27+
)
28+
.mockResolvedValueOnce(
29+
new Response(JSON.stringify({ ok: true }), {
30+
status: 200,
31+
statusText: 'OK',
32+
headers: { 'Content-Type': 'application/json' },
33+
})
34+
);
35+
36+
global.fetch = fetchMock as typeof fetch;
37+
38+
const promise = apiCall<{ ok: boolean }>('/health', {}, { baseDelay: 100, maxDelay: 1000, jitter: false });
39+
40+
await vi.runAllTimersAsync();
41+
42+
await expect(promise).resolves.toEqual({ ok: true });
43+
expect(fetchMock).toHaveBeenCalledTimes(2);
44+
});
45+
46+
it('does not retry non-retriable HTTP errors', async () => {
47+
const fetchMock = vi.fn().mockResolvedValue(
48+
new Response(JSON.stringify({ message: 'Bad request' }), {
49+
status: 400,
50+
statusText: 'Bad Request',
51+
headers: { 'Content-Type': 'application/json' },
52+
})
53+
);
54+
55+
global.fetch = fetchMock as typeof fetch;
56+
57+
await expect(apiCall('/health')).rejects.toMatchObject<ApiError>({
58+
name: 'ApiError',
59+
status: 400,
60+
message: 'Bad request',
61+
});
62+
expect(fetchMock).toHaveBeenCalledTimes(1);
63+
});
64+
65+
it('does not retry aborted requests', async () => {
66+
const abortError = new DOMException('The operation was aborted.', 'AbortError');
67+
const fetchMock = vi.fn().mockRejectedValue(abortError);
68+
69+
global.fetch = fetchMock as typeof fetch;
70+
71+
await expect(apiCall('/health')).rejects.toMatchObject({
72+
name: 'AbortError',
73+
});
74+
expect(fetchMock).toHaveBeenCalledTimes(1);
75+
});
76+
});

frontend/lib/api/client.ts

Lines changed: 154 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,160 @@
1-
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
1+
const API_BASE_URL =
2+
process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api/v1';
23

3-
export async function apiCall(endpoint: string, options: RequestInit = {}) {
4-
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
5-
...options,
6-
headers: {
7-
'Content-Type': 'application/json',
8-
...options.headers,
9-
},
10-
});
4+
const DEFAULT_RETRY_CONFIG: RetryConfig = {
5+
maxRetries: 3,
6+
baseDelay: 1000,
7+
maxDelay: 10000,
8+
jitter: true,
9+
};
1110

12-
if (!response.ok) {
13-
throw new Error(`API Error: ${response.statusText}`);
11+
export interface RetryConfig {
12+
maxRetries: number;
13+
baseDelay: number;
14+
maxDelay: number;
15+
jitter: boolean;
16+
}
17+
18+
export class ApiError extends Error {
19+
status: number;
20+
response: Response;
21+
data?: unknown;
22+
23+
constructor(message: string, status: number, response: Response, data?: unknown) {
24+
super(message);
25+
this.name = 'ApiError';
26+
this.status = status;
27+
this.response = response;
28+
this.data = data;
29+
}
30+
}
31+
32+
function shouldRetryStatus(status: number): boolean {
33+
return status >= 500 || status === 429;
34+
}
35+
36+
function shouldRetryError(error: unknown): boolean {
37+
if (error instanceof ApiError) {
38+
return shouldRetryStatus(error.status);
39+
}
40+
41+
if (
42+
(error instanceof Error && error.name === 'AbortError') ||
43+
(typeof error === 'object' &&
44+
error !== null &&
45+
'name' in error &&
46+
error.name === 'AbortError')
47+
) {
48+
return false;
49+
}
50+
51+
return true;
52+
}
53+
54+
function calculateDelay(attempt: number, config: RetryConfig): number {
55+
const exponentialDelay = config.baseDelay * Math.pow(2, attempt);
56+
const delay = Math.min(exponentialDelay, config.maxDelay);
57+
return config.jitter ? delay * (0.5 + Math.random()) : delay;
58+
}
59+
60+
function delay(ms: number): Promise<void> {
61+
return new Promise((resolve) => setTimeout(resolve, ms));
62+
}
63+
64+
function normalizeError(error: unknown): Error {
65+
if (error instanceof Error) {
66+
return error;
67+
}
68+
69+
const message =
70+
typeof error === 'object' &&
71+
error !== null &&
72+
'message' in error &&
73+
typeof error.message === 'string'
74+
? error.message
75+
: String(error);
76+
77+
const normalized = new Error(message);
78+
79+
if (
80+
typeof error === 'object' &&
81+
error !== null &&
82+
'name' in error &&
83+
typeof error.name === 'string'
84+
) {
85+
normalized.name = error.name;
1486
}
1587

16-
return response.json();
88+
return normalized;
1789
}
1890

91+
async function parseResponseBody(response: Response): Promise<unknown> {
92+
const contentType = response.headers.get('content-type') || '';
93+
94+
if (response.status === 204) {
95+
return null;
96+
}
97+
98+
if (contentType.includes('application/json')) {
99+
return response.json();
100+
}
101+
102+
const text = await response.text();
103+
return text.length > 0 ? text : null;
104+
}
105+
106+
function getErrorMessage(statusText: string, data: unknown): string {
107+
if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') {
108+
return data.message;
109+
}
110+
111+
if (typeof data === 'string' && data.trim().length > 0) {
112+
return data;
113+
}
114+
115+
return `API Error: ${statusText}`;
116+
}
117+
118+
export async function apiCall<T = unknown>(
119+
endpoint: string,
120+
options: RequestInit = {},
121+
retryConfig?: Partial<RetryConfig>
122+
): Promise<T> {
123+
const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
124+
let lastError: Error | undefined;
125+
126+
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
127+
try {
128+
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
129+
...options,
130+
headers: {
131+
'Content-Type': 'application/json',
132+
...options.headers,
133+
},
134+
});
135+
136+
const data = await parseResponseBody(response);
137+
138+
if (response.ok) {
139+
return data as T;
140+
}
141+
142+
throw new ApiError(
143+
getErrorMessage(response.statusText, data),
144+
response.status,
145+
response,
146+
data
147+
);
148+
} catch (error) {
149+
lastError = normalizeError(error);
150+
151+
if (attempt === config.maxRetries || !shouldRetryError(error)) {
152+
throw lastError;
153+
}
154+
155+
await delay(calculateDelay(attempt, config));
156+
}
157+
}
158+
159+
throw lastError || new Error('API call failed after retries');
160+
}

0 commit comments

Comments
 (0)