Your Error Handling Is a Security Policy

Your Error Handling Is a Security Policy
My login rate limiter was switched off for weeks. Nothing reported it. Nothing could have.
The cause was the dullest bug in software: a column rename. The migration described the throttling table as (subject, action, count, window_at). The code querying it asked for (bucket, hits, window_start).
The consequence was not dull at all. Unlimited password attempts against my entire customer list, with a perfectly healthy-looking site.
The comment was right
Here is the function, near enough:
export async function rateLimit(db, kind, key) {
const limit = LIMITS[kind];
if (!limit || !db) return { ok: true };
try {
// count recent attempts, compare against the limit
...
} catch {
return { ok: true }; // a throttle that takes the site down is worse
}
}
I still agree with that comment. A rate limiter whose own table is missing should not take sign-in down for everybody. Failing open is a defensible choice for availability, and I would make it again.
Combine it with a renamed column, though, and every single call throws, is caught, and returns { ok: true }. Every request is allowed. There is no log line, no error rate, no failed request, no elevated latency. The one observable symptom is the absence of a thing that was never visible when it worked.
Nobody notices that a door stopped locking until someone walks through it.
Ask what the system now permits
The question I had been asking of swallowed exceptions was "will this crash the app." That is the wrong question, and it is why this pattern kept passing my own review.
The right question is: what does the system now permit that it did not before?
For most catch blocks the answer is nothing. You lose a cached value, you skip an analytics beat, you render a fallback. Failing open costs you a feature.
For anything enforcing a rule, the answer is the entire rule. Rate limits. Permission checks. Quota enforcement. Content filters. Anything shaped like "am I allowed to do this." When the fallback is allow, the catch is not error handling, it is a policy that activates silently under conditions you cannot observe.
Catch-and-continue converts a loud failure into a quiet change of policy.
So the pattern is not banned, it needs an alarm. Anywhere the fallback permits something, increment a counter or write a log line, because the whole point is that you cannot see it from the outside. One metric, "rate limiter failed open," alerting on anything above zero, would have caught this within a minute of deploy.
A rule with two off switches
Look at that first line again:
if (!limit || !db) return { ok: true };
If a caller passes a kind string that is not in LIMITS, limit is undefined and the throttle silently disappears. A typo in a caller, or a renamed rule, removes protection by the same route with no exception involved at all.
Same failure, second door. Enumerating the valid kinds and asserting callers against them costs about four lines, and turns a silent disable into a crash at startup, which is exactly where you want it.
When you write a guard, count its off switches. Every one is a way for the guard to vanish without saying anything.
Let the database check your column names
The part I am happiest about is the check that found it, because it is cheap enough to run everywhere.
Reading SQL carefully does not reliably catch a rename, and no reviewer volunteers for that job. But a database engine will reject an unknown column at prepare time, for free, in memory, with no server and no fixtures:
sqlite.exec(readFileSync('migrations/schema.sql'));
for (const sql of everyStringLiteralThatLooksLikeSql(sourceFiles))
sqlite.prepare(sql); // throws on an unknown table or column
Fifty-four statements, under a second. It found the bug on the first run.
Two details matter more than the code.
First, I ran it against the bug before fixing anything, specifically to watch it fail. A check you have only ever seen pass is a check you have not tested, and a scanner that quietly stops matching will report "all good" forever.
Second, it refuses to pass if it finds fewer than thirty statements. If someone changes how queries are written and my crude extraction stops matching them, I want a loud failure rather than an instant green.
That is the same lesson as the rate limiter, one level up. A verification tool that fails open is exactly as dangerous as a security control that fails open.
What I changed
Three things, all small.
Every catch block that returns a permissive result now logs. Not a debug line, a counted event I can alert on.
Every rule with a lookup table asserts its callers, so a bad key is a crash and not a silent disable.
And schema-versus-query agreement is now a test, run in CI, on a real engine. Not because renames are common, but because this class of bug produces no signal at all and I would rather a machine read my SQL than trust that I will.
The site looked perfectly healthy for weeks. That was the problem, not the evidence.