API Error Handling Patterns

Error handling is one of the most overlooked aspects of API design. When things go wrong—and they will—how your API communicates errors can make the difference between a frustrated developer and a happy one. I've seen APIs that return 200 OK with an error message in the body.

TRY NANO BANANA FOR FREE

API Error Handling Patterns

TRY NANO BANANA FOR FREE
Contents

Error handling is one of the most overlooked aspects of API design. When things go wrong—and they will—how your API communicates errors can make the difference between a frustrated developer and a happy one.

I've seen APIs that return 200 OK with an error message in the body. I've seen APIs that return cryptic error codes with no explanation. And I've seen APIs that return a generic "Something went wrong" for every possible error.

Don't be that API.

In this guide, we'll explore modern error handling patterns, including the RFC 9457 Problem Details standard, and show you how to build error responses that actually help developers fix problems.

Why Error Handling Matters

Good error handling isn't just about being nice to developers (though that's important). It's about:

Debugging: Clear errors help developers identify and fix problems quickly Monitoring: Structured errors make it easier to track and alert on issues User Experience: Better errors lead to better error messages in client applications Security: Proper error handling prevents information leakage Reliability: Consistent error formats make error handling code simpler and more reliable

Let's build an error handling system that delivers on all these fronts.

HTTP Status Codes: The Foundation

Before we dive into error response formats, let's review HTTP status codes. These are your first line of communication about what went wrong.

Common Status Codes for APIs

2xx Success- 200 OK: Request succeeded - 201 Created: Resource created successfully - 204 No Content: Success with no response body

4xx Client Errors- 400 Bad Request: Invalid request format or parameters - 401 Unauthorized: Authentication required or failed - 403 Forbidden: Authenticated but not authorized - 404 Not Found: Resource doesn't exist - 409 Conflict: Request conflicts with current state - 422 Unprocessable Entity: Validation failed - 429 Too Many Requests: Rate limit exceeded

5xx Server Errors- 500 Internal Server Error: Unexpected server error - 502 Bad Gateway: Upstream service error - 503 Service Unavailable: Temporary unavailability - 504 Gateway Timeout: Upstream service timeout

Here's a simple example of using status codes correctly:

app.post('/pets', async (req, res) => {
  const { name, species } = req.body;

  // 400: Bad request format
  if (!name || !species) {
    return res.status(400).json({
      error: 'Missing required fields'
    });
  }

  // 409: Conflict with existing resource
  const existing = await db.findPetByName(name);
  if (existing) {
    return res.status(409).json({
      error: 'Pet with this name already exists'
    });
  }

  try {
    const pet = await db.createPet({ name, species });
    // 201: Created successfully
    return res.status(201).json(pet);
  } catch (error) {
    // 500: Unexpected server error
    console.error('Database error:', error);
    return res.status(500).json({
      error: 'Internal server error'
    });
  }
});

RFC 9457: Problem Details for HTTP APIs

RFC 9457 (formerly RFC 7807) defines a standard format for error responses. It's designed to be both machine-readable and human-friendly.

Here's what a Problem Details response looks like:

{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The pet name must be between 1 and 50 characters",
  "instance": "/pets/create",
  "errors": [
    {
      "field": "name",
      "message": "Name is required"
    },
    {
      "field": "species",
      "message": "Species must be one of: dog, cat, bird"
    }
  ]
}

Let's break down each field:

  • type: A URI that identifies the error type (can be documentation URL)
  • title: A short, human-readable summary
  • status: The HTTP status code
  • detail: A human-readable explanation specific to this occurrence
  • instance: A URI reference that identifies the specific occurrence

Implementing RFC 9457

Here's a complete implementation:

class ProblemDetails extends Error {
  constructor({ type, title, status, detail, instance, ...extensions }) {
    super(detail || title);
    this.type = type;
    this.title = title;
    this.status = status;
    this.detail = detail;
    this.instance = instance;
    Object.assign(this, extensions);
  }

  toJSON() {
    return {
      type: this.type,
      title: this.title,
      status: this.status,
      detail: this.detail,
      instance: this.instance,
      ...this.getExtensions()
    };
  }

