惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX - 技术
D
DataBreaches.Net
阮一峰的网络日志
阮一峰的网络日志
Recent Announcements
Recent Announcements
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
T
The Exploit Database - CXSecurity.com
Simon Willison's Weblog
Simon Willison's Weblog
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
K
Kaspersky official blog
F
Fortinet All Blogs
GbyAI
GbyAI
Forbes - Security
Forbes - Security
The Cloudflare Blog
博客园 - 司徒正美
博客园_首页
量子位
Schneier on Security
Schneier on Security
G
GRAHAM CLULEY
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Proofpoint News Feed
N
News | PayPal Newsroom
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
T
Tor Project blog
V
Vulnerabilities – Threatpost
Y
Y Combinator Blog
Jina AI
Jina AI
Help Net Security
Help Net Security
T
Threat Research - Cisco Blogs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
C
Cybersecurity and Infrastructure Security Agency CISA
Project Zero
Project Zero
N
News and Events Feed by Topic
I
Intezer
B
Blog
美团技术团队
C
CERT Recently Published Vulnerability Notes
NISL@THU
NISL@THU
L
LINUX DO - 最新话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Blog — PlanetScale
Blog — PlanetScale
AWS News Blog
AWS News Blog
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
雷峰网
雷峰网
有赞技术团队
有赞技术团队

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
Stop Returning `{error: "Something went wrong"}`: RFC 9457 Problem Details for HTTP APIs
The Practica · 2026-06-09 · via The Practical Developer

Every API returns errors. Almost no two APIs return them the same way.

Your payment service returns {"error": "card_declined"}. The inventory service returns {"code": 4003, "message": "Insufficient stock"}. The user service returns {"errors": [{"field": "email", "message": "already taken"}]}. The auth gateway returns a bare string body with no JSON at all. Every client that talks to more than one of these has to write a custom parser for each. Every new hire on the team has to learn the undocumented error dialect of every service. Every integration test has to assert against a bespoke shape that nobody wrote down.

This is not a cosmetic problem. It is a coupling problem. When error responses have no standard structure, clients hard-code parsing logic for each service. When a service changes its error field from message to detail, every dependent breaks. When there is no standard place to put debugging metadata like a trace ID or validation errors, teams invent their own conventions and the sprawl compounds.

RFC 9457 (which supersedes the older RFC 7807) defines a standard HTTP error response format called Problem Details. It is the most widely available tool you are not using. It is supported out of the box by ASP.NET Core, Spring Boot, and Python’s http.client. It takes about 50 lines of middleware in Express or Fastify. And it eliminates an entire class of integration friction that nobody budgets for.

This post covers the RFC, the middleware to implement it in any Node.js framework, and the conventions that make Problem Details actually useful in production.

The shape of a problem

A Problem Details response is a JSON object with a tiny set of standard keys. Here is the minimum viable payload:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient Funds",
  "status": 402,
  "detail": "Your account balance is $5.00 but the transfer requires $15.00.",
  "instance": "/api/transfers/abc-123"
}

Every client that understands Problem Details can read this response without prior knowledge of your API’s error format. Here is what each field means:

  • type (string, required): A URI that identifies the problem type. When dereferenced, it should provide human-readable documentation for this specific error. It is the closest thing Problem Details has to an error code, and it is the field clients should switch on.
  • title (string, required): A short, human-readable summary of the problem type. It should not change between occurrences of the same problem. Think of it as the stable label for this error category.
  • status (number, required): The HTTP status code. Including it in the body means the body is self-describing, even if the response passes through a proxy or load balancer that rewrites the HTTP status.
  • detail (string, optional): A human-readable explanation specific to this occurrence. This is where you put the per-request context like the actual balance vs. the required amount.
  • instance (string, optional): A URI that identifies the specific occurrence of the problem. This is where you put the request path or the request ID.

The genius of this design is that it is both minimal and extensible. The five standard fields give every client a common vocabulary. The type URI gives you a way to document and categorize errors without coupling the client to stringly typed codes. And you can add any extra fields you need without breaking clients that only read the standard ones.

