Skip to content

Commit 5b34d72

Browse files
Add top-level Fastify error handler that logs full stack on 5xx (JavaScriptSolidServer#312)
Without this, 500 responses carried only the 91-byte Fastify default body and nothing hit stdout/pm2 — the JavaScriptSolidServer#309 investigation had to infer the exception from the response-body size. Now every 5xx logs err.stack plus method/url/hostname via request.log.error. 4xx errors are unaffected. Response body shape is preserved (reply.send(err) delegates to Fastify's default serialization), so no existing consumer sees a behavior change.
1 parent e7d7dbb commit 5b34d72

3 files changed

Lines changed: 109 additions & 0 deletions

File tree

src/server.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { dbPlugin } from './db/index.js';
2121
import { webrtcPlugin } from './webrtc/index.js';
2222
import { tunnelPlugin } from './tunnel/index.js';
2323
import { terminalPlugin } from './terminal/index.js';
24+
import { registerErrorHandler } from './utils/error-handler.js';
2425

2526
const __dirname = dirname(fileURLToPath(import.meta.url));
2627

@@ -154,6 +155,7 @@ export function createServer(options = {}) {
154155
}
155156

156157
const fastify = Fastify(fastifyOptions);
158+
registerErrorHandler(fastify);
157159

158160
// Add raw body parser for all content types
159161
fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {

src/utils/error-handler.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Top-level Fastify error handler that logs the full stack for any 5xx.
3+
*
4+
* Without this, 500 responses carry only Fastify's default 91-byte body and
5+
* leave no trace in logs — production debugging has to infer the exception
6+
* from the response body alone (see #309 investigation for why that's bad).
7+
*
8+
* 4xx errors carry their own statusCode and don't need stack logging — they
9+
* are expected client errors with self-explanatory messages.
10+
*/
11+
export function registerErrorHandler(fastify) {
12+
fastify.setErrorHandler(function (err, request, reply) {
13+
const statusCode = err.statusCode ?? 500;
14+
if (statusCode >= 500) {
15+
request.log.error({
16+
err,
17+
method: request.method,
18+
url: request.url,
19+
hostname: request.hostname
20+
}, 'Unhandled 5xx error');
21+
}
22+
reply.send(err);
23+
});
24+
}

test/error-handler.test.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Tests for the top-level error handler (#312).
3+
*
4+
* Verifies that:
5+
* 1. Unhandled exceptions from route handlers produce a 500 whose body
6+
* matches Fastify's default shape — the existing 91-byte response
7+
* format must not regress (that's the baseline consumers rely on).
8+
* 2. The error's stack is actually logged (with method/url/hostname
9+
* context), so production debugging has a real trace.
10+
* 3. 4xx errors are NOT stack-logged (they're expected client errors).
11+
*
12+
* Uses a minimal Fastify instance so the test exercises only the handler,
13+
* not the full server's auth/routing stack.
14+
*/
15+
16+
import { describe, it, before, after } from 'node:test';
17+
import assert from 'node:assert';
18+
import Fastify from 'fastify';
19+
import { registerErrorHandler } from '../src/utils/error-handler.js';
20+
21+
class LogCapture {
22+
constructor() { this.lines = []; }
23+
write(chunk) {
24+
try { this.lines.push(JSON.parse(chunk)); } catch { /* non-JSON */ }
25+
return true;
26+
}
27+
errorLines() {
28+
return this.lines.filter((l) => l.level >= 50);
29+
}
30+
clear() { this.lines.length = 0; }
31+
}
32+
33+
describe('registerErrorHandler (#312)', () => {
34+
let app;
35+
const capture = new LogCapture();
36+
37+
before(async () => {
38+
app = Fastify({
39+
logger: { level: 'error', stream: capture },
40+
disableRequestLogging: true
41+
});
42+
registerErrorHandler(app);
43+
app.get('/throw500', async () => { throw new Error('synthetic 5xx for test'); });
44+
app.get('/throw400', async () => {
45+
const err = new Error('bad client input');
46+
err.statusCode = 400;
47+
throw err;
48+
});
49+
});
50+
51+
after(async () => { await app.close(); });
52+
53+
it('500 response body matches Fastify default shape (no regression)', async () => {
54+
capture.clear();
55+
const res = await app.inject({ method: 'GET', url: '/throw500' });
56+
assert.strictEqual(res.statusCode, 500);
57+
assert.deepStrictEqual(res.json(), {
58+
statusCode: 500,
59+
error: 'Internal Server Error',
60+
message: 'synthetic 5xx for test'
61+
});
62+
});
63+
64+
it('500 logs include the stack and request context', async () => {
65+
capture.clear();
66+
await app.inject({ method: 'GET', url: '/throw500', headers: { host: 'example.test' } });
67+
const line = capture.errorLines().find((l) => l.msg === 'Unhandled 5xx error');
68+
assert.ok(line, `expected 'Unhandled 5xx error' log line, got: ${JSON.stringify(capture.errorLines())}`);
69+
assert.strictEqual(line.method, 'GET');
70+
assert.strictEqual(line.url, '/throw500');
71+
assert.strictEqual(line.hostname, 'example.test');
72+
assert.ok(line.err && line.err.stack, 'expected err.stack in log');
73+
assert.ok(line.err.stack.includes('synthetic 5xx'), 'stack should identify the error');
74+
});
75+
76+
it('4xx errors do not trigger the 5xx stack log', async () => {
77+
capture.clear();
78+
const res = await app.inject({ method: 'GET', url: '/throw400' });
79+
assert.strictEqual(res.statusCode, 400);
80+
const unhandled = capture.errorLines().filter((l) => l.msg === 'Unhandled 5xx error');
81+
assert.strictEqual(unhandled.length, 0, '4xx must not produce a 5xx stack log');
82+
});
83+
});

0 commit comments

Comments
 (0)