<?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: Arpan Ghoshal  </title>
    <description>The latest articles on DEV Community by Arpan Ghoshal   (@arpanghoshal).</description>
    <link>https://dev.to/arpanghoshal</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%2F4108707%2Fc0d17a78-7070-40a2-b2a9-d496b32fc36d.jpg</url>
      <title>DEV Community: Arpan Ghoshal  </title>
      <link>https://dev.to/arpanghoshal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arpanghoshal"/>
    <language>en</language>
    <item>
      <title>FAILED is not UNKNOWN: the retry bug hiding in every AI agent</title>
      <dc:creator>Arpan Ghoshal  </dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:14:58 +0000</pubDate>
      <link>https://dev.to/arpanghoshal/failed-is-not-unknown-the-retry-bug-hiding-in-every-ai-agent-5721</link>
      <guid>https://dev.to/arpanghoshal/failed-is-not-unknown-the-retry-bug-hiding-in-every-ai-agent-5721</guid>
      <description>&lt;p&gt;An agent refunds a customer $500. Stripe processes it. The response never comes back — a proxy timeout, a dropped connection, a container that got OOM-killed mid-call. Your code sees an exception. Your retry decorator does what retry decorators do.&lt;/p&gt;

&lt;p&gt;Now the customer has $1,000.&lt;/p&gt;

&lt;p&gt;Nothing in that sequence is an LLM problem. The model reasoned correctly, picked the right tool, and passed the right arguments. The bug is in the four lines of infrastructure everybody writes without thinking:&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;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment_intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;amount&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="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That code encodes an assumption that is simply false: &lt;strong&gt;that an error means it didn't happen.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two states is one state too few
&lt;/h2&gt;

&lt;p&gt;Almost every retry system in the wild models outcomes as a boolean. Success, or failure. Returned, or raised.&lt;/p&gt;

&lt;p&gt;A call that leaves your process has three possible outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;What you know&lt;/th&gt;
&lt;th&gt;Safe to retry?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COMMITTED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The remote system acted, and you have proof&lt;/td&gt;
&lt;td&gt;No — it's done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAILED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The remote system did not act, and you have proof&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AMBIGUOUS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You have no idea&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;AMBIGUOUS&lt;/code&gt; is not a rare edge case. It is the normal result of a timeout, a connection reset, a 502 from a load balancer, a gateway that gave up before the origin did, or your own process dying between the request and the response. In distributed systems this has a name — the two generals problem — and it has no clean solution. What it has is a discipline: never collapse "unknown" into "failed."&lt;/p&gt;

&lt;p&gt;Databases have understood this for forty years. That's what two-phase commit is about. Payment providers have understood it for twenty; that's what an idempotency key is. Agent frameworks are ten months into shipping software that takes consequential action, and most of them still have a &lt;code&gt;max_retries&lt;/code&gt; parameter and no concept of an unknown outcome at all.&lt;/p&gt;

&lt;p&gt;The difference now is who's driving. A cron job retries in one predictable shape. An LLM retries because it read an error string, decided the action didn't go through, and reasoned its way to trying again — sometimes with slightly different arguments, sometimes three turns later, sometimes from a different worker. It will do this confidently, and it will tell you it succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ordering is the whole trick
&lt;/h2&gt;

&lt;p&gt;The instinct is to write a ledger:&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;do_refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&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;mark_done&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;refund:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payment_id&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="c1"&gt;# too late
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does nothing. The window you care about is exactly the window where you have no row: the call is in flight, the process dies, and the retry arrives to find an empty table.&lt;/p&gt;

&lt;p&gt;You have to claim the effect &lt;em&gt;before&lt;/em&gt; the call:&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;key&lt;/span&gt; &lt;span class="o"&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;refund:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# atomic insert, unique constraint
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;DuplicateEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# someone already claimed this
&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;do_refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&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;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_ambiguous&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# held, NOT released
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ProviderRejected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# provably didn't happen — safe to release
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the &lt;code&gt;except&lt;/code&gt; blocks twice. The entire safety property lives there. A timeout does not release the reservation. That reservation stays held until something outside the agent settles it: a reconciliation call to the provider, or a human. &lt;code&gt;AMBIGUOUS&lt;/code&gt; is a state you &lt;em&gt;live in&lt;/em&gt;, not a state you clear by guessing.&lt;/p&gt;

&lt;p&gt;And the key itself matters. &lt;code&gt;refund:txn_4821&lt;/code&gt; is the identity of a business action. It has to be the same string across a retry, a second worker, a restart, and a fresh conversation with the model. If your key includes a timestamp, a UUID, or a trace ID, you don't have deduplication — you have a log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: an approval is bound to arguments
&lt;/h2&gt;

