From tools to taste
You arrive at this guide already holding the whole toolkit. You know how to read a return code and errno and to check every call you make. You can clean up on every path with goto-cleanup and you have met the RAII idea that ties cleanup to scope. You can guard your assumptions with an assertion, and — most importantly — you can tell a recoverable error apart from a bug. This last guide does not hand you a fifth tool. It teaches the judgement that decides which of the four to reach for, because failing gracefully is not one technique — it is the art of choosing the right failure for each situation.
Here is the uncomfortable truth that makes this guide necessary: a program that never fails does not exist. Disks fill up, networks drop, files vanish mid-read, the allocator returns NULL, the user types nonsense. The question was never "will something go wrong?" — it is "when something goes wrong, what does my program do?" A fragile program answers that question by accident: it crashes, corrupts a file, or silently produces a wrong answer. A robust program answers it on purpose, in a way you chose in advance. That deliberate choosing is the whole subject here.
The spectrum: fail fast versus degrade gracefully
Every error puts you at a fork with two named directions, and you met them as the fail-fast versus graceful-degradation spectrum. Fail fast means: stop the moment something is wrong, as close to the cause as possible, before bad state can spread. Degrade gracefully means: keep running with reduced function — serve a cached page when the database is down, fall back to a lower-resolution image, drop one frame instead of freezing the video. Neither is universally right. The skill is knowing which way the situation points, and the deciding question is one you already learned: is this a recoverable error, or a bug?
Map it cleanly. A bug — a violated invariant, a null pointer where one is impossible, an index past the end of an array — means your model of reality is already broken; continuing only lets the corruption spread, so you fail fast: assert, abort, dump core, and fix the code. A recoverable error — open() returned -1 because the file is missing, malloc() returned NULL because memory is tight, recv() got a connection reset — is the world telling you no, not your logic being wrong; here you may handle it, retry, or degrade. The same crash that is the right response to a bug is the wrong response to a missing file.
recoverable error (the world said no) bug (your model is broken)
---------------------------------- ---------------------------
open() == -1, errno == ENOENT invariant violated
malloc() == NULL array index out of range
recv() == 0 (peer closed) impossible enum value
| |
v v
handle / retry / degrade ---------------> assert -> abort -> fix code
(keep running, tell the user) (fail fast, protect data)Handle, propagate, or abort
When a recoverable error lands in your hands, you have exactly three honest moves, and naming them turns vague anxiety into a checklist. This is the handle, propagate, or abort decision, and you make it at every layer the error passes through. Handle it: you have enough context to fix the situation right here — the file was missing, so create it; the connection dropped, so reconnect. Propagate it: you do not have enough context, so you return the error upward to a caller who does, cleaning up your own resources on the way out. Abort: the error reveals that continuing is impossible or unsafe, so you stop the program.
The deep insight is that this is a per-layer decision: the same error is propagated by the low-level function that first sees it and handled by the high-level one that has the context. A function that reads a config file cannot know whether a missing file is fatal — maybe the caller has a default — so it propagates. The top-level command that called it can decide, because it knows the user's intent. This is exactly error propagation, and getting it right means each layer does only what it has the knowledge to do, and no more. The bug of "swallowing" an error — catching it and pretending it did not happen — is what the next callout warns against.
Cleanup is non-negotiable on every path
Here is where a robust program quietly separates itself from a fragile one: it releases what it acquired on the error path too, not just the happy path. When you propagate an error up and out, you must first hand back every resource you are holding — the open file descriptor, the malloc'd buffer, the held mutex — or each failure leaks a little more until the program starves. This is precisely why guide two taught resource cleanup with goto-cleanup: a single labelled exit path that frees in reverse order of acquisition, reached from every error branch.
This is also where the RAII idea earns its keep as the cleaner answer. In C you carry the discipline by hand with goto-cleanup; the moment a function has three resources and five early returns, the bookkeeping gets error-prone, and a forgotten free() on one rare branch is a leak that only shows under load. RAII binds each resource's release to a scope so that leaving the scope by any route at all — normal return, error return, even an exception in C++ — runs the cleanup automatically. The language stops trusting you to remember. Hold that contrast: in C, cleanup is a habit you must never drop; in an RAII language, it is a guarantee the compiler enforces.
Fault injection: proving the error paths actually work
Here is the dirty secret of error handling: the code that runs when malloc() returns NULL or open() fails almost never runs in testing, because those failures are rare. So the error paths — the very paths you wrote to make the program graceful — are the least-tested code in the whole program, quietly rotting with bugs you have never seen execute. Fault injection is the cure: you deliberately make the rare failures happen, on demand, so you can watch your recovery code actually run. It turns "I think this handles a full disk" into "I made the disk full and watched it handle it."
- Pick a failure you claim to handle — say, malloc() returning NULL when memory is exhausted. Find the recovery code you wrote for it.
- Force that failure on purpose. Wrap allocation in a test seam that returns NULL on, say, the 5th call; or run under a tool that fails one allocation; or just temporarily edit the call to return the error value.
- Run it and watch what actually happens — not what you assumed. Does the recovery branch execute? Does it leak the buffer you allocated just before? Does it return a clean error or read freed memory?
- Fix whatever breaks, then automate the injected failure as a permanent test so the error path stays exercised on every build, not just the one day you remembered to try it.
Fault injection pairs naturally with one more property worth designing for: idempotence. An operation is idempotent if doing it twice has the same effect as doing it once — setting a flag to true is idempotent; appending a line to a log is not. This matters because graceful recovery often means retrying, and a retry only stops being dangerous when the operation it repeats is safe to repeat. If recv() times out and you resend a request that already charged a credit card, the retry just charged it twice. Design the operations you intend to retry so that a repeat is harmless, and fault injection is how you prove the retry actually behaves.
The whole picture, and an honest word on Rust
Step back and the five guides snap into a single discipline. Check every call so no failure goes unseen. Sort each failure: recoverable error, or bug? Bugs fail fast — assert, abort, fix. Recoverable errors get handled, propagated, or aborted, and whichever you pick, you clean up every resource on the way. Inject the rare failures so the recovery code is real and not wishful, and make retried operations idempotent so recovery cannot double-charge. That single chain — notice, classify, respond, clean up, prove — is what "failing gracefully" actually means in practice. It is not a feeling; it is five concrete habits applied to every call you write.
It is worth being honest about how much of this is sheer manual discipline in C. Every habit above is something the language lets you forget — there is no compiler check that you tested an error path, freed on every branch, or even looked at a return value. This is exactly the pressure that motivates a language like Rust, which you will meet at the top of the ladder. Rust bakes several of these habits into the type system: its error story forces you to acknowledge a fallible result before you can use the value inside, and its ownership model runs cleanup automatically when a value leaves scope, the RAII idea promoted to a language-wide guarantee.