Guide · Invisible LLM Failures

The Engineer’s Guide to Invisible LLM Failures

New failure classes from nondeterministic APIs, and why status-code monitoring can’t see them

By Eric Anderson, CTO, ControlTheory Last updated: July 17, 2026 12 minute read

The short answer: a whole family of LLM API failures returns HTTP 200 while withholding, refusing, or truncating the actual response, or fails loudly with the wrong reason attached. Status-code monitoring is structurally blind to all of it, because the failure lives in the response body and the stream, a layer your tooling never inspects. This guide maps the three failure classes, explains why each one hides, and lays out the observability posture that catches them.

The silence

What makes an LLM API failure invisible?

Your monitoring stack was built on a contract: request goes in, status code comes out, green means healthy. Every uptime probe, every health check, every alerting rule you run today inherits that assumption from a world where software was deterministic. Same input, same output, and when something broke, it broke loudly, with a 500 and a stack trace.

LLM APIs ended that contract. The same request can succeed, refuse, truncate, or die mid-stream, and in most of those cases the HTTP layer reports success. Your dashboard stays green. Your user gets nothing, or worse, gets a refusal where their answer should be.

This is not a guide about hallucinations or output quality. It is one layer down, at the transport layer, where the question is not “was the answer good” but “did the call actually succeed at all.” Every error in it is real, documented in the wild, and verified against current provider behavior.

These failures aren’t quiet because they’re subtle. They’re quiet because they report success.

That distinction matters, because “silent AI failure” has become a crowded phrase. Most of the industry uses it to mean quality drift: the model got a little worse, the evals slipped, nobody noticed. That silence is statistical. The silence in this guide is structural: the transport layer said success, and everything downstream believed it. These failures are quiet by construction, not by accident. Content moderation and capacity management run inside the provider’s inference pipeline, after your request has already been accepted. By the time the filter fires or the capacity event hits, the 200 is already on the wire. The failure arrives as a field in the response body or an event in the stream. Call that layer the quiet zone: the part of every response where failure signals exist but never propagate to your tooling, because status-code monitoring never inspects it.

The quiet zone An LLM API response shown as three layers. Monitoring watches only the HTTP status line, which reads 200 OK. The failure lives below it, in the response body and stream events: the quiet zone. Uptime monitor watches the status line only HTTP status: 200 OK everything reads healthy Response body finish_reason: content_filter stop_reason: refusal Stream events overloaded_error after the 200 529 your fallback never sees The quiet zone Monitoring never inspects below the status line. Your users find what your tooling can’t. DSTL8 · CONTROLTHEORY.COM

The quiet zone: monitoring keys on the status line, the failure lives in the body and the stream.

That construction is what makes this class dangerous rather than merely annoying. You aren’t missing an alert. You’re receiving a false all-clear, on every affected call, at scale. Every signal you would normally trust is affirmatively telling you nothing is wrong, which is why these failures are found by angry users instead of by tooling, and why the debugging sessions they trigger are so maddening: the evidence contradicts the instrumentation.

These are unknown unknowns in the most literal sense. Not failure modes you assessed and accepted. Failure modes your instrumentation cannot represent.

The taxonomy

What are the three classes of invisible LLM failures?

Three classes, ordered by how completely they hide.

ClassWhat it looks likeWhere the failure livesWhy it’s silent
Silent failures A healthy 200 in your access log. finish_reason: content_filter, stop_reason: refusal, a 529 arriving mid-stream after the 200. A field in the response body, or an event inside the stream. The verdict rides on a success status. No layer you alert on ever reads it.
False-positive moderation Loud non-2xx errors on innocent inputs. invalid_prompt, image content_policy_violation, Azure content_filter variance. A content verdict, wrongly attached to a format or policy reason. The reason points the wrong way. Each event looks like a one-off; the pattern only exists in aggregate.
Phantom failures A 400 on a request identical to one that just worked. Thinking blocks “cannot be modified,” “Extra inputs are not permitted.” Your own middleware: history filtering, a gateway stripping headers. The blame arrow points at the provider. Hours go to the wrong hypothesis.

Class 1: Silent failures. The 200 that lies.

The transport layer says success. The application failed. Nothing in your stack disagrees with the status code, so nothing fires.

