Errors, security, and observation

Design error middleware

Place one final four-argument error handler that maps known failures and hides internal details.

Error middleware is the one function every failed request ends in. It serves two audiences with opposite needs. The user needs a short, stable answer. The operator needs the stack, the request id, and the route. Each side gets its own information and nothing from the other.

Four arguments, registered last

Express recognizes error middleware by its four parameters, (err, req, res, next). It only runs when an error was thrown or passed to next(), and it goes after every route, including the 404 handler:

export function errorHandler(err, req, res, next) {
  if (res.headersSent) {
    return next(err)
  }

  const status = err.status ?? err.statusCode ?? 500

  if (status >= 500) {
    console.error({ requestId: req.id, method: req.method, url: req.originalUrl, err })
  }

  const message = status >= 500 ? 'Internal error' : err.message

  if (req.originalUrl.startsWith('/api/')) {
    return res.status(status).json({ error: message, requestId: req.id })
  }

  res.status(status).send(renderErrorPage({ status, message, requestId: req.id }))
}

And in createApp(), the last line before return app:

app.use(errorHandler)

Let’s walk through the decisions.

Check headersSent first

If a response already started streaming and then something threw, we can’t send a new status. Passing the error to next() hands it to the default Express handler, which closes the connection.

Classify by status

Our HttpError classes carry a status. The body parser errors carry statusCode. Everything else, a TypeError, a database rejection, a bug, has neither and falls to 500.

Anything below 500 is the client’s fault, so its message is safe to show. Note not found helps the user. Anything 500 and up is our fault, and the message is replaced. connection refused to 10.0.3.12:5432 tells an attacker about your network and the user nothing useful.

Log only the server failures

A 404 for a mistyped URL is not an incident, and logging every one buries the real problems. The 500s get the full object with the request id, so when a user reports the id from their error page you find the exact request.

One shape per audience

The API gets JSON with two keys, always the same two: error and requestId. The pages get an HTML error page with the same values. Compare it to the Express default, which sends a stack trace as HTML in development and the bare status text in production. Neither is a contract.

Watch the two sides diverge

Trigger each failure and compare the response with the terminal:

CauseClient seesLog shows
empty title400 title is requirednothing
someone else’s note403 Forbiddennothing
missing note404 Note not foundnothing
database down500 Internal error + idfull stack, same id

If the fourth row ever shows the stack in the client column, the handler has a bug. That row is the test I never delete.

Try this: throw a plain new Error('boom') from a route and confirm the client gets Internal error while the terminal gets boom and the stack.

Lesson completed