The agent wrapped everything in try/catch and your errors disappeared
A payment method fails, and the UI shows a generic default instead of surfacing a failure. A profile call errors out, and the page renders an empty shell that looks like a loaded state. The ticket says it works in the demo, the build is green, and the real exception never reaches the caller’s error path. The common cause is a blanket try/catch the agent added to “handle errors gracefully,” with a default value returned where an error should have been propagated.
A fallback that reads as success
The agent wraps the whole function body, catches everything, and returns a plausible object: an empty profile, an empty list, a zeroed record. Downstream code checks for a truthy value, the default object passes that check, and the caller cannot tell “missing” from “loaded.” The upstream error is logged at warning level, if it is logged at all, and nothing correlates it to the request that actually failed.
On the surface this looks defensive. It compiles, the happy-path test passes, and the reviewer sees error handling present. What it actually did was remove the signal that a failure happened, which is worse than no handling at all: an uncaught error stops loudly; a swallowed one keeps going quietly and corrupts the assumption the rest of the program builds on.
Why the model reaches for the blanket catch
The prompt usually says “handle errors gracefully” or “don’t crash.” Given that instruction, the cheapest move that satisfies the text is: wrap the body, catch the exception, return a sensible default, add a log.warn. The model treats throwing as the only failure mode worth avoiding and treats a missed exception as an acceptable side effect.
It also favors the path that never throws. A default return keeps the function happy-path test green, whereas propagating forces the test to cover an error branch the prompt never described. The agent optimizes for compiling and passing the scenario in front of it, not for the distinction between a recoverable default and a hidden failure.
The catch shapes that hide failures
catch (err) { return DEFAULT; }— swallows and returns a plausible object; downstream cannot tell missing from success.- An empty catch block — compiles, logs nothing, and the caller never learns anything went wrong.
catch { log.warn(...); continue; }on a step that should abort the whole operation rather than proceed half-finished.- Catching at the top boundary instead of the call site, so local context (which input, which id, which dependency) is lost before anyone can debug it.
- A fallback value indistinguishable from a real result: an empty list for “no records,” a default object for “loaded,” zero for “failed to compute.”
Review the catch, not just the happy path
The bad shape returns a constant regardless of what went wrong:
async function loadProfile(userId: string) {
try {
return await api.get(`/users/${userId}`);
} catch (err) {
return EMPTY_PROFILE;
}
}The caller checks if (profile), EMPTY_PROFILE is truthy, and a failed load looks identical to a successful empty state. The better version decides per error type and keeps failure visible:
async function loadProfile(userId: string) {
try {
return await api.get(`/users/${userId}`);
} catch (err) {
if (isNotFound(err)) return null;
throw new ProfileLoadError(`failed to load ${userId}`, { cause: err });
}
}Now the caller can distinguish documented absence (null) from a genuine failure (a typed throw it must handle). Narrow the catch to the errors the call actually raises; catch-all unknown at a real boundary, not on every helper.
A catch must make one of three decisions
Every catch should pick exactly one, rather than defaulting to a return:
- Propagate: rethrow, preferably as a typed error with the original attached as
cause, so the caller decides how to respond. - Recover with evidence: retry or use a valid cache only when you can actually justify it, and log which recovery branch ran and why.
- Translate: convert the low-level error into a domain error the caller can act on, without dropping the original.
Returning a default without choosing among these hides the failure by default.
Force the failure path in a test
A test that only calls the happy route never exercises the catch, so the blanket fallback can grow over months and nobody notices until production. Add one test that injects a rejected call and asserts the error reaches the caller, or that the documented absence (null) is returned for the expected case. That keeps the catch honest and prevents the default from drifting into a silent mask.
Graceful error handling means choosing what the caller should know, not making every path succeed. A try/catch that always returns a default isn’t handling errors; it’s deleting them.