finish_reason: content_filter (OpenAI, 200 OK). The response comes back with a successful status and the content withheld or truncated by moderation mid-generation. Your access log records a healthy request. Your user sees an empty or amputated answer. If your code reads choices[0].message.content without checking finish_reason, the failure propagates as a blank string, not an exception.

Why it’s silent: the verdict is a body field on a 200. No layer you alert on ever reads it.

stop_reason: refusal (Anthropic, 200 OK). A successful response whose entire payload is a refusal. The API did exactly what it is designed to do. Your application, which expected content, did not. stop_details.category sometimes tells you why (“cyber”, for example) and sometimes is null, so even body-aware handling can’t always classify it.

Why it’s silent: a refusal is a valid, successful completion as far as the protocol is concerned.

overloaded_error mid-stream (Anthropic, 529 after the 200). The cruelest one. On a streaming call, the 200 arrives first, then the overload error arrives as an SSE event inside the stream. Any fallback logic keyed to status codes never fires, because the status code was fine. These spike unpredictably, with a pattern of surges after major model launches, which is exactly when your own traffic is also spiking.

Why it’s silent: the error arrives after the moment your monitoring stopped looking.

Class 2: False-positive moderation. Loud failure, wrong reason.

These fail with a non-2xx status, so your error rate sees them. What your error rate can’t see is that the reason attached is wrong, which sends every debugging session in the wrong direction.

invalid_prompt, “flagged as potentially violating our usage policy” (OpenAI). A moderation verdict dressed as a validation error. It fires on innocent prompts, correlates with prompt length rather than content in documented cases, and is model-dependent: the same prompt passes on GPT-4o and fails on a reasoning model. Naive error handling treats it as a malformed request and retries, which fails again, because nothing about the request was malformed.

Why it hides: the error name points at your input format. The actual verdict is about content, and it’s wrong.

Image and vision content_policy_violation (OpenAI). Harmless images rejected as unsafe: screenshots of invoices, documents containing names and addresses, ordinary photos. For teams doing OCR or document parsing, these arrive as an unexplainable, non-reproducible scatter of rejections.

Why it hides: each rejection looks like a one-off. The pattern only exists at the aggregate level nobody is watching.

Azure content_filter nondeterminism (Azure OpenAI). The same prompt trips the violence filter on one deployment and passes on another. “Good morning” has been documented triggering it. Identical requests get jailbreak-flagged on one endpoint only. For enterprises running multiple deployments, this means the filter itself is a per-deployment variable that nobody’s dashboard treats as one.

Why it hides: per-deployment behavior differences are invisible to any view that aggregates across deployments, which is every default view.

Class 3: Phantom failures. Errors your own stack manufactures.

The provider isn’t lying and the filter isn’t wrong. Your own middleware created the failure, and the error message points at the provider anyway.

thinking blocks “cannot be modified” (Anthropic, 400). A random 400 on a normal follow-up prompt in a multi-turn conversation. The real cause: application code that filters content blocks by type before resending history, silently dropping thinking or redacted_thinking blocks the API requires intact. Nothing changed in your prompt. Something changed in what your code stripped.

“Extra inputs are not permitted” (Anthropic, 400). Anthropic’s API is strict about request shape: parameters an endpoint doesn’t accept are rejected outright. The common cause is exactly what it looks like, code passing fields the endpoint doesn’t support. The phantom variant is nastier: the fields are valid beta parameters, but a gateway or proxy between you and the API stripped the anthropic-beta header in transit, so a request that was valid a moment ago now fails. That one presents as a schema bug in your code. It’s a header your infrastructure ate.

Why they hide: the error is real, the status is honest, and the blame arrow points at the wrong component. Hours go to the wrong hypothesis.

The architecture gap

Why can’t status-code monitoring see these errors?

This is not a configuration gap. It’s an architectural one.

Uptime checks, health probes, synthetic monitors, and status-code alerting all share one design decision: they treat the HTTP status line as the source of truth about request health. That decision was correct for two decades. Every failure class above is defined by breaking it. Class 1 failures put the truth in the body or the stream. Class 2 failures put a false truth in the error reason. Class 3 failures put the truth in a component that isn’t even emitting the error.

Detecting any of them requires inspecting the quiet zone, the response body and stream events, on every call, including, especially, the successful ones. Almost no team logs response bodies on 200s, because for deterministic APIs that was pure cost with no information. For LLM APIs, the 200s are where the failures live.

