Skip to main content
API Design Mastery

Error Design

Ravinder··5 min read
API DesignRESTGraphQLgRPCError Handling
Share:
Error Design

Bad API errors are a productivity tax every developer pays on every integration. You get a 400 with {"error": "invalid input"}, you spend 20 minutes reading source code or asking on Slack to figure out which field was wrong and why. Multiply that by every edge case your client can hit and you have wasted days of engineering time that could have been avoided by a well-designed error contract. Error design is not an afterthought — it is part of the API surface.

The Problem with Ad-Hoc Error Formats

Most APIs invent their own error shape:

{ "error": "not_found" }
{ "message": "User does not exist", "code": 404 }
{ "errors": [{"msg": "required", "field": "email"}] }
{ "success": false, "errorMessage": "Unauthorized" }

Each is slightly different. Clients must write custom parsing logic for each API they integrate. Shared error-handling middleware becomes impossible. Observability tooling cannot extract structured error data without per-API configuration.

RFC 7807 Problem Details

RFC 7807 (and its successor RFC 9457) defines a standard JSON body for HTTP errors:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
 
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The request body contains invalid fields.",
  "instance": "/orders/checkout#2025-10-15T09:00:00Z",
  "errors": [
    {
      "field": "items[0].quantity",
      "code": "MUST_BE_POSITIVE",
      "message": "Quantity must be greater than zero."
    },
    {
      "field": "shippingAddress.postalCode",
      "code": "INVALID_FORMAT",
      "message": "Postal code 'ABCD' does not match pattern '^[0-9]{5}$'."
    }
  ]
}

Key fields:

  • type — a URI that uniquely identifies the problem class. The URI should resolve to human-readable documentation.
  • title — a short, stable, human-readable summary. Do not put dynamic data here.
  • status — the HTTP status code, mirrored in the body for clients that lose the status line (some proxies/SDKs do).
  • detail — a human-readable explanation for this specific occurrence.
  • instance — a URI identifying this specific occurrence (useful for support tickets).

Extensions (like errors above) are allowed; just keep them consistent within a problem type.

Error Code Taxonomy

Define a finite set of machine-readable error codes. Clients branch on codes, not on status text or message strings.

graph TD A[HTTP 4xx/5xx] --> B{status} B -- 400 --> C[BAD_REQUEST] C --> C1[MISSING_FIELD] C --> C2[INVALID_FORMAT] C --> C3[CONSTRAINT_VIOLATION] B -- 401 --> D[UNAUTHENTICATED] D --> D1[TOKEN_EXPIRED] D --> D2[TOKEN_INVALID] B -- 403 --> E[UNAUTHORIZED] E --> E1[INSUFFICIENT_SCOPE] E --> E2[RESOURCE_FORBIDDEN] B -- 404 --> F[NOT_FOUND] B -- 409 --> G[CONFLICT] G --> G1[DUPLICATE_KEY] G --> G2[VERSION_MISMATCH] B -- 429 --> H[RATE_LIMITED] B -- 500 --> I[INTERNAL_ERROR]

Expose codes in your API reference with stable names. Never rename a code — clients compile it into switch statements. If behavior changes, add a new code.

Validation Errors in Detail

Validation failures are the most common 4xx. Return all errors at once, not just the first:

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
 
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Failed",
  "status": 400,
  "detail": "3 fields failed validation.",
  "errors": [
    {"pointer": "/email", "code": "REQUIRED", "message": "Email is required."},
    {"pointer": "/password", "code": "TOO_SHORT", "message": "Password must be at least 12 characters.", "minimum": 12},
    {"pointer": "/birthDate", "code": "INVALID_FORMAT", "message": "Expected ISO 8601 date (YYYY-MM-DD)."}
  ]
}

Use JSON Pointer (RFC 6901) in the pointer field — /items/0/quantity is unambiguous; items[0].quantity is not universally parsed.

gRPC Error Design

gRPC has its own status codes and a google.rpc.Status type for rich errors:

// google/rpc/status.proto (conceptual)
message Status {
  int32 code = 1;       // google.rpc.Code enum value
  string message = 2;  // developer-facing message
  repeated google.protobuf.Any details = 3;
}

Use the details field to attach BadRequest, ErrorInfo, or custom message types:

{
  "code": 3,
  "message": "Invalid argument: quantity must be positive",
  "details": [
    {
      "@type": "type.googleapis.com/google.rpc.BadRequest",
      "fieldViolations": [
        {
          "field": "items[0].quantity",
          "description": "Quantity must be greater than zero."
        }
      ]
    }
  ]
}

Map gRPC status codes to HTTP status codes at your gateway for REST/gRPC hybrid APIs.

GraphQL Error Design

GraphQL errors live in the errors array, not in the HTTP status:

{
  "data": null,
  "errors": [
    {
      "message": "Order not found",
      "locations": [{"line": 2, "column": 3}],
      "path": ["order"],
      "extensions": {
        "code": "NOT_FOUND",
        "type": "https://api.example.com/problems/not-found",
        "orderId": "ord_missing123"
      }
    }
  ]
}

Use extensions for machine-readable data. Standardize the code field across your schema.

What Not to Expose

Server errors should never leak:

// BAD — leaks implementation details
{
  "error": "NullPointerException at OrderService.java:142",
  "stack": "java.lang.NullPointerException\n\tat com.example..."
}
 
// GOOD — safe, debuggable via trace ID
{
  "type": "https://api.example.com/problems/internal-error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred. Please contact support.",
  "instance": "https://api.example.com/errors/trace/01J8K2MNPQ"
}

Log the full stack trace server-side, indexed by the trace ID. The client gets the trace ID to share with support; the internals stay private.

Key Takeaways

  • Adopt RFC 7807 / RFC 9457 as your error envelope — it is a standard, not a preference, and clients can write generic handling code against it.
  • Use type URIs that resolve to documentation; they turn errors into self-service debugging tools.
  • Define a finite, stable taxonomy of machine-readable error codes; clients branch on codes, not messages.
  • Return all validation errors in one response, not just the first — each round-trip costs the developer minutes of frustration.
  • Use JSON Pointer (/items/0/quantity) for field references instead of custom dot-notation strings.
  • Never expose stack traces or internal service names in error responses; use trace IDs to bridge client reports to server logs.
Share: