DEV Community

Cover image for I dug up a plugin I'd abandoned for a year: RFC 9457 in Fastify
fracabu
fracabu

Posted on AI-assisted

I dug up a plugin I'd abandoned for a year: RFC 9457 in Fastify

A year ago I wrote a Fastify plugin, published it on npm, and forgot about it. It happens. You ship it, write a couple of posts, move on to the next project, and the repo sits there collecting dust.

This week I decided to clear a few skeletons out of the closet. This one came out first: fastify-rfc9457.

Here is what I found when I opened it after ten months:

  • all 77 tests still passed, even on the latest Fastify;
  • npm run lint had never worked, because ESLint was never installed;
  • fastify-plugin, its only dependency, had moved on to version 6.

Nothing dramatic. I updated it, added Node 24 to CI and released 1.0.1. This time without a token: npm gets the release straight from GitHub Actions, with signed provenance, and it only goes public after I approve it.

Before deciding whether to keep it alive, I asked myself whether the problem it solves still exists. It does.

The problem

If you have integrated a few third-party APIs, you have seen this. Same problem, three different answers:

{ "error": "not found" }
Enter fullscreen mode Exit fullscreen mode
{ "success": false, "msg": "Order missing", "code": 44 }
Enter fullscreen mode Exit fullscreen mode
{ "status": "KO", "errors": ["ERR_ORD_404"] }
Enter fullscreen mode Exit fullscreen mode

Every client ends up writing a custom parser for every API. And on our own servers we are usually no better: each route invents its own error object, and six months later nobody remembers which one returns message and which one returns msg.

There is a standard for this, and most APIs still don't use it.

RFC 9457 in one minute

RFC 9457 ("Problem Details for HTTP APIs") is the IETF standard for error responses. It was published in 2023 and replaces the older RFC 7807. The idea is simple: every error has the same shape and the same content type, application/problem+json.

Field What it means
type A URI that identifies the kind of error. Clients switch on this.
title A short, human-readable summary of that kind of error.
status The HTTP status code.
detail What went wrong in this specific case.
instance Where it happened (usually the request path).

You can add your own fields next to these ("extension members"), and clients that don't know them simply ignore them.

That's the whole spec, really. The hard part is applying it consistently, on every route, including the errors you don't throw yourself: validation failures, 404s, crashes. That is what a plugin is for.

Setup: two minutes

npm install fastify-rfc9457
Enter fullscreen mode Exit fullscreen mode
import Fastify from 'fastify'
import problemDetails from 'fastify-rfc9457'

const app = Fastify()

await app.register(problemDetails, {
  baseUrl: 'https://api.example.com/errors'
})

app.get('/orders/:id', async (request, reply) => {
  return reply.notFound({
    detail: `Order ${request.params.id} does not exist`
  })
})
Enter fullscreen mode Exit fullscreen mode

GET /orders/123 now returns a 404 with Content-Type: application/problem+json:

{
  "type": "https://api.example.com/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 123 does not exist",
  "instance": "/orders/123"
}
Enter fullscreen mode Exit fullscreen mode

title, status, type and instance are filled in for you. There are shortcuts for the common statuses (reply.badRequest(), reply.unauthorized(), reply.forbidden(), reply.conflict(), reply.tooManyRequests() and more), plus reply.problem(status, options) for everything else.

Validation errors, field by field

This is where it pays off. Fastify already validates requests with JSON Schema. The plugin turns those failures into Problem Details, with one entry per invalid field:

const app = Fastify({
  ajv: { customOptions: { allErrors: true } } // report every invalid field, not just the first
})

await app.register(problemDetails, {
  baseUrl: 'https://api.example.com/errors'
})

app.post('/users', {
  schema: {
    body: {
      type: 'object',
      required: ['email'],
      properties: {
        email: { type: 'string', format: 'email' },
        age: { type: 'integer', minimum: 18 }
      }
    }
  }
}, async () => ({ ok: true }))
Enter fullscreen mode Exit fullscreen mode

