Skip to content

Commit c3ecc44

Browse files
author
CryptoJones
committed
feat: global error handler + JSON 404 fallthrough (#18)
1 parent 65efee0 commit c3ecc44

3 files changed

Lines changed: 160 additions & 0 deletions

File tree

app/middleware/error-handler.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
const log = require('../config/logger.js');
6+
7+
/**
8+
* Global error handler. Mounted AFTER all routes in server.js.
9+
*
10+
* Two failure modes it cleans up:
11+
*
12+
* 1. `next(err)` calls from controllers / future middleware land
13+
* here; previously they fell through to Express's default
14+
* handler which renders an HTML error page. We always want JSON.
15+
*
16+
* 2. Uncaught exceptions inside an async route handler get
17+
* promoted to the error handler by Express ^4.21 automatically
18+
* — but only if the handler uses `async` and lets the promise
19+
* reject. Controllers in this codebase already do their own
20+
* try/catch + log + 500.json(), so this handler is the
21+
* safety net for anything that slips through.
22+
*
23+
* Body shape (matches the rest of the API):
24+
* { message: 'Error!', requestId: <pino req id, if available> }
25+
*
26+
* Stack traces are NEVER returned to the client. They land in the
27+
* structured log instead, where they're searchable + filtered.
28+
*/
29+
function errorHandler(err, req, res, next) {
30+
if (res.headersSent) {
31+
// Express docs say: delegate to default handler if headers
32+
// already sent (avoids "Cannot set headers" cascades).
33+
return next(err);
34+
}
35+
36+
// 4xx are client errors and should not log as `error` — they're
37+
// expected. Default to 500 if no status is set.
38+
const status = Number.isInteger(err && err.status) && err.status >= 400 && err.status < 600
39+
? err.status
40+
: 500;
41+
42+
const logBody = {
43+
err: err,
44+
status,
45+
method: req.method,
46+
url: req.originalUrl,
47+
requestId: req.id || (req.log && req.log.bindings && req.log.bindings().reqId),
48+
};
49+
if (status >= 500) {
50+
log.error(logBody, 'unhandled error');
51+
} else {
52+
log.warn(logBody, 'request error');
53+
}
54+
55+
const body = {
56+
message: status >= 500 ? 'Error!' : (err.message || 'Bad Request'),
57+
};
58+
if (logBody.requestId) {
59+
body.requestId = logBody.requestId;
60+
}
61+
return res.status(status).json(body);
62+
}
63+
64+
/**
65+
* 404 fallthrough. Mounted right before the error handler so any
66+
* unmatched route emits a structured 404 instead of Express's HTML.
67+
*/
68+
function notFound(req, res) {
69+
return res.status(404).json({
70+
message: 'Not found.',
71+
method: req.method,
72+
path: req.originalUrl,
73+
});
74+
}
75+
76+
module.exports = { errorHandler, notFound };

server.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const pinoHttp = require('pino-http');
1212
const db = require('./app/config/db.config.js');
1313
const log = require('./app/config/logger.js');
1414
const router = require('./app/routers/router.js');
15+
const { errorHandler, notFound } = require('./app/middleware/error-handler.js');
1516

1617
const app = express();
1718

@@ -105,6 +106,11 @@ if (rateLimitMax !== 0) {
105106

106107
app.use('/', router);
107108

109+
// 404 fallthrough + global error handler. Order matters — these
110+
// must be last so they catch what the router didn't.
111+
app.use(notFound);
112+
app.use(errorHandler);
113+
108114
// Listen port — env-configurable. Defaults to 3000 so the API can be
109115
// started by a non-root user. Bind to 0.0.0.0 for container friendliness.
110116
const port = parseInt(process.env.PORT, 10) || 3000;

tests/api/error-handler.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
//
4+
// Tests for the global error handler + 404 fallthrough.
5+
6+
import { describe, test, expect, vi, beforeAll } from 'vitest';
7+
import request from 'supertest';
8+
import express from 'express';
9+
import { errorHandler, notFound } from '../../app/middleware/error-handler.js';
10+
11+
let app;
12+
13+
beforeAll(() => {
14+
app = express();
15+
app.use(express.json());
16+
17+
// Routes that intentionally throw / next(err) so we can exercise
18+
// the error handler from inside a test, without depending on the
19+
// whole router.
20+
app.get('/explode/500', (req, res, next) => {
21+
next(new Error('boom'));
22+
});
23+
app.get('/explode/with-status', (req, res, next) => {
24+
const err = new Error('I am a teapot');
25+
err.status = 418;
26+
next(err);
27+
});
28+
app.get('/explode/leaky', (req, res, next) => {
29+
// Simulates a Sequelize-style error whose .message would leak
30+
// a hostname or stack frame if we passed it straight through.
31+
const err = new Error('SequelizeConnectionRefusedError: connect ECONNREFUSED 10.0.0.42:5432');
32+
err.status = 500;
33+
next(err);
34+
});
35+
app.use(notFound);
36+
app.use(errorHandler);
37+
});
38+
39+
describe('global error handler', () => {
40+
test('500 errors return JSON {message: "Error!"} not HTML', async () => {
41+
const res = await request(app).get('/explode/500');
42+
expect(res.status).toBe(500);
43+
expect(res.headers['content-type']).toMatch(/application\/json/);
44+
expect(res.body.message).toBe('Error!');
45+
});
46+
47+
test('500 errors never leak the original message (no stack info)', async () => {
48+
const res = await request(app).get('/explode/leaky');
49+
const text = JSON.stringify(res.body);
50+
expect(text).not.toMatch(/ECONNREFUSED/);
51+
expect(text).not.toMatch(/10\.0\.0\.42/);
52+
expect(text).not.toMatch(/Sequelize/);
53+
});
54+
55+
test('honors a numeric err.status in 4xx range', async () => {
56+
const res = await request(app).get('/explode/with-status');
57+
expect(res.status).toBe(418);
58+
// For 4xx the message goes through (it's a client error, not a server one)
59+
expect(res.body.message).toBe('I am a teapot');
60+
});
61+
});
62+
63+
describe('404 fallthrough', () => {
64+
test('unmatched route returns JSON 404 (not HTML)', async () => {
65+
const res = await request(app).get('/no/such/path');
66+
expect(res.status).toBe(404);
67+
expect(res.headers['content-type']).toMatch(/application\/json/);
68+
expect(res.body.message).toMatch(/not found/i);
69+
expect(res.body.path).toBe('/no/such/path');
70+
expect(res.body.method).toBe('GET');
71+
});
72+
73+
test('unmatched method on a known path returns 404 (Express convention)', async () => {
74+
// We don't have an OPTIONS handler so this should fall to notFound.
75+
const res = await request(app).post('/explode/500');
76+
expect(res.status).toBe(404);
77+
});
78+
});

0 commit comments

Comments
 (0)