What Problem Details replaces

Before RFC 9457, most APIs ship one of three error patterns, each with a different flaw:

Pattern 1: The bare string or numeric code

{
  "error": "invalid_input"
}

The problem: every possible error goes in the same flat field. There is no room for context. The client cannot distinguish “the email field is missing” from “the email field has the wrong format” unless you start appending suffixes, and there is no standard place to put the offending value or the field name. The format collapses under any realistic error surface.

Pattern 2: The kitchen-sink error object

{
  "success": false,
  "errorCode": "ERR-4023",
  "errorMessage": "Insufficient funds",
  "data": null,
  "timestamp": "2026-06-09T10:00:00Z",
  "path": "/api/transfers"
}

The problem: everything is ad-hoc. Every service picks different field names. errorMessage in one service is message in another. errorCode vs code vs errCode. Clients must learn each service’s dialect separately. The success boolean is cargo-culted from RPC responses and is always false in an error response anyway, making it redundant at best and misleading at worst.

Pattern 3: The standardized-but-proprietary format

Some teams define their own internal error standard. This is better than the alternatives, but it creates a maintenance burden. You have to write docs, enforce the convention in code reviews, and update every service. And the resulting format will not be understood by external clients, third-party tools, or future team members who learned RFC 9457.

Problem Details solves all three cases with a format that is defined by an IETF standard, extensible by design, and documented enough that clients can handle it generically.

The middleware you need

Here is an Express middleware that converts thrown errors into Problem Details responses. It handles three cases: explicit ProblemDetail errors from your own code, validation errors (from Zod or similar), and unexpected server errors that should never leak internals.

import { Request, Response, NextFunction } from 'express';

// A typed error class that carries Problem Details fields
export class ProblemDetail extends Error {
  constructor(
    public readonly type: string,
    public readonly title: string,
    public readonly status: number,
    detail?: string,
    public readonly instance?: string,
    public readonly extra?: Record<string, unknown>
  ) {
    super(detail ?? title);
    this.name = 'ProblemDetail';
  }
}

// Helper to create common problem types
ProblemDetail.notFound = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/not-found',
    'Resource Not Found',
    404,
    detail
  );

ProblemDetail.validation = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/validation-error',
    'Validation Error',
    422,
    detail
  );

ProblemDetail.conflict = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/conflict',
    'Conflict',
    409,
    detail
  );

ProblemDetail.rateLimited = (detail?: string) =>
  new ProblemDetail(
    'https://api.example.com/problems/rate-limited',
    'Too Many Requests',
    429,
    detail
  );

// Express error-handling middleware
export function problemDetailsMiddleware(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
): void {
  if (err instanceof ProblemDetail) {
    const body: Record<string, unknown> = {
      type: err.type,
      title: err.title,
      status: err.status,
      detail: err.message,
      instance: err.instance ?? req.originalUrl,
    };

    // Merge any extra fields into the response body
    if (err.extra) {
      Object.assign(body, err.extra);
    }

    res.setHeader('Content-Type', 'application/problem+json');
    res.status(err.status).json(body);
    return;
  }

  // For unexpected errors, return a generic 500 without leaking details
  // Log the real error server-side, then return a safe problem
  console.error('Unhandled error:', err);

  const body = {
    type: 'https://api.example.com/problems/internal-error',
    title: 'Internal Server Error',
    status: 500,
    detail: 'An unexpected error occurred. Contact support if this persists.',
    instance: req.originalUrl,
  };

  res.setHeader('Content-Type', 'application/problem+json');
  res.status(500).json(body);
}

The critical detail here is the Content-Type header. RFC 9457 specifies application/problem+json as the media type. Setting it correctly means any generic Problem Details client library or middleware can detect and parse the response without knowing your API. This is the same principle behind application/json vs application/ld+json: the media type tells the consumer what schema to expect.

Here is how you register the middleware and use it:

import express from 'express';
import { ProblemDetail, problemDetailsMiddleware } from './problem-detail';

const app = express();

