munotes®

Error Handling

Get access to whole semester resourcesSemester Pass

Chapter Forty-Eight

Syllabus topic Module 2, "Application Development: Error handling".

Pages 331 to 336 of 499

In one line

Error handling is deciding, for everything that can go wrong, whether it was expected or not: an expected refusal carries a status, a code for the program and a sentence for the person, and reaches the page beside the field it concerns; an unexpected failure is logged in full and answered with one plain apology that tells an attacker nothing.

In the wording to use when asked: error handling distinguishes anticipated domain errors, which are represented by a dedicated error type carrying an HTTP status, a machine-readable code and a human-readable message, from unanticipated failures, which are logged with their stack and answered with a generic 500; both are produced by a single centralised handler, so that every response has one shape and no internal detail is disclosed.

Two kinds of thing going wrong

ExpectedUnexpected
Examplesa wrong password, a sold-out item, an order that is not yours, a quantity of 7the database is down, a null where an object was assumed, a typo in a query
Whose faultusually the caller's, sometimes just the state of the canteenours
What the user seesexactly what is wrong, and what to do"Something went wrong on our side. Please try again."
What the log seesone ordinary request linethe whole error, with its stack
What it is called herean AppErroranything else

The difference is decided when the error is created, not when it is caught. A service that knows the biryani has sold out throws an AppError; nothing else in the application throws one. So the handler at the end needs no cleverness: instanceof AppError is the whole question.

The error type, and the makers

'use strict';

