A caller posts a malformed filter and your API answers with the SQL it tried to run, the table name, and the file and line that built the query. The same service names an internal host when an upstream times out. Nothing in the code chose to say any of that; the framework’s default error handler said it for you.
What you get
You will end up with one error mapper that turns any throw into a problem document with a correlation id in place of internal detail, and a scan that proves it. This is for you if your API’s unhandled exceptions reach callers.
Short answer
Register one error handler, last, that turns anything a route throws into an RFC 9457 problem
document. Give it a stable type, a safe title, the status, and an instance carrying a
correlation id, and write the message and stack to your log under the same id. Pass through
only the errors you defined for callers. Run that mapper in every environment, because a
framework default that prints the stack outside production teaches staging nothing.
You will need
An API whose unhandled exceptions reach callers, and Node 22 or later for the sample, which
drives three failures through Express. Verified 2026-10-21 against Node 22.22.0 and Express
5.3.0. The document format is RFC 9457, which
says the detail member ought to help the client correct the problem rather than give debugging
information.1 The taxonomy, the codes, and the retry semantics belong to
design error responses an agent can act on; here
the question is only what must not appear.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A framework production error handler | A service with no error contract yet, where a bare status code is acceptable | A body that depends on an environment string, so staging and production answer one fault differently, and no id to find the log line with | Callers need to report a failure you can then find |
| An explicit RFC 9457 mapper | Any API whose callers are programs, and which keeps a log worth correlating | Every error a caller may learn about has to be declared, and an undeclared one becomes a bare 500 | The API already has an error format its clients parse |
Sentry beforeSend scrubbing | Keeping secrets and personal data out of the error tracker | Runs after the response has left, so it protects your dashboards and never the caller’s screen | You have no tracker, or the leak you fear is in the response |
The framework default is a decision made by an environment variable, the stack in development and a status message in production. Neither body carries an identifier. The mapper costs a list of declared errors and pays back a response a caller can quote to you that says only what you chose to say. Sentry’s hook solves a different problem: the copy of the error that reaches a third party, after the caller has read theirs.
Declare what a caller may learn
Start from the short list of errors a caller is entitled to understand, each with a type, a title, a status, and a detail written for them. The mapper lets these through and nothing else, so a code path nobody declared cannot leak by omission.
export class ProblemError extends Error {
constructor({ type, title, status, detail, ...extensions }) {
super(detail ?? title)
this.name = this.constructor.name
this.problem = { type, title, status, detail, ...extensions }
}
toProblem() {
return { ...this.problem }
}
}
export class NotFound extends ProblemError {
constructor(what, id) {
super({
type: 'https://api.meterco.example/problems/not-found',
title: `${what} not found`,
status: 404,
detail: `No ${what} has the id ${id}.`,
})
}
}
The detail says which meter was missing, because the caller supplied the id and can act on it. A validation error carries its field list as an extension member, which is where caller-facing structure belongs. The driver error, the timeout, and the bug are not on this list, which is the point of having one.
Map every throw, last in the chain
One function builds two documents from whatever was thrown. The caller’s problem carries an
instance of the form urn:uuid:..., which the RFC allows as an
opaque occurrence identifier. The
log record carries the same id with the message, the stack, and the cause.
// A client error raised by Express's own middleware, such as a body express.json() could
// not parse, carries a 4xx status and expose: true. It keeps the status and its standard
// title, and its message goes to the log like any other.
function clientError(err) {
const status = err?.status ?? err?.statusCode
if (err?.expose !== true || !Number.isInteger(status) || status < 400 || status > 499) return null
return { type: 'about:blank', title: STATUS_CODES[status], status }
}
export function describeFailure(err, id = randomUUID()) {
const safe = err instanceof ProblemError ? err.toProblem() : (clientError(err) ?? INTERNAL)
const problem = { ...safe, instance: `urn:uuid:${id}` }
const record = {
id,
status: problem.status,
name: err?.name ?? typeof err,
message: err?.message ?? String(err),
stack: err?.stack,
cause: err?.cause,
}
return { problem, record }
}
// Express error middleware: the last thing registered, so every throw a route does not
// catch arrives here, including the ones this file has never heard of.
export function problemHandler(log) {
return (err, req, res, next) => {
const { problem, record } = describeFailure(err)
log(record)
// Headers already on the wire cannot be replaced; let Express close the connection.
if (res.headersSent) return next(err)
res.status(problem.status).type('application/problem+json').send(JSON.stringify(problem))
}
}
err.message is read into the record and never into the problem, because the message is where a
driver puts the query and a client puts the host name. One kind of undeclared error keeps its
status: a client error Express itself marks with expose: true, such as a body express.json()
could not parse. A malformed request is then a 400 rather than a 500.
Express 5 forwards a rejected promise from a route to this middleware, as its
migration guide describes, so an async handler
that throws lands here without a wrapper. The headersSent check follows the
Express guide: once a response has started
streaming, the default handler closing the connection is the only safe move left.
node demo.mjs
response 500 application/problem+json; charset=utf-8
{"type":"about:blank","title":"Internal Server Error","status":500,"instance":"urn:uuid:0513dca5-dbc0-4ec5-8cd1-f96324a9c103"}
log record
id: 0513dca5-dbc0-4ec5-8cd1-f96324a9c103
name: DatabaseError
message: relation "meter_readings" does not exist
query: query: SELECT value FROM meter_readings WHERE site = 'north'
stack: 12 lines, kept here and nowhere else
The caller gets a status, the registered about:blank type, and an id. The engineer who receives
that id gets the query, the table, and twelve lines of stack, through whatever access your logs
already have.
Drive every failure you own and read the answers
Drive each failure through each handler and read the bodies the way an attacker would, for the things your own service knows and a caller should not. The patterns are a table name, a query, a host name, a stack frame, a source path, a dependency path, and an exception class.
export const PATTERNS = {
'table name': /meter_readings/,
query: /SELECT .* FROM/,
hostname: /svc\.cluster\.local/,
'stack frame': /\bat .*\(/,
'source path': /\.mjs:\d+/,
'dependency path': /node_modules\//,
'exception class': /TypeError|DatabaseError|UpstreamError/,
}
node scan.mjs
handler route status content type leaks
default, development /meters?site=north 500 text/html table name, query, stack frame, source path, dependency path, exception class
default, development /invoices/inv_42/pdf 500 text/html hostname, stack frame, source path, dependency path, exception class
default, development /readings/latest 500 text/html stack frame, source path, dependency path, exception class
default, production /meters?site=north 500 text/html none
default, production /invoices/inv_42/pdf 500 text/html none
default, production /readings/latest 500 text/html none
problem mapper /meters?site=north 500 application/problem+json none
problem mapper /invoices/inv_42/pdf 500 application/problem+json none
problem mapper /readings/latest 500 application/problem+json none
The development default leaks everything the error knew, because its body is err.stack, and
finalhandler, which Express delegates to, switches
to the status message only when its environment is exactly production.2 The production
default gives the caller a status in HTML and no way to refer to the failure. The mapper gives
them an id, in the media type their client already parses. Add your own patterns from your
inventory, your schema, and your lockfile, and run the scan in the test suite.
Repeat the mapper in the other two languages
The shape is the same in Python and Go, and so is the trap. Starlette, which FastAPI runs on,
wraps the app in a
ServerErrorMiddleware
that returns the plain text Internal Server Error with a 500, and a traceback page instead
when debug is set. A FastAPI
exception handler registered for
Exception is the mapper, returning a JSONResponse in the problem shape.
Go’s net/http recovers a panic in a handler, logs the
stack to the server’s error log, and closes the connection, so a panic leaks nothing and answers
nothing.3 The leak in Go is written by hand: http.Error(w, err.Error(), 500) copies a
driver’s message, host and all, into the body. A recover middleware that writes the problem
document and logs the error under one id replaces both habits.
Check it worked
Run the suite, which drives the three failures through both handlers and reads every body.
node --test problem.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 272.160603
Test 4 asserts application/problem+json and an empty leak list for every failure behind the
mapper. Test 5 asserts the instance in the body equals the id on the log record. Test 6 pins
the pitfall: the framework default carries a stack frame in development and no id in
production. Test 7 sends a body Express cannot parse and gets a 400 problem with nothing else in
it.
When it goes wrong
Staging answers a fault with the stack and production answers it with a problem document. The mapper was wired behind a condition on the environment, so production is the first place anyone sees its output.
node pitfall.mjs
staging 500 text/html leaks: table name, stack frame, source path, dependency path, exception class
production 500 application/problem+json leaks: none
Run the mapper everywhere and move the verbosity to the log, where the id finds it. The first production incident is a poor time to find the mapper’s own bug.
A missing record comes back as a bare 500 rather than a 404. The lookup threw the driver’s own
not-found error, which is not a ProblemError, so the mapper did what it does with strangers.
Throw NotFound from the repository function that knows what missing means, and leave the
mapper strict.
The connection drops with no body at all. The error was thrown after the handler had started streaming, so the headers were already sent and the mapper handed the error back to Express, which closes the connection. Validate and fetch before the first write, so a failure still has a response to live in.
When not to do this
Do not return err.message for an error you did not define. The message is where a driver
prints the query and a client prints the host, and a dependency upgrade can change what it says
without a line of your code changing.
Do not take production’s silence as the control. The default handler’s status message hides the detail and the id with it, so a caller who hits the fault has nothing to report. A bare 500 that cannot be traced is a different failure, not a safe one.
Do not scrub in the tracker and call the response safe. Sentry’s beforeSend is, in its own
words, the last chance to edit an event before it is sent, and the event is sent after the
response. Server-side scrubbing
runs later still. Both guard a copy of the error, not the original the caller read.
Do not strip the detail out of validation errors in the name of safety. A field name and the
reason it failed are what the caller needs to fix the request, which is what the RFC says detail
is for. The allowlist in errors.mjs is where that detail lives.4
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0 and Express 5.3.0, with finalhandler 2.1.1 beneath it.
Every output block is what the command preceding it printed, against the Express app in
app.mjs on a loopback port. The database and the upstream are stand-ins that throw what a driver
and a client would. Sentry, Starlette, FastAPI, and Go’s net/http were read from
their documentation and source and not run.
Footnotes
-
The advice on
detailturns on the word “ought” and not on any keyword RFC 9457 imports from BCP 14 alongside its MUST and SHOULD. A stack trace in that member therefore breaks no requirement of the document. The security considerations come closer, encouraging generators to avoid making implementation details such as a stack dump available through the HTTP interface. Encouragement is the strongest verb the section spends on the subject. ↩︎ Back to text -
The whole decision rests on one string. The
finalhandlerREADME says the body will be the HTML of the status code message ifenvisproduction, otherwiseerr.stack, withenvread fromNODE_ENVunless overridden. Express sets it from the same variable withdevelopmentas the fallback, so a server nobody configured is a server in development, by definition rather than by intent. ↩︎ Back to text -
Go’s package exports
ErrAbortHandler, a sentinel value to panic with on purpose. Any panic aborts the response to the client; this one also stops the server writing a stack trace to its log. It is the one panic in the package whose entire purpose is to say less. ↩︎ Back to text -
The OWASP API Security Top 10 lists error messages that include stack traces among the signs of a misconfigured API, under its eighth entry. The project’s error handling cheat sheet recommends the problem details format, citing it in the body as RFC 7807, the document RFC 9457 obsoleted, while its reference list names 9457. ↩︎ Back to text