The tooling you trust to tell you something broke is watching the one layer where nothing looks broken.

The catch

How do you monitor for LLM failures that return 200?

You can’t fix what you can’t see, so detection comes before avoidance. Four changes, in order of leverage.

1. Log the body on every call, not just non-2xx. This is the foundational move and the one that feels most wasteful by deterministic-era instincts. Capture finish_reason, stop_reason, stop_details, stream error events, and content filter annotations as first-class fields, on successes.

2. Classify, don’t grep. A refusal, a filter truncation, and a mid-stream overload are semantically different failures with different causes and different fixes, but they share almost no string content across providers. Detection has to work at the level of what the log line means, not what it matches.

3. Watch rates, not instances. One refusal is noise; models refuse things, filters fire, that’s the product working. A spike in refusals is signal: a prompt template change, a model version change, a provider-side filter adjustment, a capacity event. The unit of alerting for this failure class is the trend, not the event.

4. Correlate spikes to changes. When the refusal rate jumps, the question is always “what changed”: a deploy on your side, a model change on theirs, a new prompt pattern from users. An invisible failure becomes a fixable one at the moment it’s attached to its cause.

The signature table

Every signature in this guide, in one reference. Status is what your monitoring sees; the class tells you where to actually look.

ProviderStatusSignatureClassWhat it actually means
OpenAI200finish_reason: content_filterSilentModeration withheld or truncated content mid-generation. Content can come back empty on a successful status.
Anthropic200stop_reason: refusalSilentA successful response whose entire payload is a refusal. stop_details.category is sometimes set, sometimes null.
Anthropic200, then SSE erroroverloaded_error mid-streamSilentCapacity event after the 200 is on the wire. Status-code fallback never fires. Spikes cluster around provider capacity events.
OpenAI400invalid_prompt: “flagged as potentially violating our usage policy”ModerationA content verdict dressed as a validation error. Documented firing on innocent prompts, correlating with prompt length and model, not content.
OpenAI400Image/vision content_policy_violationModerationHarmless images rejected as unsafe, including screenshots and documents containing names or addresses.
Azure OpenAI400content_filter with severity matrixModerationThe same prompt trips the filter on one deployment and passes on another. Filter behavior is a per-deployment variable.
Anthropic400thinking blocks “cannot be modified”PhantomApplication code filtered content blocks by type before resending history, stripping blocks the API requires intact.
Anthropic400“Extra inputs are not permitted”PhantomStrict parameter rejection: the endpoint doesn’t accept a field the request contains. Usually code passing unsupported parameters; the phantom variant is a gateway stripping the anthropic-beta header in transit, so valid beta fields suddenly fail.
OpenAIOut of bandAccount-level policy warningAggregateNot a per-request error: the account gets warned on accumulated moderation signals. Visible only as an elevated flag rate over time.
This is what Dstl8 does by default

Dstl8 reads the quiet zone

A reader can build all four detection changes with enough engineering time. This is where Dstl8 starts: it analyzes the actual text of every log message regardless of severity or status code, so a silent content_filter, a body-level refusal, or a mid-stream 529 surfaces as a recurring, watchable pattern instead of hiding as a 200 in your access log. When one of these spikes after a deploy or a model change, Möbius, the AI agent inside Dstl8, flags it and points at the correlated cause. Where the cause is infrastructure or configuration, you get a named fix. Where the cause is in application code, like history-filtering that strips thinking blocks, the cause and its evidence are handed to the agent with access to your repo, in the tool where you already work.

Create Free Account Learn About Dstl8
The avoidance

Can you prevent these failures?

Here is the honest part, and the part most vendors won’t say: most of this failure class cannot be prevented, because moderation verdicts and capacity events happen on the provider’s side of the wall. You do not control when a filter fires, which deployment it fires on, or when capacity tightens. Avoid what you can. Catch what you can’t.

What you can actually do:

Handle refusal and filter outcomes explicitly. Treat finish_reason: content_filter and stop_reason: refusal as first-class branches in your response handling, with their own user-facing behavior, not as an assumed-impossible default case. A 200 does not mean content.

Consume streams defensively. Expect error events after the 200. Build stream-level fallback, because status-level fallback provably never fires for this class.