  getExtensions() {
    const extensions = {};
    for (const key in this) {
      if (!['type', 'title', 'status', 'detail', 'instance', 'stack', 'message'].includes(key)) {
        extensions[key] = this[key];
      }
    }
    return extensions;
  }
}

// Error factory functions
function validationError(errors, instance) {
  return new ProblemDetails({
    type: 'https://api.example.com/errors/validation-error',
    title: 'Validation Error',
    status: 422,
    detail: 'One or more fields failed validation',
    instance,
    errors
  });
}

function notFoundError(resource, instance) {
  return new ProblemDetails({
    type: 'https://api.example.com/errors/not-found',
    title: 'Resource Not Found',
    status: 404,
    detail: `${resource} not found`,
    instance
  });
}

function conflictError(detail, instance) {
  return new ProblemDetails({
    type: 'https://api.example.com/errors/conflict',
    title: 'Conflict',
    status: 409,
    detail,
    instance
  });
}

// Error handling middleware
app.use((err, req, res, next) => {
  if (err instanceof ProblemDetails) {
    res.status(err.status)
       .set('Content-Type', 'application/problem+json')
       .json(err.toJSON());
  } else {
    // Unexpected error
    console.error('Unexpected error:', err);
    res.status(500)
       .set('Content-Type', 'application/problem+json')
       .json({
         type: 'https://api.example.com/errors/internal-error',
         title: 'Internal Server Error',
         status: 500,
         detail: 'An unexpected error occurred',
         instance: req.path
       });
  }
});

// Using it in routes
app.post('/pets', async (req, res, next) => {
  try {
    const { name, species, age } = req.body;

    // Validation
    const errors = [];
    if (!name || name.length < 1 || name.length > 50) {
      errors.push({
        field: 'name',
        message: 'Name must be between 1 and 50 characters'
      });
    }
    if (!['dog', 'cat', 'bird'].includes(species)) {
      errors.push({
        field: 'species',
        message: 'Species must be one of: dog, cat, bird'
      });
    }
    if (age && (age < 0 || age > 100)) {
      errors.push({
        field: 'age',
        message: 'Age must be between 0 and 100'
      });
    }

    if (errors.length > 0) {
      throw validationError(errors, req.path);
    }

    // Check for conflicts
    const existing = await db.findPetByName(name);
    if (existing) {
      throw conflictError(
        `A pet named "${name}" already exists`,
        req.path
      );
    }

    // Create pet
    const pet = await db.createPet({ name, species, age });
    res.status(201).json(pet);

  } catch (error) {
    next(error);
  }
});

app.get('/pets/:id', async (req, res, next) => {
  try {
    const pet = await db.findPetById(req.params.id);

    if (!pet) {
      throw notFoundError('Pet', req.path);
    }

    res.json(pet);
  } catch (error) {
    next(error);
  }
});

Error Codes: Beyond HTTP Status

Sometimes HTTP status codes aren't specific enough. That's where application-specific error codes come in:

{
  "type": "https://api.example.com/errors/rate-limit",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded your rate limit of 1000 requests per hour",
  "instance": "/pets",
  "errorCode": "RATE_LIMIT_EXCEEDED",
  "retryAfter": 3600,
  "limit": 1000,
  "remaining": 0,
  "resetAt": "2024-03-15T14:00:00Z"
}

Here's a system for managing error codes:

const ErrorCodes = {
  // Validation errors (4000-4099)
  VALIDATION_ERROR: 'VALIDATION_ERROR',
  MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD',
  INVALID_FORMAT: 'INVALID_FORMAT',
  VALUE_OUT_OF_RANGE: 'VALUE_OUT_OF_RANGE',

  // Authentication errors (4100-4199)
  AUTHENTICATION_REQUIRED: 'AUTHENTICATION_REQUIRED',
  INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
  TOKEN_EXPIRED: 'TOKEN_EXPIRED',
  TOKEN_INVALID: 'TOKEN_INVALID',

  // Authorization errors (4200-4299)
  INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS',
  RESOURCE_FORBIDDEN: 'RESOURCE_FORBIDDEN',

  // Resource errors (4300-4399)
  RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
  RESOURCE_ALREADY_EXISTS: 'RESOURCE_ALREADY_EXISTS',
  RESOURCE_CONFLICT: 'RESOURCE_CONFLICT',

  // Rate limiting (4400-4499)
  RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
  QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',

  // Server errors (5000-5099)
  INTERNAL_ERROR: 'INTERNAL_ERROR',
  DATABASE_ERROR: 'DATABASE_ERROR',
  EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR'
};

