API Error Handling Patterns for Production Applications
API Error Handling Patterns for Production Applications
API errors in development are annoying. API errors in production lose money, users, and trust. The difference between a fragile integration and a resilient one comes down to how you handle errors — not just catching them, but categorizing, retrying, reporting, and recovering from them.
Error Categories
The Three Types of API Errors
| Category | Status Codes | Retryable | Action |
|---|---|---|---|
| Client errors | 400, 401, 403, 404, 409, 422 | No (usually) | Fix the request |
| Server errors | 500, 502, 503, 504 | Yes | Retry with backoff |
| Rate limits | 429 | Yes (after waiting) | Backoff, respect Retry-After |
Detailed Error Code Guide
| Code | Meaning | Should You Retry? | What to Do |
|---|---|---|---|
| 400 | Bad request | No | Fix request body/params |
| 401 | Unauthorized | Maybe (refresh token) | Refresh auth, re-authenticate |
| 403 | Forbidden | No | Check permissions/scopes |
| 404 | Not found | No | Resource doesn't exist |
| 409 | Conflict | Maybe | Resolve conflict, retry |
| 422 | Validation error | No | Fix input data |
| 429 | Rate limited | Yes (after delay) | Wait for Retry-After, then retry |
| 500 | Server error | Yes | Retry with backoff |
| 502 | Bad gateway | Yes | Retry with backoff |
| 503 | Service unavailable | Yes | Retry with backoff, check status page |
| 504 | Gateway timeout | Yes | Retry with backoff |
Pattern 1: Structured Error Handling
Categorize errors and handle each type differently:
class APIError extends Error {
constructor(
message: string,
public status: number,
public code: string,
public retryable: boolean,
public body: unknown,
) {
super(message);
this.name = 'APIError';
}
static fromResponse(response: Response, body: any): APIError {
const retryable = response.status === 429 || response.status >= 500;
const code = body?.error?.code || body?.code || `HTTP_${response.status}`;
const message = body?.error?.message || body?.message || `HTTP ${response.status}`;
return new APIError(message, response.status, code, retryable, body);
}
}
async function apiCall<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const error = APIError.fromResponse(response, body);
// Handle specific cases before throwing
if (response.status === 401) {
await refreshAuth();
// Retry once with new auth
return apiCall(url, options);
}
throw error;
}
return response.json();
}
// Usage
try {
const data = await apiCall('/api/resource');
} catch (error) {
if (error instanceof APIError) {
if (error.retryable) {
// Queue for retry
await retryQueue.add(() => apiCall('/api/resource'));
} else if (error.status === 404) {
// Resource doesn't exist — show appropriate UI
return null;
} else if (error.status === 422) {
// Validation error — show to user
showValidationErrors(error.body);
} else {
// Unexpected error — log and alert
reportError(error);
}
}
}
Pattern 2: Error Mapping for Users
Never show raw API errors to users. Map them to user-friendly messages:
const ERROR_MESSAGES: Record<string, string> = {
// Auth errors
'invalid_api_key': 'Authentication failed. Please try again.',
'token_expired': 'Your session has expired. Please sign in again.',
'insufficient_permissions': 'You don\'t have permission to do this.',
// Validation errors
'invalid_email': 'Please enter a valid email address.',
'duplicate_email': 'An account with this email already exists.',
'password_too_short': 'Password must be at least 8 characters.',
// Payment errors
'card_declined': 'Your card was declined. Please try a different card.',
'insufficient_funds': 'Insufficient funds. Please try a different payment method.',
'expired_card': 'Your card has expired. Please update your payment method.',
// Rate limits
'rate_limit_exceeded': 'Too many requests. Please wait a moment and try again.',
// Server errors
'internal_error': 'Something went wrong on our end. Please try again.',
'service_unavailable': 'This service is temporarily unavailable. Please try again shortly.',
};
function getUserMessage(error: APIError): string {
// Try specific error code first
if (error.code && ERROR_MESSAGES[error.code]) {
return ERROR_MESSAGES[error.code];
}
// Fall back to status code
if (error.status === 429) return ERROR_MESSAGES['rate_limit_exceeded'];
if (error.status >= 500) return ERROR_MESSAGES['internal_error'];
if (error.status === 401) return ERROR_MESSAGES['token_expired'];
if (error.status === 403) return ERROR_MESSAGES['insufficient_permissions'];
// Generic fallback
return 'Something went wrong. Please try again.';
}
Pattern 3: Error Recovery Strategies
Different errors need different recovery approaches:
async function withErrorRecovery<T>(
fn: () => Promise<T>,
options: {
maxRetries?: number;
onAuthError?: () => Promise<void>;
fallback?: () => T;
onError?: (error: APIError) => void;
} = {}
): Promise<T> {
const { maxRetries = 3, onAuthError, fallback, onError } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (!(error instanceof APIError)) throw error;
onError?.(error);
// Auth error — try refreshing once
if (error.status === 401 && attempt === 0 && onAuthError) {
await onAuthError();
continue;
}
// Retryable error — backoff and retry
if (error.retryable && attempt < maxRetries) {
const delay = Math.min(Math.pow(2, attempt) * 1000, 30000);
await new Promise(r => setTimeout(r, delay));
continue;
}
// Non-retryable or max retries reached — use fallback
if (fallback) return fallback();
throw error;
}
}
throw new Error('Max retries exceeded');
}
// Usage
const userData = await withErrorRecovery(
() => apiCall('/api/user/profile'),
{
onAuthError: () => refreshToken(),
fallback: () => getCachedProfile(),
onError: (err) => logError('profile_fetch_failed', err),
}
);
Pattern 4: Error Monitoring and Alerting
Track error patterns to catch issues early:
class ErrorTracker {
private errors: Array<{
code: string;
status: number;
endpoint: string;
timestamp: number;
}> = [];
record(error: APIError, endpoint: string) {
this.errors.push({
code: error.code,
status: error.status,
endpoint,
timestamp: Date.now(),
});
// Keep only last hour
const oneHourAgo = Date.now() - 3600000;
this.errors = this.errors.filter(e => e.timestamp > oneHourAgo);
// Check for anomalies
this.checkAlerts();
}
private checkAlerts() {
const last5min = this.errors.filter(e => Date.now() - e.timestamp < 300000);
// Alert: sudden spike in errors
if (last5min.length > 50) {
this.alert('error_spike', `${last5min.length} errors in last 5 minutes`);
}
// Alert: specific endpoint failing
const byEndpoint = this.groupBy(last5min, 'endpoint');
for (const [endpoint, errors] of Object.entries(byEndpoint)) {
if (errors.length > 10) {
this.alert('endpoint_failing', `${endpoint}: ${errors.length} errors in 5 min`);
}
}
// Alert: auth errors (possible key compromise or expiry)
const authErrors = last5min.filter(e => e.status === 401);
if (authErrors.length > 5) {
this.alert('auth_failures', `${authErrors.length} auth failures — check API keys`);
}
}
private groupBy(items: any[], key: string) {
return items.reduce((groups, item) => {
(groups[item[key]] = groups[item[key]] || []).push(item);
return groups;
}, {} as Record<string, any[]>);
}
private alert(type: string, message: string) {
console.error(`[ALERT:${type}] ${message}`);
// Send to monitoring service (PagerDuty, Slack, etc.)
}
}
Pattern 5: Webhook Error Handling
Webhooks need special error handling — you don't control when they arrive:
async function handleWebhook(req: Request): Promise<Response> {
// 1. Verify signature FIRST
const signature = req.headers.get('x-webhook-signature');
const body = await req.text();
if (!verifySignature(body, signature, WEBHOOK_SECRET)) {
// Don't reveal why it failed
return new Response('Unauthorized', { status: 401 });
}
// 2. Parse payload
let event;
try {
event = JSON.parse(body);
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// 3. Acknowledge IMMEDIATELY, process async
// Return 200 fast — the sender will retry if you're slow
processWebhookAsync(event).catch(error => {
// Log but don't fail the webhook response
console.error('Webhook processing failed:', error);
// Queue for manual retry
deadLetterQueue.add(event);
});
return new Response('OK', { status: 200 });
}
// 4. Idempotent processing (webhooks can be delivered multiple times)
async function processWebhookAsync(event: WebhookEvent) {
// Check if already processed
const processed = await db.webhookEvents.findById(event.id);
if (processed) return; // Already handled
// Process
await handleEvent(event);
// Mark as processed
await db.webhookEvents.create({ id: event.id, processedAt: new Date() });
}
Pattern 6: Validation Error Display
When APIs return validation errors, show them clearly:
// API returns structured validation errors
interface ValidationError {
field: string;
message: string;
code: string;
}
// Parse validation errors from different API formats
function parseValidationErrors(body: any): ValidationError[] {
// Stripe format: { error: { param: "email", message: "..." } }
if (body?.error?.param) {
return [{ field: body.error.param, message: body.error.message, code: body.error.code }];
}
// Standard format: { errors: [{ field, message }] }
if (Array.isArray(body?.errors)) {
return body.errors;
}
// Zod format: { issues: [{ path: [...], message }] }
if (Array.isArray(body?.issues)) {
return body.issues.map((issue: any) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}));
}
return [{ field: 'general', message: 'Validation failed', code: 'validation_error' }];
}
// React component to display errors
function FormErrors({ errors }: { errors: ValidationError[] }) {
if (errors.length === 0) return null;
return (
<div role="alert" className="error-summary">
<h3>Please fix the following:</h3>
<ul>
{errors.map((error, i) => (
<li key={i}>
<strong>{error.field}:</strong> {error.message}
</li>
))}
</ul>
</div>
);
}
The Error Handling Checklist
| Layer | What to Handle |
|---|---|
| Network | Timeout, DNS failure, connection refused |
| HTTP | 4xx client errors, 5xx server errors, 429 rate limits |
| Response | Invalid JSON, unexpected format, missing fields |
| Business | Application-level errors (insufficient funds, duplicate entry) |
| Webhook | Signature verification, idempotency, async processing |
| Monitoring | Error rate tracking, anomaly detection, alerting |
| User | Friendly messages, actionable guidance, retry options |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
catch (e) { console.log(e) } | Errors silently swallowed | Handle or re-throw every error |
| Showing raw API error to user | Confusing, exposes internals | Map to user-friendly messages |
| Retrying all errors | Retrying permanent failures | Only retry 429 and 5xx |
| No error monitoring | Issues found by users | Track error rates, alert on spikes |
| Same retry strategy for all APIs | Suboptimal recovery | Per-API retry config |
| Not validating API responses | Breaks silently when API changes | Validate with Zod/schemas |
| Slow webhook processing | Webhook sender times out and retries | Acknowledge fast, process async |
Find APIs with the best error documentation on APIScout — error code references, retry guidance, and developer experience scores.