Skip to content
academia.sh

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, cause preserves 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.

Start typing to search.

↑↓ Esc navigate · open · close