Skip to content
Episode 13 · The script that died at room 1,212

Errors and exceptions: failing loudly, safely and usefully

Wednesday, 06:10: the nightly rate refresh died at 02:04 on one timeout, at room 1,212 of 36,000. The other 34,788 chain rooms are still showing yesterday's prices, and guests are booking them.

An error is a message: something didn't happen, and here's why. Good handling makes sure that message reaches someone who can act on it.

30 min foundationExceptions vs result typesFail fast: validate at the edges, crash on impossible statesTransient errors (retry) vs bugs and permanent errors (don't)Error boundaries: where errors are caught and translatedLogging errors with context, never swallowing themIdempotency
  1. Problem

    Every real system fails: networks drop, payment providers time out, users send bad input, and code has bugs. A booking flow must handle all of it without charging twice or losing bookings.

  2. Why the simple system fails

    The naive approach either crashes the whole process on the first error, or wraps everything in try/catch and ignores the error, so failures become silent data corruption nobody can debug.

  3. Engineering concept

    Classify errors (expected vs bug, transient vs permanent), fail fast on invalid state, handle errors only at boundaries that can do something useful, and log them with context.

  4. Technology

    Exceptions and typed error classes, result types, input validation, retries with backoff for transient errors, top-level handlers and UI error boundaries, structured logs with request IDs.

  5. Trade-offs

    Exceptions keep happy paths clean but hide failure modes from signatures; result types make failure explicit but add ceremony. Retries recover transient failures but amplify bugs and outages.

01

The problem

2 min
Wednesday, 06:10. Maya has three complaints about prices.
  1. Maya · Product manager
    Chain hotels show yesterday's prices. Guests are booking rates the chain changed overnight.
  2. Omar · On-call SRE
    The refresh crashed at 02:04: a 504 timeout on room 1,212 of 36,000. Nothing ran after it.
  3. Lina · Senior engineer, your mentor
    You lead this one. I'll only check your answers.
02

The big idea

4 min
One timeout, 34,788 stale rooms
The big idea

An error is information. Route it to someone who can act on it: a retry, the user, or an engineer.

Error handling never means making an error disappear. It means deciding, per kind of error, who deals with it.

03

How it works

5 min

An is an error that's thrown and travels up the call stack until a catch handles it, or the program stops. The 504 travelled out of the loop and ended the script. Last night's errors: 1,900 timeouts or 504s, 300 × 429, 12 rooms priced "N/A". Ask four questions:

Questions for any error
  1. 1
    Expected or a bug?

    "Price is N/A" is bad partner data we should expect. "rates is undefined" in our code is a bug.

  2. 2
    Transient or permanent?

    Transient: may work if you try again (). Permanent: fails the same way every time (bad data, wrong key, a bug).

  3. 3
    Who can handle it?

    A retry, for transient errors. The chain, for bad data. An engineer, for a bug or a dead key: stop and alert.

  4. 4
    Where's the boundary?

    An error boundary catches errors for one unit of work. Here: one room. One room failing must not stop the other 35,999.

  5. 5
    What context?

    Room id, status code, attempt number, the original error with its .

So: retry transient errors with growing waits; skip and record permanent per-room errors; stop and alert when everything will fail (a 401) or our code is broken. Log once, with context. Never swallow an error: catch it and do nothing.

04

Watch a decision being made

4 min
Worked example

You fix the nightly refresh (Lina checks)

One 504 killed the refresh at room 1,212 and left 34,788 rooms stale. Make the job finish, recover what it can, and make every failure visible.

  1. 1Put the boundary at one room

    refreshRoom(id) gets its own try/catch and returns an outcome: ok, bad-data or failed. The loop collects outcomes instead of dying.

    Why: A boundary around the whole job stops at the first error; one on every line hides where it failed.

  2. 2Retry only transient errors

    Retry 408, 429, 502, 503, 504 up to 3 times, waiting longer each time (1 s, 2 s, plus a little randomness). Anything else is rethrown.

    Why: Randomness stops all 40 in-flight calls retrying at the same instant.

  3. 3Stop on errors that affect everything

    A 401 or 403 rethrows out of the boundary and ends the job with an alert.

    ts
    } catch (err) {
      if (isAuthError(err)) throw err;              // every call will fail: stop, alert
      log.warn({ roomId: id, status: statusOf(err), attempt, err }, "refresh failed");
      return { id, status: "failed" };
    }

    Why: With a shared cause, continuing produces 36,000 copies of one error.

  4. 4Log once, make it safe to re-run

    One line per failed room, one summary (updated, failed, skipped). saveRates overwrites, so a 03:00 pass can re-try only the failed rooms.

    Why: Re-runs are safe only if doing the work twice equals doing it once ().

The whole thing in one line: Lina's check: "Good. Move the N/A check before saving: fail fast, where you know exactly what's wrong." Classify, then retry, skip or stop, at the unit of work.
05

In real life

3 min
Two ways languages report a failure

Go: errors are returned values

  • Functions return value, err; the caller checks if err != nil
  • Every place that can fail is visible in the code
  • Cost: repetitive checks, and an ignored err is still possible

