Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 80 additions & 63 deletions frontend/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// frontend/lib/api/client.ts
import {
OfflineActionQueuedError,
isLikelyOfflineError,
Expand All @@ -6,22 +7,30 @@
} from '@/lib/offline';

const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001/api/v1';

const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 10000,
jitter: true,
};
process.env.NEXT_PUBLIC_API_URL ||
process.env.NEXT_PUBLIC_BACKEND_URL ||
'http://localhost:3001/api/v1';

/* =====================================================
Retry Configuration
===================================================== */
export interface RetryConfig {
maxRetries: number;
baseDelay: number;
maxDelay: number;
jitter: boolean;
}

const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 10000,
jitter: true,
};

/* =====================================================
Custom API Error
===================================================== */
export class ApiError extends Error {
status: number;
response: Response;
Expand All @@ -36,29 +45,32 @@
}
}

/** Type guard for UI handling */
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}

/* =====================================================
URL Resolver
===================================================== */
export function resolveApiUrl(endpoint: string) {
return `${API_BASE_URL}${endpoint}`;
}

/* =====================================================
Retry Helpers
===================================================== */
function shouldRetryStatus(status: number): boolean {
return status >= 500 || status === 429;
}

function shouldRetryError(error: unknown): boolean {
if (error instanceof ApiError) {
return shouldRetryStatus(error.status);
}
if (error instanceof ApiError) return shouldRetryStatus(error.status);

if (
(error instanceof Error && error.name === 'AbortError') ||
(typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError')
) {
return false;
}
// Abort should NOT retry
if (error instanceof Error && error.name === 'AbortError') return false;

// Network errors → retry
return true;
}

Expand All @@ -68,43 +80,35 @@
return config.jitter ? delay * (0.5 + Math.random()) : delay;
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/* =====================================================
Error Normalization
===================================================== */
function normalizeError(error: unknown): Error {
if (error instanceof Error) {
return error;
}
if (error instanceof Error) return error;

const message =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
? error.message
typeof error === 'object' && error !== null && 'message' in error && typeof (error as any).message === 'string'

Check warning on line 92 in frontend/lib/api/client.ts

View workflow job for this annotation

GitHub Actions / Frontend (Node.js 20)

Unexpected any. Specify a different type
? (error as any).message

Check warning on line 93 in frontend/lib/api/client.ts

View workflow job for this annotation

GitHub Actions / Frontend (Node.js 20)

Unexpected any. Specify a different type
: String(error);

const normalized = new Error(message);

if (
typeof error === 'object' &&
error !== null &&
'name' in error &&
typeof error.name === 'string'
) {
normalized.name = error.name;
if (typeof error === 'object' && error !== null && 'name' in error && typeof (error as any).name === 'string') {

Check warning on line 98 in frontend/lib/api/client.ts

View workflow job for this annotation

GitHub Actions / Frontend (Node.js 20)

Unexpected any. Specify a different type
normalized.name = (error as any).name;

Check warning on line 99 in frontend/lib/api/client.ts

View workflow job for this annotation

GitHub Actions / Frontend (Node.js 20)

Unexpected any. Specify a different type
}

return normalized;
}

/* =====================================================
Response Parsing
===================================================== */
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get('content-type') || '';

if (response.status === 204) {
return null;
}
if (response.status === 204) return null;

if (contentType.includes('application/json')) {
return response.json();
Expand All @@ -114,18 +118,35 @@
return text.length > 0 ? text : null;
}

function getErrorMessage(statusText: string, data: unknown): string {
if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') {
return data.message;
/* =====================================================
Friendly Error Messages
===================================================== */
function getErrorMessage(status: number, statusText: string, data: unknown): string {
if (data && typeof data === 'object' && 'message' in data && typeof (data as any).message === 'string') {

Check warning on line 125 in frontend/lib/api/client.ts

View workflow job for this annotation

GitHub Actions / Frontend (Node.js 20)

Unexpected any. Specify a different type
return (data as any).message;

Check warning on line 126 in frontend/lib/api/client.ts

View workflow job for this annotation

GitHub Actions / Frontend (Node.js 20)

Unexpected any. Specify a different type
}

if (typeof data === 'string' && data.trim().length > 0) {
return data;
switch (status) {
case 400:
return 'Invalid request. Please check your input.';
case 401:
return 'You are not authenticated. Please login again.';
case 403:
return 'You do not have permission to perform this action.';
case 404:
return 'Requested resource was not found.';
case 429:
return 'Too many requests. Please try again shortly.';
case 500:
return 'Server error. Please try again later.';
default:
return `Request failed: ${statusText}`;
}

return `API Error: ${statusText}`;
}

/* =====================================================
Main API Call
===================================================== */
export async function apiCall<T = unknown>(
endpoint: string,
options: RequestInit = {},
Expand All @@ -135,6 +156,7 @@
let lastError: Error | undefined;
const shouldQueue = shouldQueueRequest(options);

// Queue immediately if offline
if (shouldQueue && typeof navigator !== 'undefined' && navigator.onLine === false) {
const action = queueOfflineAction(endpoint, options);
throw new OfflineActionQueuedError(
Expand All @@ -150,41 +172,36 @@
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
...(options.headers || {}),
},
});

const data = await parseResponseBody(response);

if (response.ok) {
return data as T;
}
if (response.ok) return data as T;

throw new ApiError(
getErrorMessage(response.statusText, data),
response.status,
response,
data
);
throw new ApiError(getErrorMessage(response.status, response.statusText, data), response.status, response, data);
} catch (error) {
lastError = normalizeError(error);

// Debug logging
console.error('[API ERROR]', { endpoint, attempt, error: lastError });

// Queue request if offline
if (shouldQueue && isLikelyOfflineError(lastError)) {
const action = queueOfflineAction(endpoint, options);
throw new OfflineActionQueuedError(
'The request was queued because the network is unavailable.',
'Network unavailable. Request queued.',
endpoint,
action.id
);
}

if (attempt === config.maxRetries || !shouldRetryError(error)) {
throw lastError;
}
if (attempt === config.maxRetries || !shouldRetryError(error)) throw lastError;

await delay(calculateDelay(attempt, config));
}
}

throw lastError || new Error('API call failed after retries');
}
}
Loading