Skip to main content

Standardizing REST API Error Responses using RFC 7807 Problem Details

 Frontend developers and third-party consumers waste countless hours writing defensive code to handle heterogeneous API error payloads. In a distributed architecture, Service A might return { "error": "User not found" }, while Service B responds with { "status": 404, "messages": ["Invalid ID"] }. This inconsistency results in brittle integrations, bloated client-side error handling, and a poor developer experience.

Implementing REST API error handling best practices requires a strict, system-wide contract for the failure path. Relying on ad-hoc error formats across disparate teams is unsustainable. The solution to microservices error standardization is adopting an industry-standard specification: RFC 7807 Problem Details for HTTP APIs.

The Root Cause of Inconsistent Error Payloads

In a microservices architecture, polyglot environments are the norm. Different teams choose different frameworks—Spring Boot, Express.js, ASP.NET Core, or Go standard library. Each of these frameworks implements API exception handling differently out of the box.

Spring Boot defaults to throwing a DefaultErrorAttributes payload. Express.js often leaks an HTML stack trace if unhandled, or returns plain text. ASP.NET Core has its own validation problem details format.

While architects rigorously define the success path (HTTP 2xx) using OpenAPI specifications, the failure path (HTTP 4xx and 5xx) is frequently treated as an afterthought. Without an API Gateway enforcing a standard schema, or a shared underlying library standardizing exceptions at the service level, the client is forced to guess the structure of an error response based on the endpoint they are calling.

The Fix: Implementing RFC 7807

RFC 7807 defines a standard payload format (application/problem+json) for HTTP APIs to communicate problem details. By enforcing this schema across all microservices, clients can rely on a single, predictable interface for error handling.

Here is a robust, production-ready implementation of RFC 7807 using TypeScript and Express.js.

1. Define the Problem Details Interface and Base Error Class

First, define the standard schema and an abstract base class. This ensures all custom exceptions conform strictly to the RFC 7807 specification.

// problem-details.ts
export interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail: string;
  instance?: string;
  [key: string]: unknown; // Support for extension members
}

export abstract class ProblemDetailsError extends Error {
  public readonly type: string;
  public readonly title: string;
  public readonly status: number;
  public readonly detail: string;
  public readonly instance?: string;
  public readonly extensions?: Record<string, unknown>;

  constructor(
    type: string,
    title: string,
    status: number,
    detail: string,
    instance?: string,
    extensions?: Record<string, unknown>
  ) {
    super(detail);
    this.type = type;
    this.title = title;
    this.status = status;
    this.detail = detail;
    this.instance = instance;
    this.extensions = extensions;
    
    Object.setPrototypeOf(this, new.target.prototype);
  }

  public toJSON(): ProblemDetails {
    return {
      type: this.type,
      title: this.title,
      status: this.status,
      detail: this.detail,
      ...(this.instance && { instance: this.instance }),
      ...this.extensions,
    };
  }
}

2. Create Specific Domain Exceptions

Next, extend the base class to create specific, semantically meaningful exceptions for your business logic.

// exceptions/validation.error.ts
import { ProblemDetailsError } from '../problem-details';

export interface ValidationErrorItem {
  field: string;
  message: string;
}

export class ValidationError extends ProblemDetailsError {
  constructor(errors: ValidationErrorItem[], instance?: string) {
    super(
      'https://api.example.com/probs/validation-error',
      'Your request parameters didn\'t validate.',
      400,
      'One or more fields failed validation. See the "invalidParams" array for details.',
      instance,
      { invalidParams: errors }
    );
  }
}

3. Centralize API Exception Handling Middleware

Implement a global error handler that catches exceptions, formats them into the RFC 7807 structure, and sets the correct HTTP Content-Type header.

// middleware/error-handler.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { ProblemDetailsError } from '../problem-details';