Failure is hard to miss when reading.

Java, Python, JavaScript: exceptions

  • A throw travels up until a catch handles it
  • Short happy path; nothing in a signature says it can fail

Clean code, if the boundaries are deliberate.

06

What goes wrong

3 min
Empty catch blocks

The failure still happened; now it's invisible.

Instead

Handle it, or log it with context and rethrow.

Throwing away the original error when wrapping

throw new Error("payment failed") loses the stack trace and cause.

Instead

Pass the original as cause.

Retrying at every layer

3 layers × 3 retries turns one call into dozens.

Instead

Retry in one place; for payments, only with an .

07

Trade-offs

3 min
OptionFits whenYou payMain risk
ExceptionsUnexpected failures and bugsDeliberate boundariesCatching too broadly
Result valuesEveryday outcomes: sold out, bad dataMore code per callAwkward with exception libraries
Degrade gracefullyA stale answer beats noneMore paths to testUsers see stale data
Thursday, 11:00. What do guests see for rooms whose refresh failed?
  1. Maya · Product manager
    Show the last price, labelled "price may change", and re-check the live price before charging.
  2. Omar · On-call SRE
    Last price is a guess. We just paid for three days of wrong prices. Hide the room until it's fresh.
  3. Maya · Product manager
    Hide 48 rooms every night, and guests go to a competitor.
08

Your job as tech lead

4 min

Claude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.

Decisions you own
  • For each kind of failure: retry (timeouts, 504, 429), skip and record (one room priced "N/A"), or stop everything and alert (401).
  • When a job counts as failed: "finished" with 0 of 36,000 rooms updated is a failure, and you set the threshold that wakes someone.
  • What the guest sees for each error, and what stays in the logs (stack traces never reach the browser).
Tell Claude Code
The nightly chain-rates refresh died at 02:04 on one 504 for room #1,212, leaving 34,788 rooms stale until 06:10. Last night: 1,900 timeouts or 504s, 300 × 429, 12 rooms priced "N/A". Make one room's failure never stop the others: retry timeouts, 504s and 429s with backoff up to 3 times; skip and record bad prices; stop and alert on a 401. Log a summary and fail the run if over 5% weren't updated. No empty catch blocks. Plan first; test each case with a fake API.
Check before you accept
  • For each error type (504, 429, bad price, 401), Claude shows which branch handles it and what's logged.
  • With the fake API returning 401 for everything, the job stops and reports failure.
  • The run summary adds up to 36,000 rooms.
Red flags
  • try { … } catch {} around every call "to make the job robust".
  • Every error retried the same way, including 401 and bad data.
  • "Job fixed" because it no longer crashes, with no count of rooms updated.
09

Check yourself

2 min
Q1/3Debugging
Guests report that bookings "succeed" but never appear in their account. Where is the most likely bug?
async function createBooking(input: BookingInput) {
  const booking = buildBooking(input);
  try {
    await db.bookings.insert(booking);
  } catch (e) {
    console.log("insert failed");
  }
  return { ok: true, booking };
}
How confident are you?

Next episode: search results stutter at 11 frames per second on a mid-range Android phone, while the same page is smooth on Karim's laptop. Time to look inside the browser.

Finished?

Mark it complete to update your skill map.

Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
10

Technical deep dive

Retry what can succeed, stop what can't

You write the plan, Lina checks it. She only changes one thing.

Worked example

You fix the nightly refresh

One 504 timeout killed the refresh at room 1,212 and left 34,788 rooms stale. Make the job finish, recover what can be recovered, and make every failure visible.

  1. 1Classify last night's errors

    From the chain's log: 1,900 timeouts/504, 300 × 429 (transient), 12 × price "N/A" (permanent, per room), possible 401 (permanent, affects everything), and bugs in our code (permanent).

    Why: Each class needs a different response. Without classifying, you either retry everything or nothing.

  2. 2Put the boundary at one room

    Wrap refreshRoom(id) in its own try/catch that returns an outcome: ok, bad-data or failed. The loop collects outcomes instead of dying.

    Why: The unit of work is one room. A boundary around the whole job still stops at the first error; a try/catch on every line hides where it failed.

  3. 3Retry only transient errors

    Inside the boundary, retry 408, 429, 502, 503, 504 up to 3 times, waiting longer each time (1 s, 2 s, plus a little randomness). Anything else is rethrown at once.

    Why: Transient errors often succeed seconds later. Growing waits with randomness stop all 40 in-flight calls from retrying at the same instant.

  4. 4Stop on errors that affect everything

    A 401 or 403 rethrows out of the boundary and ends the job with an alert.

    ts
    } catch (err) {
      if (isAuthError(err)) throw err;              // every call will fail: stop, alert
      log.warn({ roomId: id, status: statusOf(err), attempt, err }, "refresh failed");
      return { id, status: "failed" };
    }

    Why: When the cause is shared, continuing just produces 36,000 copies of one error. Stopping loudly gets a human to fix the key.

  5. 5Log once, with context

    One structured line per failed room (room id, status, attempt count, original error as cause), and one summary line: updated, failed, skipped.

    Why: "Error: request failed" at 02:04 is useless. The room id lets us re-run just that room; the counts tell Omar if tonight was normal.

  6. 6Make it safe to run again

    saveRates overwrites the row for (room, date), so re-running a room twice gives the same result. A second pass at 03:00 re-tries only the failed ones.

    Why: Retries and re-runs are only safe if doing the work twice equals doing it once ().

