- Author
- app-hacks
- Published
TypeScript / REST API / Error Handling
REST API Error-Handling Patterns for Frontend Applications
Classify network failures, HTTP errors, and invalid responses, then separate finite retry policy from actionable UI notifications.
Treating every REST API failure as a “network error” can retry invalid input indefinitely or ask users to edit a form during a server outage. Frontends should distinguish network failures, 4xx, 5xx, cancellation, and invalid response data, while keeping retry policy separate from UI messages.
Convert failures to a discriminated type
type ApiError =
| { kind: 'network'; cause: unknown }
| { kind: 'timeout' }
| { kind: 'cancelled' }
| { kind: 'client'; status: number; body: unknown; bodyParseError?: unknown }
| { kind: 'server'; status: number; body: unknown; bodyParseError?: unknown }
| { kind: 'invalid-response'; cause: unknown };
type Result<T> = { ok: true; data: T } | { ok: false; error: ApiError };
The shared wrapper from article 04 owns the one-time response-body read, preservation of HTTP error body parse failures, and runtime validation. This article maps those low-level errors to UI policy.
// Import these error classes from the shared wrapper in article 04.
function classifyRequestError(cause: unknown, signal?: AbortSignal): ApiError {
if (signal?.aborted) {
if (signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError') {
return { kind: 'timeout' };
}
return { kind: 'cancelled' };
}
if (cause instanceof HttpError) {
const error = {
status: cause.status,
body: cause.body,
bodyParseError: cause.bodyParseError,
};
return cause.status >= 400 && cause.status < 500
? { kind: 'client', ...error }
: { kind: 'server', ...error };
}
if (cause instanceof ResponseParseError || cause instanceof ValidationError) {
return { kind: 'invalid-response', cause };
}
return { kind: 'network', cause };
}
async function requestJson<T>(
operation: () => Promise<T>,
signal?: AbortSignal,
): Promise<Result<T>> {
try { return { ok: true, data: await operation() }; }
catch (cause: unknown) {
return { ok: false, error: classifyRequestError(cause, signal) };
}
}
Pass the same signal to fetchJson() and requestJson(). AbortSignal.timeout() sets the signal reason to TimeoutError; a no-argument AbortController.abort() produces AbortError and is classified as cancellation. For application-defined abort reasons, use a documented signal.reason convention rather than exception names alone. Network failures have no status code. A discriminated union makes those facts explicit and lets TypeScript detect missing branches.
Isolate retry policy
Temporary network failures, timeouts, some 5xx responses, and 429 can be retry candidates. Authentication, authorization, invalid input, and missing resources generally are not. Retrying POST can duplicate an operation when the server completed it but the response was lost.
function isRetryable(error: ApiError, method: string): boolean {
const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase());
if (!safeMethod) return false;
if (error.kind === 'network' || error.kind === 'timeout') return true;
if (error.kind === 'server') return [502, 503, 504].includes(error.status);
return error.kind === 'client' && error.status === 429;
}
const wait = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function withRetry<T>(operation: () => Promise<Result<T>>, method = 'GET', maxAttempts = 3): Promise<Result<T>> {
let result = await operation();
for (let attempt = 1; attempt < maxAttempts; attempt += 1) {
if (result.ok || !isRetryable(result.error, method)) return result;
await wait(300 * 2 ** (attempt - 1));
result = await operation();
}
return result;
}
maxAttempts includes the first call. Respect a valid bounded Retry-After for 429 when implemented, add jitter for many simultaneous clients, and pass an AbortSignal so retries stop after the screen is discarded.
Keep UI messages outside the client
function messageFor(error: ApiError): string {
switch (error.kind) {
case 'network': return 'Check your network connection.';
case 'timeout': return 'The request took too long. Please try again.';
case 'cancelled': return '';
case 'client':
if (error.status === 401) return 'Please sign in.';
if (error.status === 403) return 'You do not have permission for this action.';
if (error.status === 404) return 'The requested data was not found.';
return 'Check your input or request.';
case 'server': return 'The server encountered a problem. Try again later.';
case 'invalid-response': return 'The received data could not be processed.';
}
}
A toast is supplementary. A failed list should also show an inline error and retry action; a 422 form error should appear near the relevant field when possible. Show a notification after the final automatic attempt, not after each retry. For retryable POST operations, verify server-side idempotency support instead of assuming the client can make them safe.
Error handling is a design that classifies low-level failures, expresses retry policy as a bounded function, and chooses recovery in the UI context—not merely a collection of catch blocks.