function createError(code, status, detail, extensions = {}) {
  return new ProblemDetails({
    type: `https://api.example.com/errors/${code.toLowerCase().replace(/_/g, '-')}`,
    title: code.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
    status,
    detail,
    errorCode: code,
    instance: extensions.instance,
    ...extensions
  });
}

// Usage
throw createError(
  ErrorCodes.RATE_LIMIT_EXCEEDED,
  429,
  'You have made too many requests',
  {
    instance: req.path,
    retryAfter: 3600,
    limit: 1000,
    remaining: 0
  }
);

Validation Error Patterns

Validation errors deserve special attention because they're so common. Here's a robust pattern:

class ValidationError extends ProblemDetails {
  constructor(errors, instance) {
    super({
      type: 'https://api.example.com/errors/validation-error',
      title: 'Validation Error',
      status: 422,
      detail: `${errors.length} validation error(s) occurred`,
      instance,
      errors
    });
  }
}

class Validator {
  constructor() {
    this.errors = [];
  }

  required(field, value, message) {
    if (value === undefined || value === null || value === '') {
      this.errors.push({
        field,
        code: 'REQUIRED',
        message: message || `${field} is required`
      });
    }
    return this;
  }

  minLength(field, value, min, message) {
    if (value && value.length < min) {
      this.errors.push({
        field,
        code: 'MIN_LENGTH',
        message: message || `${field} must be at least ${min} characters`,
        constraint: { min }
      });
    }
    return this;
  }

  maxLength(field, value, max, message) {
    if (value && value.length > max) {
      this.errors.push({
        field,
        code: 'MAX_LENGTH',
        message: message || `${field} must be at most ${max} characters`,
        constraint: { max }
      });
    }
    return this;
  }

  pattern(field, value, regex, message) {
    if (value && !regex.test(value)) {
      this.errors.push({
        field,
        code: 'INVALID_FORMAT',
        message: message || `${field} has invalid format`,
        constraint: { pattern: regex.toString() }
      });
    }
    return this;
  }

  oneOf(field, value, allowed, message) {
    if (value && !allowed.includes(value)) {
      this.errors.push({
        field,
        code: 'INVALID_VALUE',
        message: message || `${field} must be one of: ${allowed.join(', ')}`,
        constraint: { allowed }
      });
    }
    return this;
  }

  range(field, value, min, max, message) {
    if (value !== undefined && (value < min || value > max)) {
      this.errors.push({
        field,
        code: 'OUT_OF_RANGE',
        message: message || `${field} must be between ${min} and ${max}`,
        constraint: { min, max }
      });
    }
    return this;
  }

  validate() {
    if (this.errors.length > 0) {
      throw new ValidationError(this.errors);
    }
  }
}

// Usage in routes
app.post('/pets', async (req, res, next) => {
  try {
    const { name, species, age, email } = req.body;

    const validator = new Validator();
    validator
      .required('name', name)
      .minLength('name', name, 1)
      .maxLength('name', name, 50)
      .required('species', species)
      .oneOf('species', species, ['dog', 'cat', 'bird', 'fish'])
      .range('age', age, 0, 100)
      .pattern('email', email, /^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format')
      .validate();

    const pet = await db.createPet({ name, species, age, email });
    res.status(201).json(pet);

  } catch (error) {
    next(error);
  }
});

Handling Errors in Clients

Good error handling isn't just about the server—clients need to handle errors gracefully too:

class APIClient {
  async request(url, options = {}) {
    try {
      const response = await fetch(url, options);

      // Success
      if (response.ok) {
        return await response.json();
      }

      // Parse error response
      const contentType = response.headers.get('content-type');
      let error;

      if (contentType?.includes('application/problem+json')) {
        error = await response.json();
      } else {
        error = {
          status: response.status,
          title: response.statusText,
          detail: await response.text()
        };
      }

      throw new APIError(error);

    } catch (err) {
      if (err instanceof APIError) {
        throw err;
      }
      // Network error or other unexpected error
      throw new APIError({
        status: 0,
        title: 'Network Error',
        detail: err.message
      });
    }
  }

  async createPet(data) {
    try {
      return await this.request('/api/pets', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });
    } catch (error) {
      if (error.status === 422) {
        // Handle validation errors
        console.error('Validation errors:', error.errors);
        throw new ValidationError(error.errors);
      } else if (error.status === 409) {
        // Handle conflict
        console.error('Conflict:', error.detail);
        throw new ConflictError(error.detail);
      } else {
        // Handle other errors
        console.error('API error:', error);
        throw error;
      }
    }
  }
}