The whole thing in one line: You classified the errors, caught them at the room level, retried only transient ones with growing waits, stopped the job on auth errors, logged each failure once with context, and made the work safe to re-run: the refresh now finishes even on a bad night.
ts
export class AppError extends Error {
  constructor(message: string, readonly code: string, readonly status: number, options?: { cause?: unknown }) {
    super(message, options);
    this.name = new.target.name;
  }
}
export class RoomSoldOut extends AppError {
  constructor(roomId: string) { super("Room " + roomId + " is sold out", "SOLD_OUT", 409); }
}
export class PaymentUnavailable extends AppError {
  constructor(cause: unknown) { super("Payment provider unavailable", "PAYMENT_UNAVAILABLE", 503, { cause }); }
}
Typed errors let a boundary decide by type; `cause` keeps the original error and its stack trace.
Error kindExampleHTTP statusRetry?Who acts
Validationcheck-out before check-in400No: fix the inputThe user
Business conflictroom sold out409NoThe user
Transient dependencytimeout, 503, 429503 after retriesYes, with growing waits, if idempotentRetry logic, then on-call
Shared permanent401: API key revokedn/a (stop the job)NoAn engineer, now
Bugundefined is not a function500NoAn engineer
Classify first; the status, retry policy and alert follow from the class.
11

Interactive simulation

12

Visual diagram

Click a component

Where a payment timeout is thrown, translated, retried, logged and shown. Start the walkthrough.

13

Analogy

  • At a hotelRefusing a form with no name at the deskIn codeFailing fast on bad input
  • At a hotelThe duty manager handling escalationsIn codeAn error boundary
  • At a hotel"Connection lost, try again"In codeA temporary error worth retrying
  • At a hotel"Card declined"In codeA permanent error: don't retry
  • At a hotelSeeing a burst pipe and saying nothingIn codeSwallowing an error
  • At a hotel"Room 412, 14:03, pipe under sink"In codeA log with context

Where the analogy breaks: hotel staff can use judgement in the moment. Code can't, so every decision about retrying, translating or logging has to be written in advance.

14

Check your understanding

Q1/3Reasoning
If a booking request has a check-out date before its check-in date, the safest option is to continue with default dates so the guest isn't shown an error.
How confident are you?
15

Practice ladder

Step 1 · Trace

Trace a booking through the error handler

Debugging Warm-up

StayHub's booking flow:

async function book(req) {
  assertValidStay(req.checkIn, req.checkOut, req.guests);   // throws ValidationError (400)
  const room = await reserveRoom(req.roomId);              // throws RoomSoldOut (409)
  const payment = await chargeWithRetry(req.quote, req.idempotencyKey); // retries 503 ×3, then PaymentUnavailable (503)
  return saveBooking(room, payment);
}
// The HTTP boundary maps AppError → its status, anything else → 500 "Something went wrong" + requestId, and logs once.

For each case, trace what happens: which line fails, is anything retried, what status and message does the guest get, and what is logged?

  1. check-out is before check-in
  2. the payment provider returns 503 twice, then succeeds
  3. reserveRoom has a bug: room.price.amount when price is undefined
Step 2 · Complete

Finish the classification table

Explain Practice

Lina's table for the new hotel-photo import has gaps. Fill in each ___: transient or permanent, and what the job does (retry, skip and record, or stop and alert).

ErrorTransient?Job does
Photo server times outtransientretry with growing waits, max 3
Photo URL returns 404______
Storage bucket says "403 access denied"______
Image file is corrupted (can't be decoded)______
429 Too Many Requests______
Step 3 · Solve

The confirmation emails nobody received

Design Practice

Support: "Five guests this week say they never got a confirmation email." The email step in the booking flow:

try { await email.send(confirmation); } catch (e) { /* email is not critical */ }

There are no logs or metrics for email. Redesign the error handling for confirmation emails: what to catch, what to retry, what to log, and how you'd find out next time before guests tell support.

Step 4 · Decide

Retry a payment that timed out?

Decision Stretch

The payment provider sometimes takes over 10 s and our call times out. A timeout doesn't tell us whether the card was charged. Last month: 40 timeouts out of about 12,000 payments.

Karim: "Just retry timeouts automatically, three times." The provider supports idempotency keys, but our code doesn't send one yet.

Write your decision: "We chose X over Y because…, accepting the risk of…". Say what must be true before any automatic retry.

16

Explain it yourself

A payment call in your booking API fails. Walk through how your code should decide what to do, from the failing call up to what the guest and the on-call engineer see.

0 words