You wrap your OpenAI call in a try-catch. The API responds with HTTP 200. Your monitoring logs success. Your dashboard shows green. Twelve hours later, a customer emails: why did my request return nothing?
This is a silent failure — the detection gap that almost every monitoring setup misses by default.
The problem: what "success" actually means
When you instrument an LLM agent, most observability tools watch for one thing: did the API call fail?
try:
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": query}]
)
# Success — log it
log_to_monitoring(response)
except Exception as e:
# Failure — log and alert
log_to_monitoring(error=e)
The catch block fires on network timeouts, authentication errors, rate limits, malformed requests — the visible failure modes. But there's a whole class of failures that return HTTP 200 anyway:
- The API call succeeds.
- The response object is syntactically valid.
- But
response.choices[0].message.contentis empty or whitespace-only.
The monitoring layer sees the response code and declares victory. The agent proceeds with nothing to work with. Your system continues silently.
Why this happens (and why it's surprisingly common)
Empty outputs aren't always bugs. Sometimes they're hints of a real problem:
The model hit an internal limit. Certain Anthropic models or OpenAI beta versions will return 200 with an empty completion if the output is too long for the context window after token counting, or if the model's internal filtering catches the request. The API succeeded; the inference did not.
Prompt injection or safety filtering. A user prompt contains adversarial input that the model's safety classifier flags. The model refuses to respond, returns 200, and sends back an empty string — by design. Your monitoring sees 200 and thinks it's fine.
Tool-call-only agents. An agent framework (LangChain, CrewAI) is configured to return tool calls only, never direct text. The model returns
[{"type": "tool_use", "name": "...", ...}]with no text content inmessage.content. Strictly speaking, the agent did run — but if your monitoring only looks atcontent, it sees empty and logs it as a failure when it was actually correct.Provider parsing failures. A non-standard provider or a custom SDK wrapping an OpenAI-compatible endpoint garbles the response parsing. The outer call returns 200 (the wrapper succeeded in getting a response), but the actual content extraction fails and returns null.
All of these return HTTP 200. All of them are invisible to standard error traps.
What observability layers typically miss
Let's map the layers of a typical LLM observability stack and see where the gap lives:
| Layer | Watches | Catches HTTP 200 + empty? |
|---|---|---|
| Try-catch / error handler | Exception objects | No — no exception raised |
| HTTP status codes | 4xx, 5xx, timeouts | No — 200 is success |
| Token counts | Input tokens captured | Depends — output tokens = 0 or missing? |
| Response parsing | JSON validity | No — empty string is valid JSON |
| Logging middleware (OpenTelemetry, etc.) | Request/response metadata | Only if you explicitly log content length |
| LLM-specific SDK (e.g., LangChain callback) | Message count, tool calls | Only if message.content is explicitly checked |
The gap: none of these layers default to checking whether the actual output is non-empty. They all pass the call through as "success" because, by the narrow definition of "did the API succeed," it did.
How to detect it: the mechanics
To reliably catch empty outputs, you need to add an explicit check in the instrumentation layer — a guard that inspects the content, not just the status.
Here's the pattern:
import openai
from opsveritas import opsveritas
# Initialize monitoring
opsveritas.init(secret="your-secret")
# Wrap the client — this instruments tokens and cost automatically
client = openai.OpenAI(api_key="...")
wrapped_client = opsveritas.wrap(client)
# Make the call
response = wrapped_client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Translate this to French: ..."}]
)
# Now the explicit check — capture the output and test it
output_text = response.choices[0].message.content or ""
output_is_empty = not output_text.strip()
# Log or alert
if output_is_empty:
print("SILENT FAILURE: HTTP 200 but empty output")
# This is the signal your monitoring should catch
else:
print(f"Success: {output_text}")
The SDK here captures tokens and cost automatically. But the empty-output check has to live in your code, right after the response lands. Why? Because only you know what "empty" means for your agent — it might be zero characters, or it might be a response that's just whitespace, or it might be null. The infrastructure can't make that judgment.
For agents running in frameworks like LangChain or CrewAI, the same pattern applies — you wrap the model, then add a post-call assertion:
from langchain.chat_models import ChatOpenAI
from opsveritas import opsveritas
model = ChatOpenAI(model="gpt-4")
model = opsveritas.langchain("my-agent")(model)
result = model.invoke({"query": "..."})
if not result or not str(result).strip():
# Alert: silent failure detected
...
The SDK sends telemetry (tokens, cost, latency). But the empty check is application logic — it has to live where the response is handled.
What happens at the monitoring backend
Once you've added that check, here's what a proper monitoring backend should do:
- Receive the telemetry — tokens, cost, status, and a flag for empty output.
-
Classify the failure —
status: successbutoutput_tokens: 0orcontent_length: 0? That's a silent failure, not a normal success. - Alert separately — silent failures are different from timeout failures or rate-limit failures. Your runbook for each is different. A silent failure might mean "retry this agent with a different prompt" or "check the safety filter logs." A timeout means "increase concurrency limits."
- Track baseline — over time, the monitoring system learns this agent's normal output-token distribution. If output suddenly drops to zero for 3 runs in a row, that's a regression signal.
Why this matters for cost and reliability
Silent failures compound two problems:
- Cost: An agent that runs repeatedly, returns nothing, and never triggers an alert keeps calling your LLM API. With millions of requests a day, even one silent-failure loop can burn thousands in cost — because the loop keeps retrying, each retry succeeds (200), and each success is invisibly empty.
- Reliability: A customer-facing system that silently returns empty results erodes trust before you even know there's a problem. By the time complaints arrive, you're debugging backwards through hours of logs trying to spot the failure that your monitoring said never happened.
The fix isn't complicated — but it requires two pieces:
- Explicit output checks in your agent code (one line per agent).
- Monitoring that understands empty output as a distinct failure mode (not lumped in with general "success").
Neither is rocket science. But most setups skip both, which is why HTTP 200 + empty output remains one of the industry's favorite silent killers.
Have you hit this? Silent-failure loops that your monitoring missed because they all returned 200? The earlier you catch them, the less cost and reputation they burn. The gap is real — and closeable.
More on how we built this detection into our own monitoring: agents.opsveritas.com
Top comments (0)