Coding by Hand
Rust home

panic vs Recoverable

A working kitchen has two ways to handle trouble. A line cook reads off the recipe card, finds the eggs are missing, and writes "out of eggs, sub in tofu" right back onto the card. The order keeps moving. That is a recoverable error. Now imagine grease catches the burner and flames climb the wall. Nobody writes a note. The smoke alarm screams, the room empties, and the kitchen stops. That is a panic. Rust gives you both tools and forces you to pick which one the situation calls for.

Two ways a kitchen handles trouble: a recipe card with a note vs. a smoke alarm and an evacuation.
Two ways a kitchen handles trouble: a recipe card with a note vs. a smoke alarm and an evacuation.

The split came out of decades of pain in C and C++. Those languages had no recipe card. A function that could fail returned a magic number — minus one, a null pointer, a stray bit somewhere — and the caller had to remember to check. Most callers forgot. Whole categories of security bugs, from Heartbleed to the Morris worm, traced back to a return value nobody read. Graydon Hoare, the Mozilla engineer who started Rust in 2006 after his elevator's firmware crashed for the third time, decided the language would never let you forget. Every failure that the program could reasonably continue past became a Result — a value the compiler refuses to ignore. Every failure that meant the program was already lying to itself became a panic — loud, immediate, and uncatchable by accident.

Here is the recipe-card side. A divide function returns Result<i64, String>. The compiler will not let the caller use the answer without first opening the envelope and finding out whether it is Ok or Err.

fn divide(top: i64, bottom: i64) -> Result<i64, String> {
    if bottom == 0 {
        return Err(String::from("cannot divide by zero"));
    }
    Ok(top / bottom)
}

Now the smoke-alarm side. A checkout function that tries to remove more eggs than the fridge holds. The invariant — fridge count never goes negative — is something the program assumed at every other line. If it breaks, no Err value is honest enough. The function calls panic! and the kitchen stops.

fn checkout(eggs_in_fridge: u32, eggs_needed: u32) -> u32 {
    if eggs_needed > eggs_in_fridge {
        panic!("kitchen on fire: needed {eggs_needed} eggs, had {eggs_in_fridge}");
    }
    eggs_in_fridge - eggs_needed
}

By default a panic walks every stack frame up to main, runs every destructor on the way, and the program exits. That walk is the unwind. Most of the time you want it to escape and kill the process — a bug should be loud. But Rust gives you one escape hatch. std::panic::catch_unwind puts a fire door around a block of code. If the smoke alarm trips inside that block, the door seals the room and execution keeps going on the other side. The rest of the restaurant keeps serving.

fn main() {
    // The recipe-card path: errors come back as values you can read.
    println!("recipe-card path:");
    for (top, bottom) in [(10, 2), (9, 3), (7, 0)] {
        match divide(top, bottom) {
            Ok(value) => println!("  {top} / {bottom} = {value}"),
            Err(reason) => println!("  {top} / {bottom} -> Err: {reason}"),
        }
    }

    // The smoke-alarm path: silence the default crash message so the
    // captured trace is byte-identical on every machine, then trip it.
    std::panic::set_hook(Box::new(|_info| {}));

    println!();
    println!("smoke-alarm path:");
    let safe = std::panic::catch_unwind(|| checkout(12, 3));
    report("normal order", safe);

    let bad = std::panic::catch_unwind(|| checkout(2, 10));
    report("over-order", bad);

    println!();
    println!("restaurant kept serving after the fire.");
}

fn report(label: &str, outcome: std::thread::Result<u32>) {
    match outcome {
        Ok(remaining) => println!("  {label}: eggs left = {remaining}"),
        Err(_) => println!("  {label}: PANIC caught (kitchen sealed off)"),
    }
}

A note on the panic hook. The default Rust hook prints a line to stderr that includes the file path on your machine — different on a Mac, different on a Linux server, different inside CI. That would make this lesson's captured output change every time the page rebuilt. The line set_hook(Box::new(|_info| {})) silences it so the trace you see is the trace the program actually prints to stdout, identical on every machine. In a real program you would log the panic somewhere useful instead of throwing it away.

Run the program and watch both paths happen in sequence. The recipe-card calls return Err and the loop keeps going. The smoke-alarm call panics, catch_unwind traps it, and the closing line still prints.

recipe-card path:
  10 / 2 = 5
  9 / 3 = 3
  7 / 0 -> Err: cannot divide by zero

smoke-alarm path:
  normal order: eggs left = 9
  over-order: PANIC caught (kitchen sealed off)

restaurant kept serving after the fire.

One question. Why did the snapshot show only the words PANIC caught and not the actual kitchen on fire message the panic carried? Because the panic hook is what prints the message, and we replaced that hook with a closure that throws everything away. The payload is still inside the Err returned by catch_unwind — you can downcast it and pull the string out. The lesson kept the output short on purpose so the snapshot stays readable.

catch_unwind acts like a fire door: the panic unwinds up to the door and the rest of the program keeps running.
catch_unwind acts like a fire door: the panic unwinds up to the door and the rest of the program keeps running.

catch_unwind is a fire door, not a safety net. It can only trap unwinding panics, and a project compiled with panic = "abort" skips unwinding entirely — the kitchen burns down with no door to seal. The next lesson builds real error types with thiserror so the recipe card carries more than just a flat string.