Skip to main content

API Error Handling Patterns for Production Applications

·APIScout Team
error handlingbest practicesapi integrationproductionreliability

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

CategoryStatus CodesRetryableAction
Client errors400, 401, 403, 404, 409, 422No (usually)Fix the request
Server errors500, 502, 503, 504YesRetry with backoff
Rate limits429Yes (after waiting)Backoff, respect Retry-After

Detailed Error Code Guide

CodeMeaningShould You Retry?What to Do
400Bad requestNoFix request body/params
401UnauthorizedMaybe (refresh token)Refresh auth, re-authenticate
403ForbiddenNoCheck permissions/scopes
404Not foundNoResource doesn't exist
409ConflictMaybeResolve conflict, retry
422Validation errorNoFix input data
429Rate limitedYes (after delay)Wait for Retry-After, then retry
500Server errorYesRetry with backoff
502Bad gatewayYesRetry with backoff
503Service unavailableYesRetry with backoff, check status page
504Gateway timeoutYesRetry 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

LayerWhat to Handle
NetworkTimeout, DNS failure, connection refused
HTTP4xx client errors, 5xx server errors, 429 rate limits
ResponseInvalid JSON, unexpected format, missing fields
BusinessApplication-level errors (insufficient funds, duplicate entry)
WebhookSignature verification, idempotency, async processing
MonitoringError rate tracking, anomaly detection, alerting
UserFriendly messages, actionable guidance, retry options

Common Mistakes

MistakeImpactFix
catch (e) { console.log(e) }Errors silently swallowedHandle or re-throw every error
Showing raw API error to userConfusing, exposes internalsMap to user-friendly messages
Retrying all errorsRetrying permanent failuresOnly retry 429 and 5xx
No error monitoringIssues found by usersTrack error rates, alert on spikes
Same retry strategy for all APIsSuboptimal recoveryPer-API retry config
Not validating API responsesBreaks silently when API changesValidate with Zod/schemas
Slow webhook processingWebhook sender times out and retriesAcknowledge fast, process async

Find APIs with the best error documentation on APIScout — error code references, retry guidance, and developer experience scores.

Comments