Pass conversation history back intact. Never filter content blocks by type in multi-turn code. The blocks you don’t recognize are the ones the API requires.

Audit your gateway’s header behavior. If a proxy sits between you and the provider, verify beta and feature headers survive the trip. One stripped header presents as a schema bug you’ll chase for hours.

Have a failover posture for overload spikes. 529 surges cluster around provider capacity events. Decide in advance what degrades: model tier, provider, or user-facing behavior.

Know your false-positive surface. If you send long prompts, code-heavy prompts, or user images, you are statistically exposed to false-positive moderation. That’s a rate to watch, not a bug to fix.

Every one of these reduces exposure. None of them eliminates it. The failures that remain will not announce themselves, which is why the durable posture is seeing, not hoping.

The leadership view

What does this mean if you lead the team?

The industry has already voted on whether this layer matters. In New Relic’s 2026 State of AI Coding report, 96 percent of technology leaders rated observability very or extremely important for AI-generated code, and not a single respondent rated it unimportant. The same survey documents the exact gap this guide lives in: 94 percent rate AI-generated code higher quality than human-authored code at review time, while 78 percent report an increase in production incidents once it ships. Code that reads well is not code that runs well, and the difference only shows up at runtime, in precisely the layer where this failure class hides. Gartner projects LLM observability investment reaching half of GenAI deployments by 2028, up from roughly 15 percent today, driven by the rise of production AI failures that give no outward sign.

The teams shipping AI features on status-code monitoring alone are not running lean. They’re running blind, and the gap between those two shows up as user-reported incidents your tooling swore weren’t happening.

If your team is already thinking about trust in AI-shipped code, this failure class is the runtime half of that argument: How to Trust What Your Agents Ship covers the discipline, and The Agentic Engineer’s Guide to Runtime Feedback covers the practice. This guide is the evidence that the old instrumentation can’t carry either one.

The incident files

Where do you go when you’re hitting one of these right now?

Each failure above has a full incident page: exact error signature, verified provider behavior, reproduction context, and remediation detail for the engineer holding the pager.

Silent failures (200 OK, broken UX)

False-positive moderation blocks

FAQ

Frequently asked questions

What is an invisible LLM failure?

An LLM API failure that either returns a successful HTTP status while the response is broken (a silent content filter, a body-level refusal, a mid-stream overload error) or fails loudly with a misleading reason attached (a moderation verdict presented as a validation error). In both cases, status-code monitoring either never fires or fires with the wrong story.

Why do LLM APIs return 200 for failed responses?

Because content moderation and capacity management run inside the inference pipeline, after the request has been accepted. The request was valid and was processed, so the transport layer reports success. The failure is recorded as a field in the body (finish_reason, stop_reason) or as an event in the stream, which status-code monitoring never inspects.

Can these failures be prevented?

Partially. You can handle refusal and filter outcomes explicitly, consume streams defensively, keep conversation history intact, audit gateway header behavior, and prepare failover for overload spikes. But moderation verdicts and capacity events happen on the provider’s side, so a residual failure rate is structural. The durable posture is detection: watching these as rates over time and correlating spikes to changes.

How is this different from LLM evaluation and hallucination monitoring?

Eval tooling asks whether the model’s answer was good. This failure class is one layer down: whether the call succeeded at all. A hallucination is a bad answer inside a real response; its silence is statistical (quality drifted and nobody measured). An invisible failure is a missing, refused, or truncated response wearing a success status; its silence is structural (the transport layer reported success). You need different instrumentation for each: evals wrap your model calls in scoring; this class requires reading the response body and stream of every call in your logs.

How does Dstl8 detect failures that return 200?

Dstl8 analyzes the text of every log message regardless of severity or status code, classifies what each line means, and surfaces recurring patterns like refusal spikes or mid-stream errors as trends you can watch and alert on. When a pattern spikes, Möbius, the AI agent inside Dstl8, correlates it to the change that caused it: a deploy, a model change, a prompt pattern.

Your uptime monitor says 200. Your users are getting refused. See the difference.

Dstl8 surfaces the failures your status dashboard can’t. Cited to the log line.

Start Free 14-Day Trial
brew install control-theory/dstl8/dstl8 && dstl8 setup

Prefer to start local and open source? Gonzo reads your log streams in the terminal, free, in two minutes. Want the walkthrough instead? Request a demo.