// An error we expected and can explain to the user: a wrong
// password, a sold-out item, an order that is not theirs.
// Anything else that is thrown is a bug, and the error
// handler treats it as one (src/middleware/errors.js).
class AppError extends Error {
  constructor(status, code, message, details) {
    super(message);
    this.name = 'AppError';
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

function badRequest(message, details) {
  return new AppError(400, 'invalid_input', message, details);
}

function notSignedIn() {
  return new AppError(401, 'not_signed_in',
    'Please sign in first.');
}

function forbidden() {
  return new AppError(403, 'forbidden',
    'Your account is not allowed to do that.');
}

function notFound(what) {
  return new AppError(404, 'not_found', `${what} not found.`);
}

function conflict(code, message, details) {
  return new AppError(409, code, message, details);
}

function tooManyAttempts(retryAfterSeconds) {
  return new AppError(429, 'too_many_attempts',
    'Too many failed sign-ins. Try again later.',
    { retryAfterSeconds });
}

module.exports = {
  AppError, badRequest, notSignedIn, forbidden, notFound,
  conflict, tooManyAttempts,
};

Each maker builds one kind of refusal, and the names say what they mean rather than what number they are: a route calls notFound('Order'), not new AppError(404, ...). Three things travel with every one:

munotes.in331

Error Handling

  • The status, which the browser and any program can act on;
  • The code, a fixed word the page can test, sold_out, slot_taken, invalid_move, which never changes when the wording does;
  • The message, a plain sentence ready to show; and, where it helps, details: the fault in each field, or how many portions are left.

conflict takes its code because 409 covers several refusals, each of which a page may want to treat differently (Chapter 30).

The one handler

'use strict';

const path = require('node:path');
const { AppError } = require('../errors');

// An address under /api that no route answered.
function apiNotFound(req, res, next) {
  next(new AppError(404, 'not_found',
    'There is nothing at this address.'));
}

// Any other address that no file answered.
function pageNotFound(req, res) {
  res.status(404).sendFile(
    path.join(__dirname, '..', '..', 'public', '404.html'));
}

function send(res, status, code, message, details) {
  res.status(status).json({ error: { code, message, details } });
}

// The last stop for every error. An AppError was expected,
// so its message is shown. Anything else is a bug: the user
// gets a plain apology and the details go to the log, never
// to the browser, where they would help an attacker.
function errorHandler(log) {
  return (err, req, res, next) => {
    if (res.headersSent) return next(err);
    if (err instanceof AppError) {
      if (err.status === 429) {
        res.set('Retry-After', String(err.details.retryAfterSeconds));
      }
      return send(res, err.status, err.code, err.message,
        err.details);
    }
    if (err.type === 'entity.parse.failed') {
      return send(res, 400, 'bad_json',
        'The request body is not valid JSON.');
    }
    if (err.type === 'entity.too.large') {
      return send(res, 413, 'too_large',
        'The request body is too large.');
    }
    log.error(`${req.method} ${req.originalUrl} failed:`, err);
    send(res, 500, 'server_error',
      'Something went wrong on our side. Please try again.');
  };
}

module.exports = { apiNotFound, pageNotFound, errorHandler };

Reading it in order is reading the policy:

  1. If the answer has already begun, hand the error back to Express: nothing else is honest.
  2. An AppError is answered with its own status, code, message and details. A 429 also gets its Retry-After header, the number of seconds from the limiter (Chapter 46).
  3. Express's own parse failures are translated into the application's own shapes, 400 bad_json and 413 too_large, so that the page meets one kind of error and never Express's wording.
  4. Anything else is a bug: the method, the address and the whole error go to the log, and the browser gets one sentence with no code, no message from the error, no file name and no stack (S11).

Two more middlewares complete it: an unknown address under /api becomes a JSON 404, and any other unknown address gets the HTML page (Chapter 40), because a person who mistypes an address should see a page, not JSON.

munotes.in332

Error Handling

The status codes, as this application uses them

Every one of these is produced in this chapter's transcripts.

CodeMeaningHere
200it worked, here is the resultevery successful read and change
201it worked, and something new existsa new order, account or menu item
204it worked, nothing to send backsigning out
400the request itself is wrongvalidation (invalid_input), broken JSON (bad_json)
401who are you?not signed in, or a wrong email or password
403we know, and you may notthe wrong role, or a change from another site
404no such thingan unknown address, an id that cannot name a row, someone else's order
409not now, because of how things areslot_closed, slot_taken, sold_out, item_unavailable, invalid_move, email_taken, name_taken
413, 415too large; not JSONbodies the server refuses to read
429too many triesfive failed sign-ins in fifteen minutes
500our faulta bug: logged in full, apologised for plainly
503not available at the momentthe health check when the database cannot be reached

The line that matters most is 400 against 409. A quantity of 7 is a 400: it is wrong whatever the canteen is doing. An order for a sold-out item is a 409: the request is proper, and tomorrow the same one succeeds. Getting that line right is what lets a page say something useful.

One order route, six answers

The order route can produce most of them. Each of these is one request:

$ cd ~/canteen-preorder
$ npm start > server.log 2>&1 &
$ curl -s -o /dev/null --retry 10 --retry-connrefused localhost:3000/api/health
$ order() { curl -s -o /tmp/body -w "%{http_code}" -b "$1" \
>   -H 'Content-Type: application/json' -d "$2" \
>   localhost:3000/api/orders; echo " $(jq -c '.error.code // .order.id' /tmp/body)"; }
$ order none '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
401 "not_signed_in"
$ curl -s -c lata -o /dev/null -H 'Content-Type: application/json' \
>   -d '{"email":"owner@college.example","password":"canteen-demo"}' \
>   localhost:3000/api/auth/login
$ order lata '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
403 "forbidden"
$ curl -s -c priya -o /dev/null -H 'Content-Type: application/json' \
>   -d '{"email":"priya@college.example","password":"canteen-demo"}' \
>   localhost:3000/api/auth/login
$ order priya '{"slot":"12:40","items":[{"menuItemId":1,"quantity":7}]}'
400 "invalid_input"
$ order priya '{"slot":"12:40","items":[{"menuItemId":5,"quantity":1}]}'
409 "item_unavailable"
$ order priya '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
201 1
$ order priya '{"slot":"12:40","items":[{"menuItemId":1,"quantity":1}]}'
409 "slot_taken"

Six answers, in the order a student might meet them: 401 with nobody signed in; 403 for the owner, who is not a student; 400 invalid_input for a quantity of 7; 409 item_unavailable for an item that is off today's menu; 201 with the order's number; and 409 slot_taken for a second order in the same slot, which is FR-10.

munotes.in333

Error Handling

The last two are the pair to remember: the same request, sent twice, is right the first time and refused the second, because the state of the canteen changed in between. That is what a 409 is for.

What a person is told

A code is for the program; the page shows the message (Chapter 41). Three levels:

  • A field's message, beside the field, for a refusal of input: "Choose 1 to 5."
  • The page's message area, for anything else: "Only 2 Chicken Biryani left."
  • The apology, for a 500: "Something went wrong on our side. Please try again."

And one more, which is easy to forget: no connection at all. api.js turns a failed fetch into an error of the same shape, with status 0 and a message about the network (Chapter 40), because on a phone at 12:15 the commonest failure is the Wi-Fi.

When something really goes wrong

The health check is the one route that answers a failure with a status of its own, because that is its whole job (Chapter 42). Stopping MySQL shows it and an ordinary route meeting the same problem, and shows what a 500 does and does not say:

$ cd ~/canteen-preorder
$ sudo mysqladmin shutdown
$ curl -s -o /dev/null -w "health: %{http_code}\n" localhost:3000/api/health
health: 503
$ curl -s localhost:3000/api/health | jq -c .
{"status":"down","database":"unreachable"}
$ curl -s -o /dev/null -w "menu:   %{http_code}\n" localhost:3000/api/menu
menu:   500
$ curl -s localhost:3000/api/menu | jq -c .
{"error":{"code":"server_error","message":"Something went wrong on our side. Please try again."}}
$ grep -c 'GET /api/menu failed' server.log
2
$ grep -o 'Error: [^,]*' server.log | head -1
Error: connect ECONNREFUSED 127.0.0.1:3306
$ lab-entry true
$ curl -s -o /dev/null -w "health again: %{http_code}\n" localhost:3000/api/health
health again: 200
$ curl -s localhost:3000/api/menu | jq -c '.items | length'
14

Three things to see. The health check answers 503, naming the database, which is what a monitor reads. The menu answers 500 with the apology and nothing else: no code from the driver, no address, no stack, because a page asking for the menu can do nothing with any of it (S11). And the log has the failure in full, with the method, the address and the driver's own error, which is what a developer needs.

Then MySQL is started again, and the next request succeeds: the pool opens new connections by itself, and nothing had to be restarted. A system that needs a restart after its database hiccups is a system that will be restarted during the demonstration.

munotes.in334

Error Handling

Do this for your project

  1. Decide, for every failure, whether it is expected. Throw one kind of error for the expected ones, from the place that knows.
  2. Give each a status, a code for programs and a sentence for people, and details only where they help.
  3. Handle them all in one place, at the end of the pipeline.
  4. Never send a stack, a file name or a database message to a browser; log them instead.
  5. Keep 400 for what is always wrong and 409 for what is wrong now.
  6. Answer an unknown API address with JSON and an unknown page address with a page.
  7. Handle "no connection at all" in the client as carefully as any refusal.

Mistakes that cost marks

Everything is a 500, or worse, everything is a 200 with {"ok": false} inside.

The stack in the browser, which is a map of the server for anyone who asks.

A different error shape from every route, so the pages need a special case each.

catch (err) {}, which turns a bug into wrong behaviour nobody can find.

Codes the pages test by their English, so changing "Sold out" to "Sold out for today" breaks the application.

No message for a lost connection, so a student on bad Wi-Fi sees a page that does nothing at all.

Quick revision

  • Expected (AppError: status, code, message, details) against unexpected (logged, 500, one sentence).
  • Makers: badRequest, notSignedIn, forbidden, notFound, conflict(code, ...), tooManyAttempts.
  • One handler at the end: headersSent, AppError, Express's parse errors translated, everything else logged.
  • 400 = always wrong; 409 = wrong now. 401 = who are you; 403 = you may not; 404 = no such thing.
  • Retry-After with a 429; 503 from the health check; status 0 in the client for no connection.
  • Unknown /api address: JSON 404. Unknown page: the 404 page.

Questions you must be able to answer

1. How does the application tell an expected refusal from a bug? By its type. Anything the code knew could happen is thrown as an AppError carrying a status, a code and a message; nothing else creates one. The handler at the end answers an AppError with its own details and treats everything else as a bug.

2. Why are 400 and 409 both used, and how do you choose? 400 is for a request that is wrong whatever the state of the system, such as a quantity above the limit. 409 is for a request that is proper but cannot be carried out now, such as ordering an item that has sold out or a second order for the same slot. The same 409 request may succeed later.

munotes.in335

Error Handling

3. Why does a 500 tell the user so little? Because the details help an attacker more than the user: a stack trace reveals the framework, the file layout and often the database's table names, and a user can do nothing with any of it. The details go to the log, where the developers can read them.

4. What is the code in an error for, when there is already a message? The message is for the person and may be reworded at any time; the code is a fixed word for the program, so a page can treat sold_out differently from slot_taken without ever testing English text.

5. What happens when the database is down? The health check answers 503, saying the database is unreachable, which is what a monitor watches. Any other route's query fails, the error is logged in full, and the browser is told only that something went wrong. When the database comes back, the pool opens new connections and requests succeed again.

6. Why does an unknown address under /api answer JSON while any other unknown address answers a page? Because the caller of an API address is a program, which needs the same shape of answer as every other refusal, while the caller of any other address is a person in a browser, who should see a readable page with a way back.

munotes.in336

The rest of this subject

These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.

Issue
Done!