15 Silent-Failure Anti-Patterns Your AI Coding Agent Keeps Shipping — And a Review Checklist That Actually Catches Them
Every team I’ve talked to about this has the same story with different nouns. A balance is wrong, a stock count is off, a refund never happened — and when you dig in, there’s no error anywhere. Not in the logs, not in Sentry, not in the alerts. There’s a catch block that did exactly what it was told: catch the exception, log a warning, return something that compiles and moves on.
Generic advice — “think about failure semantics,” “write tests for the fallback path” — is true and useless in the same breath, because it doesn’t tell you what to actually look for in a 400-line AI-generated diff at 6pm before a release. This is that list. Fifteen specific shapes, in the languages your team actually ships, plus a checklist you can paste into your PR template today and a path for retrofitting this into code that’s already been in production for three years.
The 15 Anti-Patterns, by Shape
1. The bare catch with a zero/empty default
// before — Go
func GetShippingRate(zone string) float64 {
rate, ok := rateTable[zone]
if !ok {
log.Printf("unknown zone: %s", zone)
return 0.0
}
return rate
}
// after
func GetShippingRate(zone string) (float64, error) {
rate, ok := rateTable[zone]
if !ok {
return 0, fmt.Errorf("unknown zone %q: %w", zone, ErrZoneNotFound)
}
return rate, nil
}
The tell: a numeric or string default returned from a catch block with no comment explaining why that specific value is safe. It usually isn’t — it’s just the first “valid-looking” value of the right type.
2. The discarded Go error at the call site
// before
rate, _ := GetShippingRate(zone)
applyDiscount(rate)
// after
rate, err := GetShippingRate(zone)
if err != nil {
return fmt.Errorf("applying discount: %w", err)
}
applyDiscount(rate)
This is Go-specific and it’s the single most common AI-generated Go anti-pattern I run into — the function itself is written correctly, and the bug is entirely at the call site, one line away. Grep for , _ := and , _ = across any AI-touched Go diff before anything else.
3. The bare-loop retry with no backoff and a fabricated terminal state
# before — Python
for attempt in range(3):
try:
result = payment_gateway.charge(amount, card_token)
break
except GatewayTimeout:
continue
else:
result = {"status": "pending"}
# after
def charge_with_retry(amount, card_token, idempotency_key, max_attempts=3):
for attempt in range(max_attempts):
try:
return payment_gateway.charge(amount, card_token, idempotency_key=idempotency_key)
except GatewayTimeout:
if attempt == max_attempts - 1:
raise PaymentUnresolved(idempotency_key)
time.sleep((2 ** attempt) + random.uniform(0, 1))
“Pending” is not a real payment state your gateway returned — it’s a state the model invented because “the loop ended without success” needed a name. Anywhere you see a status string that isn’t one of the values your actual downstream system defines, assume it’s fabricated.
4. Catching the base Exception/Throwable instead of the specific type
// before — Kotlin
fun fetchInventoryCount(sku: String): Int {
return try {
inventoryClient.getCount(sku)
} catch (e: Exception) {
Int.MAX_VALUE
}
}
// after
fun fetchInventoryCount(sku: String): LookupResult {
return try {
LookupResult.Success(inventoryClient.getCount(sku))
} catch (e: NotFoundException) {
LookupResult.Failure(FailureReason.SKU_NOT_FOUND)
} catch (e: SocketTimeoutException) {
LookupResult.Failure(FailureReason.SERVICE_UNAVAILABLE)
}
}
Int.MAX_VALUE as an inventory fallback means a network blip tells your storefront it has infinite stock. Anytime a catch block’s fallback value is more optimistic than “unavailable,” someone chose convenience over correctness, and it’s worth asking why out loud in review.
5. Swallowing the exception and returning null where the caller assumes non-null
// before — TypeScript
async function getUserPreferences(userId: string): Promise {
try {
return await prefsClient.fetch(userId);
} catch {
return null as any; // caller signature says Preferences, not Preferences | null
}
}
// after
async function getUserPreferences(userId: string): Promise {
try {
return await prefsClient.fetch(userId);
} catch (e) {
if (e instanceof NotFoundError) return DEFAULT_PREFERENCES; // explicit, documented default
throw e; // infra failure propagates
}
}
The as any is the tell — the model lied to the type system to make a failure fit a signature that doesn’t allow for one, instead of changing the signature to be honest about failure.
6. The retry that isn’t idempotent
A retry loop around a POST that creates a resource, with no idempotency key, means a timeout-then-retry can create the resource twice. This is invisible in dev because your test database is empty and fast; it shows up in production as duplicate orders, duplicate emails, duplicate charges — always under load, never in the demo.
7. Logging the exception and continuing a loop over a batch
# before — Python
for record in records:
try:
process(record)
except Exception as e:
logger.warning(f"failed to process {record.id}: {e}")
continue # silently drops the record, batch reports "success"
# after
failures = []
for record in records:
try:
process(record)
except ProcessingError as e:
failures.append((record.id, str(e)))
if failures:
raise BatchPartialFailure(failed=failures, total=len(records))
A batch job that “completed” while silently dropping 12% of records is worse than one that crashed, because a crash gets noticed. This pattern is extremely common in AI-generated ETL and import scripts specifically because “process the whole list without stopping” is almost always the literal prompt.
You Shipped It But You Don't Own It: The AI Code Ownership Crisis AI code ownership — knowing not just who wrote the code but who actually understands it — has become one of the...
8. Circuit-breaker-shaped code with no actual circuit breaker
An AI agent asked for “resilient” code often produces a retry loop and calls it done. Resilience against a dependency that’s fully down isn’t more retries — it’s failing fast. Retrying blindly against a dead dependency doesn’t protect you, it adds load to something already struggling and can turn a partial outage into a cascading one.
9. Timeout values copy-pasted without justification
AI-generated HTTP clients love timeout=30. Thirty seconds is fine for a batch export, catastrophic for a request in your checkout path where the user is staring at a spinner and every upstream service in the chain is also waiting on you. Every timeout value in AI-generated code should be treated as a placeholder until someone deliberately sets it based on the actual SLA of the call.
10. The health check that always returns healthy
# before — Python
@app.get("/health")
def health():
try:
db.ping()
return {"status": "ok"}
except Exception:
return {"status": "ok"} # yes, this happens
# after
@app.get("/health")
def health():
try:
db.ping()
return {"status": "ok"}
except Exception as e:
return JSONResponse(status_code=503, content={"status": "unhealthy", "detail": str(e)})
This looks absurd written out, but it’s a common consequence of pattern #1 applied to a health endpoint — the model wraps the check in a try/except “to be safe” and the except branch just repeats the happy path. Your orchestrator keeps routing traffic to a dead instance because the health check itself never fails.
11. Structured logging keys that don’t match anything you actually query on
AI-generated log lines are often prose-shaped (f"failed to process user {user_id}") instead of structured with consistent keys your log aggregator can filter on. The exception gets logged, technically, but nobody can build an alert on it because the field names are different in every function.
The Engineering Debt of AI: Why "Working" Code Fails in Production Most mid-level developers enter the AI field thinking it is just another API integration. You send a string, you get a string, and you...
12. The “just in case” broad catch around code that can’t actually throw what it’s catching
A try/except around a pure function with no I/O, catching a generic exception “for safety.” This doesn’t cause data corruption, but it’s a strong signal the model is pattern-matching “add error handling” onto code where the instruction doesn’t apply — worth flagging because if it’s here, the same reflex is probably misapplied somewhere that does matter in the same PR.
13. Fallback to cached/stale data with no staleness signal to the caller
// before — TypeScript
async function getExchangeRate(pair: string): Promise {
try {
return await rateApi.fetch(pair);
} catch {
return cache.get(pair); // could be 6 hours old, caller has no idea
}
}
// after
async function getExchangeRate(pair: string): Promise<{ rate: number; stale: boolean }> {
try {
return { rate: await rateApi.fetch(pair), stale: false };
} catch {
const cached = cache.get(pair);
if (!cached) throw new RateUnavailableError(pair);
return { rate: cached.value, stale: true };
}
}
Serving stale data is often a legitimate fail-open decision. The bug isn’t the fallback — it’s that the caller has no way to know it happened, so a financial calculation silently uses a six-hour-old exchange rate as if it were live.
14. Merged error types across unrelated failure causes
A single catch (Exception e) around a block that does a DB read, a cache write, and an external API call, with one log line covering all three. When it fires, you know something in that block failed — you don’t know which of three completely different systems is the actual problem, and you’re debugging blind at 2am instead of reading a specific exception type off the log line.
15. The AI-generated test that asserts the fallback path is correct behavior
# before — the test locks in the bug
def test_get_user_balance_on_db_error():
with mock_db_failure():
assert get_user_balance("u1") == 0.0 # asserts the silent failure is "correct"
# after — the test proves the fallback is visible and distinct
def test_get_user_balance_on_db_error():
with mock_db_failure():
with pytest.raises(BalanceLookupUnavailable):
get_user_balance("u1")
This is the one that actually locks the anti-pattern in permanently. The model writes the buggy function, then writes a test that verifies the buggy behavior, and now your CI is green and will stay green forever — the fallback isn’t just unnoticed, it’s now protected by a passing test suite that will flag the correct fix as a regression.
The AI PR Review Checklist
Paste this into your PR template for anything AI-generated or AI-assisted. It’s designed to take under five minutes per PR — the goal is catching the fifteen shapes above, not a full audit.
## AI-Generated Code Review Checklist
- [ ] grep the diff for `except Exception`, `catch (Exception`, `catch {}` — read every match
- [ ] grep Go diffs for `, _ :=` and `, _ =` on any call that returns an error
- [ ] every catch block: does the fallback value type-match what a REAL success looks like,
or is it a suspiciously convenient default (0, "", null, MAX_VALUE, empty list)?
- [ ] every retry loop: does it have backoff? does the retried call have an idempotency key?
- [ ] every "terminal" state after a retry loop (e.g. "pending"): is this a real state your
system defines, or did the model invent it?
- [ ] every timeout value: was it deliberately chosen, or is it a copy-pasted default (30s, 60s)?
- [ ] does the health check / readiness probe actually fail when its dependency fails?
- [ ] any batch/loop processing: does a per-item failure get surfaced, or silently dropped
with a `continue`?
- [ ] for every fallback path: is there a test that asserts the CALLER sees a distinct signal,
not just that the function "didn't crash"?
- [ ] for cached/stale fallbacks: does the return type expose staleness to the caller?
- [ ] infra error vs business error: are they caught separately, or merged into one branch?
- [ ] is there a metric incrementing when the fallback path fires, or only a log line?
Measuring Whether This Is Actually Costing You Anything
Don’t take this article’s word for it — instrument it and let your own system tell you. This isn’t a published benchmark, it’s the minimum tracking setup to turn “I have a bad feeling about this” into a number you can act on:
# Python — track every fallback firing, tagged by service and reason
from prometheus_client import Counter
fallback_fired = Counter(
"silent_fallback_total",
"A catch block returned a fallback instead of propagating",
["service", "function", "reason"],
)
Instrument every catch-with-fallback you find during the checklist pass above — even the ones you’re not fixing yet. Two weeks of this data answers three questions no article can answer for your specific system: how often do these actually fire in production, which services account for most of it, and does the fallback rate correlate with anything customer-visible (support tickets, refund volume, reconciliation discrepancies). Teams that skip this step end up either over-fixing code paths that fire once a year, or ignoring one that’s firing 400 times a day into a queue nobody’s watching. You cannot prioritize the fifteen patterns above by feel — the instrumentation is what tells you which of your specific services actually needs the fix first.
Retrofitting Error Contracts Into Legacy Code
Everything above is easy to apply going forward. It’s a different problem when you’ve got three years of production code where none of this was ever defined, and a full rewrite isn’t happening. Here’s a path that doesn’t require a rewrite:
AI Python Generation: From Rapid Prototyping to Maintainable Systems In the current engineering landscape, python code generation with ai has evolved from a novelty into a core component of the development lifecycle. AI can produce...
Step 1 — instrument before you fix anything
Don’t start by changing behavior. Start by adding the fallback-fired counter (above) to existing catch blocks, unchanged otherwise. This is a low-risk, additive change, and it’s what tells you where to spend the actual fixing effort — see the metrics section above.
Step 2 — wrap, don’t rewrite, at the boundary
You don’t need to touch every internal function. Define the error contract at the service’s external boundary — the API layer, the message consumer entry point — and let internal fallback behavior stay as-is for now while the boundary translates it into something explicit for callers outside the service.
# Python — boundary-level contract without touching internals
@app.middleware("http")
async def surface_silent_failures(request, call_next):
response = await call_next(request)
if request.state.__dict__.get("fallback_fired"):
response.headers["X-Degraded-Response"] = "true" # caller can now see it
return response
Step 3 — strangle the highest-fired paths first
Use the metric from step 1 to rank catch blocks by how often the fallback actually fires in production, not by how scary the code looks in review. Fix the top five by frequency using the Result/Either pattern from the anti-pattern list above. Leave the ones that fire zero times in a quarter alone — they’re not free to fix, and they’re not costing you anything either.
Step 4 — put the contract in the type system, not the docs
A wiki page that says “this function throws UserNotFoundError” gets out of date the first time someone changes the function and doesn’t update the wiki. A Result<T, E> or sealed-class return type gets out of date the moment it doesn’t compile. For legacy code you can’t safely change the signature of yet, wrap it: build a thin adapter with the honest, typed signature around the untouched legacy function, and migrate callers to the adapter one at a time instead of touching the legacy internals at all.
Step 5 — make the checklist a required PR gate, not a suggestion
The checklist above only works long-term if it’s enforced the same way a linter is — as a required step for any PR touching a file with AI-assisted commits, not a “nice to have” reviewers skip when they’re busy. Teams that treat this as optional see the fallback-fired metric flatline for a month and then creep right back up as review discipline fades.
FAQ
Is this list specific to one AI coding tool?
No — these fifteen shapes show up across Claude, Copilot, and GPT-based agents alike, because they’re a property of what “code that doesn’t crash” looks like across the industry’s training data, not a quirk of one vendor’s model.
Do I need to fix all fifteen patterns before shipping AI-generated code?
No — instrument first (the metrics section), rank by actual fire frequency in your system, and fix the highest-impact ones. Treating this as a blocking gate for every PR is how teams abandon the practice after a month.
What’s the fastest single check to add today?
The grep for except Exception, catch (Exception, and Go’s discarded , _ := error returns. It takes under a minute per PR and catches the majority of the fifteen patterns above in one pass.
How do I know if this is actually worth the engineering time to fix?
You don’t, until you instrument it. That’s the point of the metrics section — the fallback-fired counter turns this from a subjective code-smell argument into a number your team can prioritize against actual incident and support data.
Written by: