REST API Error Handling Best Practices: Your 500s Are a Lie

2026-04-13 · Nico Brandt

You call an API. Something goes wrong. You get back {"error": "Something went wrong"}. No status code context, no error code, no detail about which field failed or why. You open the logs, grep for a request ID you don’t have, and start guessing.

That’s what your API’s consumers deal with every time you return a generic 500. The fix isn’t complicated — these REST API error handling best practices come down to a structured error pattern with the right status codes, a standard response format, and centralized middleware that enforces both. Here’s the implementation in Express and Go, plus the client-side code most articles forget.

The 7 HTTP Status Codes You Actually Need

Most APIs get this wrong in one of two ways: returning 500 for bad user input, or returning 200 with an embedded error field. Both break the contract that HTTP status codes create in a REST API. A 500 tells the caller your server crashed. A 200 tells every HTTP tool — monitoring, caching, retry logic — that the request succeeded.

Seven codes cover 99% of REST API error handling best practices:

Code Meaning When to use
400 Bad Request Malformed JSON, missing required header — structurally broken
401 Unauthorized No credentials or expired token
403 Forbidden Authenticated but not authorized for this resource
404 Not Found Resource doesn’t exist
409 Conflict Duplicate entry, stale update, version mismatch
422 Unprocessable Entity Valid JSON but invalid data — email field contains “not-an-email”
429 Too Many Requests Rate limited

The distinction that trips everyone up: 400 means the request is structurally unparseable. 422 means the structure is fine but the data fails validation. If you’re returning 400 for “email is required,” you’re technically misrepresenting the problem — the JSON parsed fine, the field was just empty.

Right status code is step one. But a 422 with no body is barely better than a 500. What goes in the response matters more.

Use RFC 9457 Problem Details (Not Your Own Format)

The best error response format for a REST API is RFC 9457 Problem Details — a standard with five fields (type, title, status, detail, instance) and the content type application/problem+json. It gives clients a machine-readable error type, a human-readable title, and specific detail about what went wrong, all in a consistent structure.

RFC 9457 (published June 2023, superseding RFC 7807) defines the standard API error response format. Five fields, one content type, and you never have to invent your own schema again:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "2 fields failed validation",
  "instance": "/users/signup",
  "errors": [
    { "field": "email", "message": "Must be a valid email address", "code": "user.invalid_email" },
    { "field": "password", "message": "Must be at least 8 characters", "code": "user.weak_password" }
  ]
}

type identifies the error class — making it a URL that resolves to documentation is a nice touch (Stripe and GitHub do this). detail is specific to this occurrence. errors is an extension field for validation details. You can add any extension fields your consumers need: error_code for machine-readable identifiers, retry_after for rate limits, whatever your domain requires.

The pragmatic take: if you already have a consistent format your consumers rely on, don’t break their integrations to adopt RFC 9457. But if you’re starting fresh or your errors are a mess, this is the right default. It’s what the HTTP caching layer already expects from your responses.

Now show me the code. Fair enough.

Express: Error Classes and Centralized Middleware

The pattern is three pieces: error classes, a centralized handler, and async wrapping. Structured API errors need all three to stay consistent across every endpoint.

// errors.js
class AppError extends Error {
  constructor(statusCode, errorCode, title, detail) {
    super(detail);
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.title = title;
    this.detail = detail;
  }

  toRFC9457(path) {
    return {
      type: `https://api.example.com/errors/${this.errorCode}`,
      title: this.title,
      status: this.statusCode,
      detail: this.detail,
      instance: path,
    };
  }
}

class ValidationError extends AppError {
  constructor(errors) {
    super(422, 'validation.failed', 'Validation Failed',
      `${errors.length} field(s) failed validation`);
    this.fieldErrors = errors;
  }

  toRFC9457(path) {
    return { ...super.toRFC9457(path), errors: this.fieldErrors };
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(404, 'resource.not_found', 'Not Found',
      `${resource} does not exist`);
  }
}

The centralized middleware catches everything. Register it last in your Express chain:

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  if (err instanceof AppError) {
    return res
      .status(err.statusCode)
      .type('application/problem+json')
      .json(err.toRFC9457(req.path));
  }

  // Unknown error — log it, but don't leak internals
  console.error('Unhandled error:', err);
  res.status(500).type('application/problem+json').json({
    type: 'https://api.example.com/errors/internal',
    title: 'Internal Server Error',
    status: 500,
    detail: 'An unexpected error occurred',
    instance: req.path,
  });
}

Route handlers throw specific errors. The middleware formats them. No error formatting scattered across fifty route files:

app.post('/users', catchAsync(async (req, res) => {
  const errors = validate(req.body);
  if (errors.length) throw new ValidationError(errors);

  const existing = await db.findByEmail(req.body.email);
  if (existing) throw new AppError(409, 'user.email_taken',
    'Conflict', 'A user with this email already exists');

  const user = await db.createUser(req.body);
  res.status(201).json(user);
}));