export const problemDetailsMiddleware = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  if (res.headersSent) {
    return next(err);
  }

  // Set the specific RFC 7807 content type
  res.setHeader('Content-Type', 'application/problem+json');

  if (err instanceof ProblemDetailsError) {
    res.status(err.status).json(err.toJSON());
    return;
  }

  // Fallback for unhandled, generic exceptions
  const genericProblem = {
    type: 'https://api.example.com/probs/internal-server-error',
    title: 'Internal Server Error',
    status: 500,
    detail: 'An unexpected condition was encountered.',
    instance: req.originalUrl,
  };

  // Log the raw error for internal tracing, but do not leak to client
  console.error(`[Unhandled Exception] ${req.method} ${req.originalUrl}`, err);

  res.status(500).json(genericProblem);
};

4. Client-Side Consumption

With the backend standardized, frontend developers can write a single, reusable API client that intelligently parses errors.

// api-client.ts
import { ProblemDetails } from './types';

export async function fetchWithProblemDetails(url: string, options?: RequestInit) {
  const response = await fetch(url, options);

  if (!response.ok) {
    const contentType = response.headers.get('Content-Type');
    
    if (contentType?.includes('application/problem+json')) {
      const problem: ProblemDetails = await response.json();
      // Handle standard problem details centrally
      throw new Error(`[${problem.status}] ${problem.title}: ${problem.detail}`);
    }

    // Fallback for non-compliant endpoints
    throw new Error(`HTTP Error: ${response.status}`);
  }

  return response.json();
}

Deep Dive: Why the RFC 7807 Schema Works

The power of RFC 7807 Problem Details lies in its strict property definitions. By adhering to this schema, you remove ambiguity from system communications.

The Five Core Properties

  1. type (string): A URI reference that identifies the problem type. It should resolve to human-readable documentation explaining the error. This is the primary key for the client to differentiate error types programmatically.
  2. title (string): A short, human-readable summary of the problem type. It should not change from occurrence to occurrence of the problem.
  3. status (number): The HTTP status code generated by the origin server for this occurrence. This ensures the payload matches the HTTP transport layer header.
  4. detail (string): A human-readable explanation specific to this occurrence of the problem. It helps developers debug the exact issue.
  5. instance (string, optional): A URI reference that identifies the specific occurrence of the problem. This is invaluable for correlating errors with distributed tracing systems (e.g., passing a trace ID or request URI).

Extension Members

RFC 7807 allows for additional properties to be added to the top level of the JSON object. In the ValidationError example above, we added an invalidParams array. This flexibility allows you to attach domain-specific data without breaking the standard contract.

Common Pitfalls and Edge Cases

Leaking Sensitive Internal State

When writing a fallback exception handler, never pass the raw Error.message or Error.stack into the detail property of a 500 Internal Server Error. This leaks database connection strings, file paths, or third-party API keys to consumers. Always map unknown exceptions to a generic, safe problem detail.

Mismatched HTTP Status Codes

A frequent anti-pattern is returning an HTTP 200 OK header while the problem details payload indicates a status of 400 or 500. This breaks intermediate HTTP caches, CDNs, and API Gateways. The status property in the JSON payload must strictly match the HTTP response code header.

Using "about:blank" Inappropriately

The specification permits using about:blank for the type property if the HTTP status code completely describes the error (e.g., a standard 404 Not Found). However, developers often abuse this for domain-specific errors. If you return an HTTP 403 Forbidden because a user's subscription expired, the type should be https://api.example.com/probs/subscription-expired, not about:blank.

API Gateway Overwrites

If you utilize an API Gateway (like Kong, APISIX, or AWS API Gateway), ensure your routing configurations do not blindly swallow upstream error payloads. Gateways should be configured to proxy application/problem+json responses transparently, or transform their own native errors into RFC 7807 format to maintain the contract with the client.

Conclusion

Standardizing REST API error responses is not just a cosmetic improvement; it is a foundational requirement for robust system architecture. Implementing RFC 7807 Problem Details eliminates integration guesswork, reduces client-side boilerplate, and streamlines debugging in distributed environments. By treating the failure path with the same architectural rigor as the success path, you significantly increase developer velocity and system reliability.