<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jonno</title>
    <description>The latest articles on DEV Community by Jonno (@vexithon).</description>
    <link>https://dev.to/vexithon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4054742%2F1c131af9-32a7-4531-8c09-54cbf47f10f8.png</url>
      <title>DEV Community: Jonno</title>
      <link>https://dev.to/vexithon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vexithon"/>
    <language>en</language>
    <item>
      <title>LLM pipelines fail silently by default</title>
      <dc:creator>Jonno</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:08:31 +0000</pubDate>
      <link>https://dev.to/vexithon/llm-pipelines-fail-silently-by-default-1klo</link>
      <guid>https://dev.to/vexithon/llm-pipelines-fail-silently-by-default-1klo</guid>
      <description>&lt;p&gt;A normal program that breaks throws an exception. An LLM pipeline that breaks writes you a paragraph explaining how well it went.&lt;/p&gt;

&lt;p&gt;You know the shape of this problem even if you haven't named it. Something worked in testing. It shipped. Six weeks later a number stops making sense, and nobody can say when it started going wrong — because nothing ever failed. There were no errors. The logs are full of successes.&lt;/p&gt;

&lt;p&gt;This isn't bad luck or sloppy engineering. It's structural. The failure surface of a language model is &lt;em&gt;plausible output&lt;/em&gt;, and plausible output passes every check you would normally write. Type checks pass. Schema validation passes. The string is the right length, the JSON parses, the number has the right number of digits. Your pipeline has no way to tell the difference between a correct answer and a confident wrong one, so it records both as success.&lt;/p&gt;

&lt;p&gt;Below are five of these failure modes: what happens, why it stays hidden, and how to catch it. Then a fifteen-minute audit you can run against your own pipeline today.&lt;/p&gt;




&lt;h2&gt;
  
  
  The principle underneath all of them
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A step has not succeeded because it did not raise. It has succeeded when something independent confirms the intended effect happened.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nearly every failure in this article is a violation of that one sentence. There are three levels of knowing an operation worked, and most systems sit at the second while believing they're at the third:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;What you have&lt;/th&gt;
&lt;th&gt;Trustworthy?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-report&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The step says it worked&lt;/td&gt;
&lt;td&gt;No — this &lt;em&gt;is&lt;/em&gt; the failure mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No exception&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nothing raised&lt;/td&gt;
&lt;td&gt;No — absence of error isn't evidence of effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Verified&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A separate read confirms the effect&lt;/td&gt;
&lt;td&gt;Yes. This is the bar.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hold onto the distinction between &lt;em&gt;didn't crash&lt;/em&gt; and &lt;em&gt;verified&lt;/em&gt;. It's the whole thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Empty-success
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hides for weeks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An extraction step gets an input it can't handle — an unusual layout, a truncated document, a language it wasn't prompted for — and returns an empty structure instead of raising. &lt;code&gt;{}&lt;/code&gt;. &lt;code&gt;[]&lt;/code&gt;. An empty string.&lt;/p&gt;

&lt;p&gt;Downstream code treats "no data" as "no data found", which is a completely legitimate result. So the pipeline completes, the batch is marked done, and the record count just quietly drops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it hides:&lt;/strong&gt; the shape is valid. If you're validating with a schema, an empty dict often satisfies it. Nothing is malformed. There's simply less than there should be, and nobody is counting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; assert on content, not shape. If a field is required for the record to be worth having, an empty value is a failure — not a result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong — shape check only. `{}` sails straight through.
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Right — the fields that make the record useful must be present
&lt;/span&gt;&lt;span class="n"&gt;REQUIRED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;REQUIRED&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ExtractionFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;empty required fields: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;not result.get(k)&lt;/code&gt; rather than &lt;code&gt;k not in result&lt;/code&gt;. A present-but-empty field is the more common case and the harder one to see — &lt;code&gt;{"name": ""}&lt;/code&gt; passes a membership check without complaint.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The &lt;code&gt;finish_reason&lt;/code&gt; nobody reads
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One-line fix.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your output hits &lt;code&gt;max_tokens&lt;/code&gt;. You get a clean-looking partial result: a JSON object missing its last two fields, a list missing its tail, a summary that stops before the conclusion.&lt;/p&gt;

&lt;p&gt;The API told you this happened. It's right there in &lt;code&gt;finish_reason&lt;/code&gt;, and almost nobody checks it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it hides:&lt;/strong&gt; the prefix is perfectly valid. Truncated JSON often still parses if the truncation landed after a closing brace. And short outputs look like short inputs — there's nothing obviously wrong with a brief answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;finish_reason&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TruncatedOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finish_reason=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;finish_reason&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire fix. Anthropic-shaped responses use &lt;code&gt;stop_reason&lt;/code&gt; with &lt;code&gt;end_turn&lt;/code&gt; as the good value; same idea.&lt;/p&gt;

&lt;p&gt;One refinement worth making: if &lt;code&gt;finish_reason&lt;/code&gt; is &lt;em&gt;absent&lt;/em&gt; entirely — a proxy stripped it, a wrapper didn't forward it — treat that as a failure too. You cannot confirm the generation completed, so don't assume it did. Fail closed.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The swallowed exception
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Most common bug in this article.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The most expensive four lines in AI engineering
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;          &lt;span class="c1"&gt;# pipeline continues, reports success
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what makes this one interesting: it was written deliberately, by a competent engineer, for a good reason. You have a batch of 5,000 records and you don't want one malformed input to kill a forty-minute job. So you catch, log, and continue.&lt;/p&gt;