Validation errors return ALL failures at once — never one at a time. Making the caller play whack-a-mole with sequential “fix this field” responses is a code review red flag that wastes everyone’s time.

That handles Express. But what if you’re building in Go?

Go: Same Pattern, Different Idioms

Go doesn’t have exceptions, but API error handling patterns translate naturally: an error type and a handler wrapper.

// errors.go
type AppError struct {
    Status    int            `json:"status"`
    Type      string         `json:"type"`
    Title     string         `json:"title"`
    Detail    string         `json:"detail"`
    ErrorCode string         `json:"error_code,omitempty"`
    Errors    []FieldError   `json:"errors,omitempty"`
}

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
    Code    string `json:"code"`
}

func (e *AppError) Error() string { return e.Detail }

func NewValidationError(fields []FieldError) *AppError {
    return &AppError{
        Status: 422, Type: "/errors/validation-failed",
        Title: "Validation Failed",
        Detail: fmt.Sprintf("%d field(s) failed validation", len(fields)),
        Errors: fields,
    }
}

func NewNotFoundError(resource string) *AppError {
    return &AppError{
        Status: 404, Type: "/errors/not-found",
        Title: "Not Found", Detail: resource + " does not exist",
    }
}

The middleware is a handler wrapper — a function that takes a handler returning error and converts it to a standard http.HandlerFunc:

// middleware.go
func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        err := fn(w, r)
        if err == nil {
            return
        }

        w.Header().Set("Content-Type", "application/problem+json")

        var appErr *AppError
        if errors.As(err, &appErr) {
            appErr.Type = "https://api.example.com" + appErr.Type
            w.WriteHeader(appErr.Status)
            json.NewEncoder(w).Encode(appErr)
            return
        }

        // Unknown error — log, don't leak
        log.Printf("unhandled: %v", err)
        w.WriteHeader(500)
        json.NewEncoder(w).Encode(&AppError{
            Status: 500, Title: "Internal Server Error",
            Detail: "An unexpected error occurred",
        })
    }
}

The key insight: the JSON output is identical to Express. Same structure, same fields, same application/problem+json content type. A client consuming your API doesn’t know or care which stack produced it. If you’re deciding between Go and other stacks for APIs, this portability is the point.

Both server implementations done. But structured errors only matter if the client actually reads the structure.

Client-Side: Handle These Errors in fetch

Every competitor article explains how to produce errors. None explain how to consume them. That’s like writing a TypeScript guide that covers type definitions but never shows type narrowing.

class ApiError extends Error {
  constructor(problem) {
    super(problem.detail);
    this.status = problem.status;
    this.errorCode = problem.error_code;
    this.title = problem.title;
    this.fieldErrors = problem.errors || [];
  }
}

async function apiFetch(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    headers: { 'Content-Type': 'application/json', ...options.headers },
  });

  if (!res.ok) {
    const problem = await res.json();
    throw new ApiError(problem);
  }

  return res.json();
}

Now your form handler can map validation errors directly to fields:

try {
  await apiFetch('/users', { method: 'POST', body: JSON.stringify(formData) });
} catch (err) {
  if (err instanceof ApiError && err.status === 422) {
    err.fieldErrors.forEach(({ field, message }) => showFieldError(field, message));
  } else if (err instanceof ApiError && err.status === 429) {
    // Structured errors make retry logic trivial
    setTimeout(() => retry(), parseRetryAfter(err));
  }
}

The structured format on the server makes the client code boring. Boring is good. Boring means predictable. That’s the whole payoff.

One thing left before you ship this.

Don’t Leak Your Internals

Structured errors are a security surface. In production, strip:

Auth errors should be deliberately vague. “Invalid credentials” — not “User not found” or “Wrong password.” The difference prevents user enumeration attacks, which matters more than most devs realize when choosing their auth strategy.

Your error middleware already handles this — unknown errors get a generic 500 with no internals. The AppError classes only expose what you explicitly put in them. That’s the security boundary.

For error codes, use domain.specific_error naming: auth.token_expired, user.email_taken, order.insufficient_stock. Machine-readable for clients and AI agents consuming your API. Human-readable for debugging. No implementation details leaked.

REST API Error Handling Best Practices: Start With the Middleware

Remember that {"error": "Something went wrong"} response? Your API consumers won’t see it again. They’ll get the exact status code, the specific error code, the field that failed, and what to do about it.

If you have an existing API with inconsistent errors, don’t rewrite everything at once. Add the centralized middleware and error classes first. Apply them to new endpoints. Migrate old endpoints as you touch them — the same incremental refactoring approach that actually ships.

The full pattern in one sentence: right status code, RFC 9457 body, centralized middleware, client-side typed errors.

Copy the Express or Go implementation above. Adapt the error codes to your domain. Apply these REST API error handling best practices and your API will have better error handling than most production services within the hour.