app.post('/api/transfers', (req, res) => {
  const { amount, fromAccount, toAccount } = req.body;

  if (amount <= 0) {
    throw ProblemDetail.validation('Transfer amount must be positive.');
  }

  const balance = getBalance(fromAccount);
  if (balance < amount) {
    throw ProblemDetail.notFound('Account not found.'); // wrong! use conflict
  }

  // Actually, let me fix that
  if (balance < amount) {
    throw new ProblemDetail(
      'https://api.example.com/problems/insufficient-funds',
      'Insufficient Funds',
      402,
      `Your balance is $${balance}. The transfer requires $${amount}.`,
      req.originalUrl,
      { balance, required: amount }
    );
  }

  // proceed with transfer...
  res.json({ status: 'ok' });
});

// Error middleware must be registered last
app.use(problemDetailsMiddleware);

app.listen(3000);

When a client sends a transfer for more than the balance, they get back:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient Funds",
  "status": 402,
  "detail": "Your balance is $5.00. The transfer requires $15.00.",
  "instance": "/api/transfers",
  "balance": 5,
  "required": 15
}

The client can check type to decide how to handle the error (show a “low balance” UI vs. a generic error), use detail for the user-facing message, and inspect balance and required for more granular handling. All of this works without prior documentation of the API’s error format.

Validation errors with RFC 9457

The most common error in any CRUD API is invalid input. RFC 9457 does not define a standard way to represent per-field validation errors, but its extension mechanism makes it straightforward. The convention that has emerged in practice uses a validation_errors array on the response:

import { z } from 'zod';

const transferSchema = z.object({
  amount: z.number().positive('Amount must be positive.'),
  fromAccount: z.string().min(1, 'Source account is required.'),
  toAccount: z.string().min(1, 'Destination account is required.'),
});

app.post('/api/transfers', (req, res, next) => {
  const result = transferSchema.safeParse(req.body);

  if (!result.success) {
    const validationErrors = result.error.issues.map((issue) => ({
      field: issue.path.join('.'),
      message: issue.message,
      code: issue.code,
    }));

    throw new ProblemDetail(
      'https://api.example.com/problems/validation-error',
      'Validation Error',
      422,
      'One or more fields failed validation.',
      req.originalUrl,
      { validation_errors: validationErrors }
    );
  }

  // result.data is now typed and valid
  // proceed with transfer...
});

The response looks like this:

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "One or more fields failed validation.",
  "instance": "/api/transfers",
  "validation_errors": [
    {
      "field": "amount",
      "message": "Amount must be positive.",
      "code": "too_small"
    },
    {
      "field": "toAccount",
      "message": "Destination account is required.",
      "code": "too_small"
    }
  ]
}

The validation_errors extension is not part of the RFC, but it follows the RFC’s guidance: standard fields stay standard, and extensions use descriptive names that do not collide with future RFC fields. A client that only reads standard fields gets type, title, status, detail, and instance. A client that knows about the extension reads validation_errors for field-level rendering.

Fastify has it built in

If you use Fastify instead of Express, you get most of this for free. Fastify has a setErrorHandler API and a built-in schema validation system that can produce Problem Details out of the box:

import Fastify from 'fastify';

