Lesson 15 / 20
Error Handling Strategy
The distinction between operational errors and programmer errors, chaining error causes, the forms of asynchronous errors that escape catching, and the correct use of last-resort hooks.
Contents
The previous lesson said a configuration error should stop the process from coming up. Not every error occurring while running deserves the same treatment: a request’s malformed body should not shut down the service, but a corrupted in-memory state should.
This lesson’s question: which error gets caught, and which one ends the process? The distinction rests on a single criterion, and the whole strategy follows from it.
Two Classes of Error
An operational error is a condition a correctly written program expects to meet in its running environment: a missing file, an unreachable network, a client that sent invalid data, a full disk. Not the program’s fault — anticipated and handled.
A programmer error is the code violating its own contract: accessing a field on an undefined value, forgetting a required argument, giving an object where a number is expected. Unanticipated, and it shows the assumptions about the program’s state at that moment were wrong.
The rule: an operational error is handled, a programmer error is not. Catching the second and continuing means running on with a corrupted state — a failure of unknown duration and much harder to diagnose.
For the distinction to be visible in code, operational errors are marked with their own type:
// error-types.mjs export class OperationalError extends Error { constructor(message, { code, status = 500, cause } = {}) { super(message, { cause }); this.name = 'OperationalError'; this.code = code; this.status = status; this.operational = true; } } export function isOperational(error) { return error instanceof OperationalError && error.operational === true; }
Chaining the Cause
An error from a lower layer gets translated moving up to a higher one: the higher
layer throws an error that speaks its own language, without losing the real cause.
The cause option builds that link.
// try-errors.mjs import { readFile } from 'node:fs/promises'; import { OperationalError, isOperational } from './error-types.mjs'; async function readMeasurements(path) { try { return await readFile(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') { throw new OperationalError(`measurement file missing: ${path}`, { code: 'MEASUREMENT_FILE_MISSING', status: 503, cause: error, }); } throw error; } } for (const path of ['measurements.ndjson', 'missing.ndjson']) { try { const text = await readMeasurements(path); console.log(`${path}: ${text.trim().split('\n').length} lines`); } catch (error) { console.log(`${path}: operational = ${isOperational(error)}, code = ${error.code}, status = ${error.status}`); console.log(` root cause: ${error.cause.code}`); } } // Programmer error: contract violation, never caught and swallowed try { null.value; } catch (error) { console.log(`programmer error: operational = ${isOperational(error)}, type = ${error.constructor.name}`); }
node try-errors.mjs
measurements.ndjson: 12 lines missing.ndjson: operational = true, code = MEASUREMENT_FILE_MISSING, status = 503 root cause: ENOENT programmer error: operational = false, type = TypeError
Three details matter.
Only the recognized error code is translated; everything else rethrows as is. Translating every error into a single type hides unknown failures inside a known category.
The translated error carries its own code. The caller decides without parsing the message text; the code stays fixed even if the message changes.
The real cause stays reachable — reaching the system-call error at the bottom of the chain is the shortest path to the failure during diagnosis.
The Counterpart in the Service
The error handler in the HTTP Server lesson reported every error as 400. Once the distinction is established, the correct mapping can be made:
// error-server.mjs import { createServer } from 'node:http'; import { OperationalError, isOperational } from './error-types.mjs'; import { recordFromLine } from './summarizer.mjs'; function errorResponse(error, response) { if (isOperational(error)) { response.writeHead(error.status, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: error.message, code: error.code }) + '\n'); return; } // Programmer error: no detail goes out, the full record stays inside process.stderr.write(`programmer error: ${error.stack.split('\n')[0]}\n`); response.writeHead(500, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: 'internal error', code: 'INTERNAL_ERROR' }) + '\n'); } const server = createServer(async (request, response) => { try { if (request.url === '/measurement') { const chunks = []; for await (const chunk of request) chunks.push(chunk); const body = Buffer.concat(chunks).toString('utf8'); try { recordFromLine(body.trim()); } catch (error) { throw new OperationalError('invalid measurement record', { code: 'INVALID_RECORD', status: 400, cause: error, }); } response.writeHead(201, { 'content-type': 'application/json' }); response.end('{"received":1}\n'); return; } if (request.url === '/crash') { null.value; // deliberate programmer error } throw new OperationalError('not found', { code: 'ROUTE_NOT_FOUND', status: 404 }); } catch (error) { errorResponse(error, response); } }); server.listen(8791, '127.0.0.1', () => console.log('listening'));
Three requests are sent with the server running:
curl -s -X POST --data-binary 'broken' -w 'status: %{http_code}\n' http://127.0.0.1:8791/measurement
{"error":"invalid measurement record","code":"INVALID_RECORD"}
status: 400
curl -s -w 'status: %{http_code}\n' http://127.0.0.1:8791/missing
{"error":"not found","code":"ROUTE_NOT_FOUND"}
status: 404
curl -s -w 'status: %{http_code}\n' http://127.0.0.1:8791/crash
{"error":"internal error","code":"INTERNAL_ERROR"}
status: 500
The last call printed this line in the server’s terminal:
programmer error: TypeError: Cannot read properties of null (reading 'value')
The operational error’s message goes to the client, the programmer error’s does not — not a habit of hiding things, a decision resting on two reasons: the stack trace and internal message leak file paths and code structure; and there is nothing the client can do about it anyway — the only meaningful response to a 500 is to retry or report it.
Where Asynchronous Errors Escape Catching
try/catch covers only its own synchronous body and awaited promises. An error
thrown inside a callback falls outside that scope.
// uncaught.mjs import { readFile } from 'node:fs/promises'; // try/catch does not catch an error deferred to a callback try { setTimeout(() => { throw new Error('thrown inside a callback'); }, 0); } catch (error) { console.log('this line never runs'); } // Unhandled rejection readFile('missing.ndjson', 'utf8'); console.log('sync flow done');
node uncaught.mjs
The output’s first line is sync flow done; then the uncaught error prints and the
process ends with a nonzero code. this line never runs never prints: setTimeout
returned immediately, and the error was thrown much later, in the loop’s timers
phase — by then the try block had long since finished.
A second problem in the same file is sneakier. Because the readFile call is not
awaited, the rejection of the promise it returns is never handled anywhere. This is
an unhandled rejection, and its default behavior is to end the process:
// rejection.mjs import { readFile } from 'node:fs/promises'; readFile('missing.ndjson', 'utf8'); // returned promise is not awaited, its error goes unhandled console.log('sync flow done');
node rejection.mjs > /dev/null; echo "exit code: $?"
exit code: 1
This looks harsh, but it is correct: a silently swallowed rejection leads to data being thought written when it was not. Rule: every call that returns a promise is either awaited or has its rejection explicitly handled.
Last-Resort Hooks
Two hooks put uncaught errors into the program’s own hands:
// last-resort.mjs process.on('uncaughtException', (error, origin) => { process.stderr.write(`fatal: ${error.message} (origin: ${origin})\n`); process.exit(1); // state is considered corrupted: restart }); process.on('unhandledRejection', (reason) => { process.stderr.write(`unhandled rejection: ${reason.message ?? reason}\n`); process.exit(1); }); setTimeout(() => { throw new Error('late error'); }, 0); console.log('hook installed');
node last-resort.mjs; echo "exit code: $?"
hook installed fatal: late error (origin: uncaughtException) exit code: 1
The only legitimate use of these hooks is recording the failure before the process dies. Installing the hook and continuing to run breaks the runtime’s default behavior and lets it keep going in an unknown state.
No long work happens inside the hook. The process is already in a suspect state; starting an asynchronous log write there gives no guarantee it will complete. A short, synchronous line and exiting is safest.
Keeping the service alive after the process dies is not the process’s own job — an external supervisor that restarts it handles that, taken up in the Process Management and Resilience lesson.
Summary
- An operational error is an anticipated running condition and is handled; a programmer error is a contract violation and is not.
- Operational errors are marked with their own type and carry a code field; the caller does not parse the message text to decide.
- When an error is translated,
causepreserves the real reason; only recognized conditions are translated, the rest is rethrown as is. - An error thrown inside a callback and the rejection of an unawaited promise fall
outside
try/catch‘s scope; the second’s default result is the process ending. - Last-resort hooks are used only to record and exit; installing the hook and continuing means proceeding in an unknown state.
Next Step
Recording errors came up a few times, but where and in what form was never settled. A free-text message is no use to someone searching for the failure — what’s needed is a record format supporting searching, filtering, and counting. The next lesson builds structured logging and adds level management.
To keep your progress and take notes, Log in
My notes
Log in to take notes.