Dead Code Walking: The Error Handlers You Wrote But Never Actually Tested
There's a particular flavor of false confidence that comes from writing a try-catch block. You type out the catch, maybe log a message, maybe return a default value, and somewhere in the back of your brain a little checkbox gets ticked. Error handling: done. You move on. The feature ships. Life continues.
Until it doesn't.
Because that catch block? It's never actually run. Not in development. Not in staging. Not in any QA pass. It exists in a quantum state — theoretically functional, practically untested — right up until the moment production decides to find out which one it really is.
Spoiler: it's usually the bad one.
The Illusion of the Safety Net
Here's what makes this bug so insidious: it doesn't look like a bug. It looks like good engineering. Defensive programming is something we're all told to do. Handle your exceptions. Don't let errors propagate unchecked. Fail gracefully.
So we do. We wrap the database call. We catch the network timeout. We handle the null response from that third-party API we don't fully trust. We write code that looks responsible.
But there's a massive difference between writing error handling code and validating error handling code. Most teams do the first and skip the second entirely. The result is a codebase full of catch blocks that are essentially decorative — they make the developer feel better without actually doing anything verified.
Think about the last time you deliberately triggered a database connection failure in your local environment just to see what your app does. Or intentionally threw an exception inside a background job to watch the recovery path execute. If you're being honest with yourself, the answer is probably "rarely" or "never."
Why We Skip the Testing Part
This isn't laziness. It's psychology.
First, there's the effort asymmetry problem. Setting up the happy path in a test environment is usually straightforward. Setting up the failure conditions — network partitions, disk full errors, malformed third-party responses, race conditions in async code — requires real work. You have to mock things, or spin up fault injection tools, or write test harnesses that deliberately break stuff. That's friction, and friction kills follow-through.
Second, there's optimism bias. Developers are, by temperament, people who believe they can make things work. The error path feels like a hypothetical. "Sure, the API could return a 500, but it basically never does." Except sometimes it does. And sometimes "basically never" becomes "every thirty seconds" when you're running at scale during peak traffic on Black Friday.
Third — and this one's subtle — there's the code review problem. When someone submits a PR with a try-catch block, reviewers see the structure and assume it works. Nobody asks, "Did you actually run this path?" It looks complete. It probably gets approved. The gap between presence and correctness never gets surfaced.
What Actually Happens in Production
Let me paint you a picture that'll feel familiar.
A payment processing service catches exceptions from its upstream billing API and, on failure, is supposed to queue a retry job and notify the ops team via Slack. Solid design. The developer who wrote it was thorough. The catch block is right there in the code.
Months later, the billing API starts returning malformed JSON during a partial outage — not a network error, just garbage data that throws a parsing exception instead of the expected API exception. The catch block is typed to the wrong exception class. It never fires. The retry queue stays empty. The Slack notification never sends. Orders silently fail. Nobody knows until customers start calling.
The error handling was there. It just wasn't handling this error. And because it was never tested end-to-end, nobody knew.
This is the pattern. Not a missing catch block — a catch block that exists but doesn't actually cover the real failure modes, because those failure modes were never simulated.
How to Actually Fix This
The goal isn't to write more error handling. It's to trust the error handling you've written. That requires a different approach.
Make error paths first-class test citizens. Every significant error handler deserves at least one test that deliberately triggers it. Not a unit test that mocks the exception and confirms the catch block runs — a test that actually exercises the recovery behavior. Does the retry get queued? Does the fallback return the right value? Does the user see a useful message or a stack trace?
Use chaos engineering, even at small scale. Tools like Chaos Monkey get a lot of press, but you don't need Netflix-level infrastructure to apply the concept. Start small. Introduce a flag in your config that randomly fails a specific service call with some low probability. Run it in staging. Watch what breaks. Fix it before production does the same thing without warning.
Treat untested catch blocks like unreviewed code. Establish a team norm: if an error handler has no test coverage, it goes on a watchlist. Not necessarily blocked — sometimes you're moving fast — but flagged for follow-up. Make the gap visible rather than assumed-away.
Run your error paths manually before shipping. This sounds obvious but almost nobody does it consistently. Before a feature ships, deliberately break the things it depends on and watch what happens. Kill the database connection. Return a 503 from the mock API. Throw the exception the catch is supposed to handle. Five minutes of deliberate breakage before launch beats forty-five minutes of incident response at midnight.
Log at the catch site, always. If your catch block doesn't emit a log entry, you'll never know it ran — or that it didn't. Structured logging with enough context to reconstruct what happened is the minimum bar. An error handler that fails silently is almost worse than no error handler at all, because it gives you false confidence.
The Uncomfortable Truth
Most production outages don't happen because developers forgot to handle errors. They happen because developers thought they handled errors, shipped that assumption, and never verified it.
The catch block is the easy part. It takes thirty seconds to write. Validating that it actually does what you think it does, under the conditions that will actually occur, is the work that gets skipped. And it gets skipped because it's invisible — until the moment it isn't.
Your error handling code is either tested or it's a wish. Production will eventually tell you which one it is. The question is whether you find out on your schedule or on its.