const app = Fastify({
  schemaErrorFormatter: (errors, dataVar) => {
    const validationErrors = errors.map((err) => ({
      field: err.instancePath.replace(/^\//, ''),
      message: err.message,
    }));

    return new Error(
      JSON.stringify({
        type: 'https://api.example.com/problems/validation-error',
        title: 'Validation Error',
        status: 422,
        detail: 'One or more fields failed validation.',
        instance: dataVar,
        validation_errors: validationErrors,
      })
    );
  },
});

app.setErrorHandler((error, request, reply) => {
  let problem: Record<string, unknown>;

  try {
    problem = JSON.parse(error.message);
  } catch {
    problem = {
      type: 'https://api.example.com/problems/internal-error',
      title: 'Internal Server Error',
      status: 500,
      detail: 'An unexpected error occurred.',
      instance: request.url,
    };
  }

  reply
    .header('Content-Type', 'application/problem+json')
    .status((problem.status as number) || 500)
    .send(problem);
});

Fastify’s schema validation runs on every request automatically. When validation fails, the schemaErrorFormatter is called, and the custom error handler serializes the Problem Details response. The entire integration is about two dozen lines.

What to put in type

The type URI is the most important design decision in your Problem Details implementation. It is the stable identifier that clients use to distinguish error categories. Getting it right means clients never have to parse your detail field programmatically.

Here are the rules:

Use absolute URIs, not short codes. A type of https://api.example.com/problems/insufficient-funds is better than INSUFFICIENT_FUNDS or ERR-1002. URIs are globally unique, dereferenceable (you can put docs at that URL), and they do not pollute a shared namespace. Short codes collide easily when you merge services or acquire another company’s API.

Group by cause, not by HTTP status. Two different 422 errors should have different type URIs. A validation error where the email is missing is a different problem from a validation error where the credit card is expired. Give them separate types so clients can handle them differently.

Version the URI if the semantics change. If your insufficient-funds problem changes its detail format or adds new extension fields, increment the URL path: v1/insufficient-funds becomes v2/insufficient-funds. Clients that still reference the old URI get the old documentation. This is especially useful for public APIs where you cannot force clients to upgrade.

Document every type at its URI. The URL in type should resolve to a page (HTML or Markdown) that explains the problem, lists all extension fields, and shows example responses. You can host these statically alongside your API docs. A client developer who sees a problem type they have not encountered can open the URL and learn everything they need.

Handling validation vs. processing errors

One of the trickiest design decisions in an API error system is distinguishing errors the client should fix (validation) from errors the server owns (processing failures, unavailable resources, unexpected states). Problem Details gives you the vocabulary to make this distinction clear without coupling clients to status code ranges.

Use these conventions:

  • Status 422 for validation errors where the client sent structurally invalid data (wrong types, missing required fields, constraint violations). The type should point to the specific validation that failed.
  • Status 400 for request-level problems that are not about individual fields (malformed JSON, unsupported media type, missing content-type header). These are often caught by framework middleware before your handler runs.
  • Status 409 for conflicts where the request was valid but the current state of the resource prevents it (optimistic locking failures, duplicate creation, version conflicts).
  • Status 402 for payment-required scenarios. This status code is rarely used in practice, but RFC 9457 gives you a clean home for it. If your API deals with payments, use it.
  • Status 429 for rate limiting. The response should include a Retry-After header and, optionally, an extension field with the reset timestamp.

Here is a rate-limit response that follows all the conventions:

{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "You exceeded 100 requests per minute. Try again after 30 seconds.",
  "instance": "/api/transfers",
  "retry_after_seconds": 30,
  "retry_at": "2026-06-09T10:01:00Z"
}

The Retry-After header goes in the HTTP headers where tools like CDNs and proxies can read it. The retry_after_seconds and retry_at fields go in the body where the client SDK can read them after parsing the JSON.

Using Problem Details outside Express

The pattern works for any HTTP framework in any language, but if you use something other than Express or Fastify in Node.js, the approach is the same.

For plain Node.js http.createServer:

import http from 'node:http';

function sendProblem(res: http.ServerResponse, problem: Record<string, unknown>): void {
  res.writeHead((problem.status as number) || 500, {
    'Content-Type': 'application/problem+json',
  });
  res.end(JSON.stringify(problem));
}

const server = http.createServer((req, res) => {
  if (req.url === '/api/transfers' && req.method === 'POST') {
    // parse body, validate, handle business logic...
    sendProblem(res, {
      type: 'https://api.example.com/problems/insufficient-funds',
      title: 'Insufficient Funds',
      status: 402,
      detail: 'Your balance is too low.',
      instance: req.url,
    });
    return;
  }
  // ...
});

For Hono or other modern frameworks, the pattern is identical: catch errors, build a Problem Details object, set the content type, and send it. The framework does not matter. The protocol does.

Testing Problem Details responses

Once you have a standard error format, your integration tests become simpler and more explicit. Instead of asserting on ad-hoc response shapes, you assert against the standard fields:

import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from './app';

describe('POST /api/transfers', () => {
  it('returns a Problem Details response for insufficient funds', async () => {
    const res = await request(app)
      .post('/api/transfers')
      .send({ amount: 100, fromAccount: 'abc', toAccount: 'xyz' });

    expect(res.status).toBe(402);
    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
    expect(res.body).toMatchObject({
      type: 'https://api.example.com/problems/insufficient-funds',
      title: 'Insufficient Funds',
      status: 402,
    });
  });

  it('returns validation errors with field-level detail', async () => {
    const res = await request(app)
      .post('/api/transfers')
      .send({ amount: -5, fromAccount: '', toAccount: '' });

    expect(res.status).toBe(422);
    expect(res.body.validation_errors).toBeDefined();
    expect(res.body.validation_errors.length).toBeGreaterThan(0);
    expect(res.body.validation_errors[0]).toHaveProperty('field');
    expect(res.body.validation_errors[0]).toHaveProperty('message');
  });
});

The test is readable. It checks the content type, the standard fields, and any extension fields. A developer joining the team can look at any error test and immediately understand what shape the response has, because every error follows the same structure.

Migrating an existing API

If you have an API that already returns ad-hoc errors, do not rewrite all handlers in one commit. The migration path is incremental:

  1. Add the ProblemDetail class and error middleware to your framework. This does not change any existing responses.
  2. Pick one handler or one route group and convert its error paths to throw ProblemDetail instances instead of returning ad-hoc objects.
  3. Add a response transformation layer that catches any non-ProblemDetail error response from old handlers and wraps it into a Problem Details shape. This means old handlers keep working the same way internally, but the wire format normalizes.
  4. Remove the transformation layer once all handlers are converted.
  5. Update your API documentation to reference Problem Details types.

The response transformation layer in step 3 looks like this:

app.use((req, res, next) => {
  // Intercept the original res.json to wrap non-standard errors
  const originalJson = res.json.bind(res);
  res.json = function (body: unknown) {
    // If it looks like a legacy error response, wrap it
    if (body && typeof body === 'object' && 'error' in (body as object)) {
      const legacy = body as { error?: string; message?: string };
      const wrapped = {
        type: `https://api.example.com/problems/legacy/${legacy.error ?? 'unknown'}`,
        title: legacy.message ?? 'Error',
        status: res.statusCode,
        detail: legacy.message ?? 'An error occurred.',
        instance: req.originalUrl,
      };
      res.setHeader('Content-Type', 'application/problem+json');
      return originalJson(wrapped);
    }
    return originalJson(body);
  };
  next();
});

This is a bridge, not a permanent solution. It lets you ship Problem Details responses from day one while old handlers are gradually converted. Remove it when the last old handler is updated.

The takeaway

RFC 9457 Problem Details is the most widely applicable API design improvement you can make in an afternoon. It costs about 50 lines of middleware. It replaces ad-hoc error formats with a structure that every client can parse generically. It gives you a clean place for trace IDs, validation errors, and business-specific context without inventing a new schema for every service.

The reason teams skip it is that error response formats feel like a low-priority detail compared to shipping features. But error handling is the part of your API that clients interact with when things go wrong, which is exactly when clarity matters most. A standardized error format means your client SDKs, dashboards, monitoring, and on-call engineers all speak the same vocabulary.

Adopt Problem Details now. Document every type URI. Use application/problem+json as the content type. Your clients will thank you, your tests will be cleaner, and your next integration with an external partner will not require a three-day slog through undocumented error codes.


A note from Yojji

Designing a consistent, well-documented API error surface is the kind of infrastructure discipline that pays compounding returns. Every client integration gets easier. Every on-call rotation has a shared vocabulary for describing what broke. Every new service on the team already knows how to format its errors. Yojji builds APIs and microservices on the JavaScript stack for clients in finance, healthcare, and logistics, where error clarity is not a nice-to-have but a contractual requirement.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and the full cycle of product delivery from discovery through DevOps and production support.