class APIError extends Error {
  constructor(problem) {
    super(problem.detail || problem.title);
    this.status = problem.status;
    this.title = problem.title;
    this.detail = problem.detail;
    this.type = problem.type;
    this.errors = problem.errors;
    Object.assign(this, problem);
  }
}

class ValidationError extends APIError {
  constructor(errors) {
    super({
      status: 422,
      title: 'Validation Error',
      errors
    });
  }

  getFieldError(field) {
    return this.errors?.find(e => e.field === field);
  }
}

class ConflictError extends APIError {
  constructor(detail) {
    super({
      status: 409,
      title: 'Conflict',
      detail
    });
  }
}

// Usage
const client = new APIClient();

try {
  const pet = await client.createPet({
    name: 'Fluffy',
    species: 'cat',
    age: 3
  });
  console.log('Pet created:', pet);
} catch (error) {
  if (error instanceof ValidationError) {
    // Show validation errors in UI
    error.errors.forEach(err => {
      showFieldError(err.field, err.message);
    });
  } else if (error instanceof ConflictError) {
    // Show conflict message
    showError('A pet with this name already exists');
  } else {
    // Show generic error
    showError('Something went wrong. Please try again.');
  }
}

Error Logging and Monitoring

Good error handling includes proper logging:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

app.use((err, req, res, next) => {
  // Log error with context
  const logData = {
    error: {
      message: err.message,
      stack: err.stack,
      code: err.errorCode
    },
    request: {
      method: req.method,
      url: req.url,
      headers: req.headers,
      body: req.body,
      ip: req.ip,
      userAgent: req.get('user-agent')
    },
    user: req.user?.id,
    timestamp: new Date().toISOString()
  };

  if (err.status >= 500) {
    logger.error('Server error', logData);
  } else if (err.status >= 400) {
    logger.warn('Client error', logData);
  }

  // Send response
  if (err instanceof ProblemDetails) {
    res.status(err.status)
       .set('Content-Type', 'application/problem+json')
       .json(err.toJSON());
  } else {
    res.status(500)
       .set('Content-Type', 'application/problem+json')
       .json({
         type: 'https://api.example.com/errors/internal-error',
         title: 'Internal Server Error',
         status: 500,
         detail: 'An unexpected error occurred'
       });
  }
});

Security Considerations

Be careful not to leak sensitive information in errors:

function sanitizeError(err, isProduction) {
  if (!isProduction) {
    // Development: show full details
    return err;
  }

  // Production: hide sensitive details
  if (err.status >= 500) {
    return new ProblemDetails({
      type: 'https://api.example.com/errors/internal-error',
      title: 'Internal Server Error',
      status: 500,
      detail: 'An unexpected error occurred',
      // Don't include: stack traces, database errors, file paths
    });
  }

  // Client errors: safe to show
  return err;
}

app.use((err, req, res, next) => {
  const sanitized = sanitizeError(err, process.env.NODE_ENV === 'production');
  res.status(sanitized.status).json(sanitized.toJSON());
});

Wrapping Up

Good error handling is about empathy. When something goes wrong, developers need clear, actionable information to fix the problem.

Key takeaways:

  1. Use appropriate HTTP status codes
  2. Implement RFC 9457 Problem Details for consistency
  3. Include specific error codes for programmatic handling
  4. Provide detailed validation errors with field-level feedback
  5. Log errors with context for debugging
  6. Handle errors gracefully in clients
  7. Don't leak sensitive information in production

Start with a solid error handling foundation, and your API will be much easier to work with—both for you and for everyone who uses it.