whybother

Field Guide · 02 of 10

Why AI agents swallow errors with try/catch

An agent shown a stack trace removes the stack trace. The cheapest way is to catch the error and return something harmless, so the crash is gone and so is the only evidence of it. The bug is still there. It now fails quietly, which is worse.

What is actually happening

The task as the agent understands it is “this error appears, make it stop appearing”. The error message is the observable. The cause is two or three steps behind it, and may be in another file. Wrapping the failing line in a try changes the observable in one edit and fixes the symptom as stated.

In the skit the cause is that items is undefined on first render, before the data has loaded. A real fix initialises items, or renders a loading state until it exists, or fixes the code that should have supplied it. The swallowed version leaves the cause untouched. The component now renders nothing on first load, and nothing again if the request later fails. No error, no log, a blank list.

That is the cost of the trade. A crash is loud and local: you know where it happened and when. A swallowed error is silent and spread out. It surfaces days later as an empty page, a save that never arrived, or a total that is off by one record, and by then the line that used to point at the problem has been deleted.

The agent will also tell you it is fixed, and in the narrow sense it is. The terminal is clean and the red overlay is gone. Silencing and fixing produce the same screenshot, which is the whole problem.

How it shows up in the diff

- return items.map((item) => <Row {...item} />)
+ try {
+   return items.map((item) => <Row {...item} />)
+ } catch {
+   return null // fixed
+ }

This is the diff from the skit. The comment is the tell: // fixed is a claim, and nothing in the code backs it up.

Guardrail

Ask for the cause, and ask for it in writing. An agent that has to name the root cause cannot easily claim it fixed one while adding a catch.

Paste into AGENTS.md

Fix the cause of an error, not its message. Never add a catch that hides an error without logging or rethrowing it, and say what the root cause was.

Check it mechanically

# every catch the branch adds, with its file header above and 3 lines after
git diff -U3 origin/main...HEAD | grep -E -A3 '^\+\+\+ |^\+.*\bcatch\b'

That is a review aid, not a gate. Every changed file prints its +++ header and the next three lines. The hits are the added lines containing catch, each followed by three lines of context. A header followed by diff lines and no catch is a file with nothing to check; skip it. Most hits will be fine. The ones that return a default value without logging are the ones to question.

Linters can fail the build on the worst cases:

Be clear about the limit. no-empty and S110 do not fire on catch { return null } or except Exception: return None, because neither block is empty or a bare pass, and a comment silences no-empty. BLE001 does flag except Exception: return None, but ESLint core has no equivalent rule. The linter catches the laziest version. The TypeScript one with a default value in it still needs a person.

Further reading

Release It! (2nd ed.): how production systems fail, and why a failure you can see is a failure you can contain.