Send { "email": "nope", "age": 12 } and you get:

{
  "type": "https://api.example.com/errors/bad-request",
  "title": "Bad Request",
  "status": 400,
  "detail": "body/email must match format \"email\", body/age must be >= 18",
  "instance": "/users",
  "errors": [
    { "field": "/email", "message": "must match format \"email\"", "keyword": "format" },
    { "field": "/age", "message": "must be >= 18", "keyword": "minimum" }
  ],
  "code": "FST_ERR_VALIDATION"
}
Enter fullscreen mode Exit fullscreen mode

A frontend can map errors[].field straight onto form inputs. No regex on error strings.

Your own error types

Not every error is a plain HTTP status. "Insufficient funds" is a 403, but a client needs to know why. Register it once, then use it by name, with any extra fields you need:

app.registerProblemType('insufficient-funds', {
  status: 403,
  title: 'Insufficient Funds',
  type: 'https://api.example.com/errors/insufficient-funds'
})

app.post('/transfer', async (request, reply) => {
  return reply.problem(403, {
    type: 'insufficient-funds',
    detail: 'Your balance is €30, but the transfer requires €50',
    balance: 30,
    required: 50
  })
})
Enter fullscreen mode Exit fullscreen mode
{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient Funds",
  "status": 403,
  "detail": "Your balance is €30, but the transfer requires €50",
  "instance": "/transfer",
  "balance": 30,
  "required": 50
}
Enter fullscreen mode Exit fullscreen mode

The client checks type, not the text of detail. You can reword the message tomorrow without breaking anyone.

Crashes that don't leak your infrastructure

Now the error nobody plans for:

app.get('/report', async () => {
  throw new Error('connect ECONNREFUSED 10.0.0.12:5432')
})
Enter fullscreen mode Exit fullscreen mode

Without care, that message (with your database's internal IP and port) ends up in a JSON response. With the plugin and NODE_ENV=production, the client gets:

{
  "type": "https://api.example.com/errors/internal-server-error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred",
  "instance": "/report"
}
Enter fullscreen mode Exit fullscreen mode

In development you also get the stack trace in the response, which is what you want while debugging. In production it is stripped automatically.

One hook for logging and metrics

Every problem response goes through a single hook, so logging and monitoring live in one place:

await app.register(problemDetails, {
  onProblem: (problem, request) => {
    request.log.error({ problem }, 'Problem Details response')
  }
})
Enter fullscreen mode Exit fullscreen mode

Bonus: titles in your users' language

If your API serves a non-English audience, titles come in English, Italian, Spanish, German and French:

await app.register(problemDetails, { defaultLanguage: 'it' })

app.get('/search', async (request, reply) => {
  return reply.tooManyRequests({ detail: 'Riprova tra 30 secondi' })
})
Enter fullscreen mode Exit fullscreen mode
{
  "type": "about:blank",
  "title": "Troppe Richieste",
  "status": 429,
  "detail": "Riprova tra 30 secondi",
  "instance": "/search"
}
Enter fullscreen mode Exit fullscreen mode

Note "type": "about:blank": that is what the RFC says to use when you don't give an error a type URI. Set baseUrl and every error gets a real one.

So, what now?

Problem Details won't make your API faster. It makes it predictable: one parser on the client, one place for logging on the server, and no internal details leaking out when something breaks.

The plugin works with Fastify 5 on Node.js 20, 22 and 24, is fully typed and has a single dependency. It's on npm as fastify-rfc9457 and the code is at github.com/fracabu/fastify-rfc9457.

This time I want to actually maintain it. I'm not promising a roadmap. I'm promising that if you open an issue, someone will read it. If you try it and something is missing or broken, tell me.

And you: any skeletons in your closet? Projects you published and then left behind? Tell me in the comments.


This post was written with the help of AI and reviewed by me. The code examples were run against the published package.

Top comments (0)