&lt;p&gt;The same class of bug shows up in human-in-the-loop flows, and it's uglier because it looks like it's working.&lt;/p&gt;

&lt;p&gt;A person approves a $500 refund. The agent gets &lt;code&gt;approved: true&lt;/code&gt; back. Two turns later the agent re-plans, decides the amount should be $5,000, and calls the tool. It still holds an approval. The approval is a boolean, and booleans don't remember what they were about.&lt;/p&gt;

&lt;p&gt;An approval should be bound to the exact arguments the human read:&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;approval_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canonical_json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&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;stripe.refund&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;payment_id&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;txn_4821&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500_00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change one field and the hash no longer matches, so the approval authorises nothing and the call stops. Same principle for single use: an approval that can be replayed is a permission, and you didn't mean to grant a permission.&lt;/p&gt;

&lt;p&gt;While we're here — a tool being present in the agent's tool list is not permission either. "The model can call it" and "this principal may perform it, with these arguments, right now" are different questions, and only one of them is answered by your prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not a guardrails problem
&lt;/h2&gt;

&lt;p&gt;Most of the safety tooling in this space watches what the agent &lt;em&gt;says&lt;/em&gt;: prompt injection filters, output classifiers, jailbreak detection, PII scrubbing. All useful, all aimed at the model.&lt;/p&gt;

&lt;p&gt;None of it helps here. The model was fine. The failure happened in the gap between "the agent decided" and "the real system changed" — one function call wide, no natural language in it at all. That gap needs a different kind of check, one that never sees a prompt and only sees an action with its exact arguments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this principal entitled to act at all?&lt;/li&gt;
&lt;li&gt;For these exact arguments: allow, require approval, or deny?&lt;/li&gt;
&lt;li&gt;If a human approved something, was it &lt;em&gt;this&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;Could this effect already have happened?&lt;/li&gt;
&lt;li&gt;Did the real system act, and do we know for certain?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five questions, asked in the last moment before the effect is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  CTRLRun
&lt;/h2&gt;

&lt;p&gt;I got tired of writing the reservation table by hand on every project, so I built the thing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ctrlrun.dev" rel="noopener noreferrer"&gt;CTRLRun&lt;/a&gt; is an open-source Python library (Apache-2.0) that sits at that execution boundary. It's a library inside your process, not a service in front of it. It never sees your prompts, your model, or your reasoning traces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;ctrlrun
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ctrlrun&lt;/span&gt;

&lt;span class="nd"&gt;@ctrlrun.protect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stripe.refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refund:{payment_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get around that call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;allow&lt;/code&gt; → the effect is reserved, then your function runs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;approve&lt;/code&gt; → &lt;code&gt;ApprovalRequired&lt;/code&gt; is raised, bound to these exact arguments&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deny&lt;/code&gt; → &lt;code&gt;ActionDenied&lt;/code&gt;, and no approval request is even created&lt;/li&gt;
&lt;li&gt;a second attempt at a committed effect → &lt;code&gt;DuplicateEffect&lt;/code&gt;, with the original receipt returned&lt;/li&gt;
&lt;li&gt;a lost response → the effect is held &lt;code&gt;AMBIGUOUS&lt;/code&gt;, and the blind retry gets &lt;code&gt;AmbiguousEffect&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;everything that happened, and the decision behind it, in a receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SQLite on one host, Postgres across hosts. State survives a restart, which is the point — a process that died mid-call still leaves a claim behind for the retry to hit. There's an MCP gateway if your agent talks over MCP, and a &lt;code&gt;ctrlrun verify&lt;/code&gt; command that checks a set of guarantees in CI.&lt;/p&gt;

&lt;p&gt;There's a browser demo at &lt;a href="https://ctrlrun.dev" rel="noopener noreferrer"&gt;ctrlrun.dev&lt;/a&gt; that walks through each of these failures across a bunch of domains — no signup, and the "try it" page runs the actual released wheel in your tab via Pyodide, so the refusals you see are the library's own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take the idea even if you skip the library
&lt;/h2&gt;

&lt;p&gt;If you only keep one thing from this: go find the retry logic wrapped around whatever your agents do to production, and check what it does with a timeout. If it retries, you have this bug. It hasn't cost you anything yet because your volume is low and most timeouts really are failures.&lt;/p&gt;

&lt;p&gt;Most of them.&lt;/p&gt;




&lt;p&gt;Source: &lt;a href="https://github.com/CTRLRun/ctrlrun" rel="noopener noreferrer"&gt;github.com/CTRLRun/ctrlrun&lt;/a&gt;. Issues and disagreement both welcome — particularly if you've hit a failure mode I haven't modelled.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