&lt;p&gt;The intent is sound. The effect is that failure becomes invisible. The warning goes into a log nobody reads, the job reports success, and you have holes in your data that no alert will ever mention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; continuing past a failure is fine. Continuing past a failure &lt;em&gt;while reporting success&lt;/em&gt; is not. Count them, surface them, and fail the run past a threshold you chose on purpose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;report_failures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# visible, not buried in a log
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# explicit tolerance
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;BatchFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tolerance is the key part. Accepting a 5% failure rate is a legitimate engineering decision. Accepting an &lt;em&gt;unknown&lt;/em&gt; failure rate because the number was never computed is not the same thing, even though the code looks similar.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Auth failure as empty result
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hides for months.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A token expires. The API returns 401. Somewhere in your stack a wrapper converts any non-200 response into an empty list.&lt;/p&gt;

&lt;p&gt;Your pipeline now reports "no new records." It will report that every single run, indefinitely, with total confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it hides:&lt;/strong&gt; this is the most durable failure mode I know of, because "no new data" is a completely unremarkable outcome. Quiet week? Slow season? Nobody investigates a zero. I've seen this run for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; &lt;code&gt;empty&lt;/code&gt; and &lt;code&gt;unavailable&lt;/code&gt; are different states and must never collapse into the same value. If you can't determine the answer, return something that is neither empty nor zero, and make callers handle it explicitly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong — 'no records' and 'couldn't ask' become identical
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;

&lt;span class="c1"&gt;# Right — unavailable is its own state
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Unknown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__bool__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;       &lt;span class="c1"&gt;# falsy, so guard clauses still work
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__eq__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;  &lt;span class="c1"&gt;# but never equal to [] or 0
&lt;/span&gt;
&lt;span class="n"&gt;UNKNOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Unknown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UNKNOWN&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;__bool__&lt;/code&gt; returning &lt;code&gt;False&lt;/code&gt; matters — existing &lt;code&gt;if not result:&lt;/code&gt; checks keep working. The &lt;code&gt;__eq__&lt;/code&gt; matters more: &lt;code&gt;UNKNOWN != []&lt;/code&gt; means any code that specifically tests for emptiness is forced to handle the unknown case rather than silently absorbing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Self-reported completion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The agent-specific one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where multi-step agents fail in ways single calls don't.&lt;/p&gt;

&lt;p&gt;Your agent's final message says: &lt;em&gt;"I've successfully saved all 12 records to the database."&lt;/em&gt; Your orchestrator sees a confident completion statement and marks the task done.&lt;/p&gt;

&lt;p&gt;The database has zero rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it hides:&lt;/strong&gt; the agent is the least reliable available witness to its own success, and it's also the most articulate. It will describe what it intended to do in the past tense, fluently, whether or not it happened. Related: agents narrate tool calls they never made. The transcript reads as though a search ran; the execution log is empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; the completion signal has to come from outside the agent. For each task, define what observable state proves it's done — a row exists, a file is on disk, an API read returns the new value — and check that instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong — narration as evidence
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agent_output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;mark_done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Right — independent read of the world
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;mark_done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if the verifier itself throws — the database is unreachable, the API times out — that is &lt;em&gt;not&lt;/em&gt; success. It's unconfirmed. Fail closed, every time.&lt;/p&gt;

&lt;p&gt;The same logic applies to asking a model how confident it is. That number is generated text, not a calibrated estimate, and it correlates poorly with correctness. Treat self-reported confidence as exactly zero evidence and derive confidence from checks that actually ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grounded_in_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;verify_grounded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema_valid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cross_check_agrees&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;second_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no checks, confidence is zero — not an optimistic default.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fifteen-minute audit
&lt;/h2&gt;

&lt;p&gt;Run these against your own pipeline now. Each one takes a minute or two.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Feed it malformed input&lt;/strong&gt; — truncated JSON, an empty string, the wrong content type. Does it report success?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoke a credential&lt;/strong&gt; and run it. Does it say "no new data"?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read every &lt;code&gt;except&lt;/code&gt; block.&lt;/strong&gt; Can any of them produce a success status?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read every retry decorator.&lt;/strong&gt; What does it return when it gives up? If it returns a default instead of re-raising, that's a silent failure by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for &lt;code&gt;finish_reason&lt;/code&gt;.&lt;/strong&gt; If it's absent, you have undetected truncation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;max_tokens&lt;/code&gt; to 10&lt;/strong&gt; and run. Does anything notice?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile one batch by hand.&lt;/strong&gt; Items in, versus items actually processed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask what proves "done"&lt;/strong&gt; for your main task. If the answer is "the step returned", that's self-report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break it and wait.&lt;/strong&gt; Does anyone find out without reading logs?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what feeds your dashboard.&lt;/strong&gt; Self-report, or an independent read?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Any "yes, it reported success" is a live silent failure in production right now.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two things worth internalising
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A permissive fallback on an error path converts a loud failure into a silent one.&lt;/strong&gt; This is the single most common way well-intentioned defensive code makes things worse. &lt;code&gt;except: data = best_effort_parse(raw)&lt;/code&gt; feels robust and is strictly worse than crashing, because now you have wrong data and no signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert on the absence of success, not just on errors.&lt;/strong&gt; A cron job that stops running produces no errors — it produces nothing, which is indistinguishable from a system at rest. Similarly, "alert if error rate &amp;gt; 5%" never fires when the pipeline processes zero items: zero errors out of zero attempts is a 0% error rate. Watch throughput too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This covers 5 failure modes. I catalogued 19 across output, control flow, agents, and observability — each with a runnable test, plus a dependency-free &lt;code&gt;guards.py&lt;/code&gt; and 75 tests demonstrating every mode failing silently and then being caught. It's $99: &lt;a href="https://vexithon.gumroad.com/l/fbmkxq" rel="noopener noreferrer"&gt;The Silent Failure Atlas&lt;/a&gt;. The five above are the ones I'd fix first regardless of whether you buy anything.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
