<?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;Say an agent issues a $500 partial refund on a $2,000 order. Stripe processes it, but the response never makes it back to you. Maybe a proxy timed out, maybe the connection dropped, maybe the container got OOM-killed mid-request. Your code sees an exception, and your retry decorator does what retry decorators do. The customer now has $1,000 back.&lt;/p&gt;

&lt;p&gt;The model didn't do anything wrong here. It picked the right tool and passed the right arguments. The bug is in the retry loop almost everyone writes without thinking about it:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assumption baked into this is that an exception means the refund didn't happen. That's usually true, which is why this code gets through review. It isn't always true.&lt;/p&gt;

&lt;h2&gt;
  
  
  A call has three outcomes, and retry code usually handles two
&lt;/h2&gt;

&lt;p&gt;Most retry logic treats the outcome as a boolean: it returned or it raised. But a call that leaves your process can end three ways:&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;Committed&lt;/td&gt;
&lt;td&gt;The other side 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;Failed&lt;/td&gt;
&lt;td&gt;The other side didn't act, and you have proof&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ambiguous&lt;/td&gt;
&lt;td&gt;You don't know&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ambiguous isn't a rare edge case. It's what you get from a timeout, a connection reset, a 502 from a load balancer, a gateway that gave up before the origin finished, or your own process dying between sending the request and reading the response. This is the two generals problem and there's no clean solution to it. What you can do is refuse to treat "I don't know" as "it failed."&lt;/p&gt;

&lt;p&gt;None of this is new. Databases dealt with it decades ago with two-phase commit, and payment APIs have idempotency keys for exactly this reason. Stripe has them, and if you pass a stable key Stripe will dedupe the refund for you. But a lot of agent tool wrappers don't pass one, or generate a fresh UUID on every call, which dedupes nothing. Stripe also only keeps keys for 24 hours, and most of the other APIs your agent touches don't support them at all.&lt;/p&gt;

&lt;p&gt;What's new is who's doing the retrying. A cron job retries in one predictable way. An LLM reads the error string, decides the action probably didn't go through, and tries again on its own. Sometimes with slightly different arguments, sometimes three turns later, sometimes from a different worker. Then it tells you the refund went through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the record before the call
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to keep 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 doesn't help. The case you care about is the one where the call is in flight and the process dies, and in that case this code never writes the row. The retry shows up, finds nothing, and refunds again.&lt;/p&gt;

&lt;p&gt;You have to claim the effect before you make 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 on a unique key
&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 refund
&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="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;# provider said no, nothing happened, safe to release
&lt;/span&gt;    &lt;span class="k"&gt;raise&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;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;# timeout, reset, anything else: keep holding it
&lt;/span&gt;    &lt;span class="k"&gt;raise&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;The except blocks are where all the safety is. Only an explicit rejection from the provider releases the key. Anything else keeps it held. If the process dies outright, neither block runs and the row just sits there as reserved, which is also what you want. It stays held until something outside the agent settles it, either a reconciliation check against the provider or a person looking at it. Guessing doesn't get to clear it.&lt;/p&gt;

&lt;p&gt;The key matters too. &lt;code&gt;refund:txn_4821&lt;/code&gt; names a business action, so it has to come out the same across a retry, a second worker, a restart, and a brand new conversation with the model. If you put a timestamp, a UUID or a trace ID in it, every attempt gets its own key, nothing ever collides, and what you've built is a log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approvals have the same bug
&lt;/h2&gt;

&lt;p&gt;Human-in-the-loop flows have their own version of this, and it's worse because it looks like it's working.&lt;/p&gt;

&lt;p&gt;Someone approves a $500 refund and the agent gets &lt;code&gt;approved: true&lt;/code&gt; back. Two turns later the agent re-plans, decides the refund should be $1,500, and calls the tool. It still has an approval. The approval was a boolean, and a boolean doesn't know what it was approving.&lt;/p&gt;

&lt;p&gt;The fix is to bind the approval to the exact arguments the person saw:&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;If any field changes, the hash doesn't match and the call stops. Approvals should also be single use. One that can be replayed is really a standing permission, and nobody meant to hand that out.&lt;/p&gt;

&lt;p&gt;The same goes for the tool list. The model being able to call a tool says nothing about whether this user is allowed to perform that action, with these arguments, right now. Your prompt answers the first question. Nothing answers the second unless you write it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt guardrails don't cover this
&lt;/h2&gt;

&lt;p&gt;Most agent safety tooling looks at what the model says: prompt injection filters, output classifiers, jailbreak detection, PII scrubbing. That stuff is useful, but it's aimed at the model, and in every example above the model was fine.&lt;/p&gt;

&lt;p&gt;The failure happens after the agent decides and before the real system changes. That's one function call with no natural language in it anywhere. Checking it takes something that never looks at a prompt, only sees the action and its arguments, and asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this caller allowed to do this at all?&lt;/li&gt;
&lt;li&gt;For these exact arguments, should it go through, wait for approval, or be denied?&lt;/li&gt;
&lt;li&gt;If someone approved something, was it this?&lt;/li&gt;
&lt;li&gt;Could this have already happened?&lt;/li&gt;
&lt;li&gt;Did the other system actually act, and do we know that for sure?&lt;/li&gt;
&lt;/ul&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 turned it into a library.&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 boundary. It runs inside your process instead of as a service in front of it, and 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;With that decorator in place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If policy says allow, the effect is reserved and then your function runs.&lt;/li&gt;
&lt;li&gt;If it says approve, you get &lt;code&gt;ApprovalRequired&lt;/code&gt;, bound to these exact arguments.&lt;/li&gt;
&lt;li&gt;If it says deny, you get &lt;code&gt;ActionDenied&lt;/code&gt; and no approval request is created.&lt;/li&gt;
&lt;li&gt;A second attempt at an effect that already committed raises &lt;code&gt;DuplicateEffect&lt;/code&gt; and hands back the original receipt.&lt;/li&gt;
&lt;li&gt;If the response gets lost, the effect is held as ambiguous and a blind retry gets &lt;code&gt;AmbiguousEffect&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every decision and its outcome goes into a receipt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It uses SQLite on a single host and Postgres across hosts. State survives restarts, which is the whole point, since a process that died mid-call still leaves its claim behind for the retry to run into. There's also an MCP gateway if your agent talks over MCP, and a &lt;code&gt;ctrlrun verify&lt;/code&gt; command you can run in CI to check a set of guarantees.&lt;/p&gt;

&lt;p&gt;If you want to poke at it without installing anything, &lt;a href="https://ctrlrun.dev" rel="noopener noreferrer"&gt;ctrlrun.dev&lt;/a&gt; has a demo that walks through each of these failures in a few different domains. No signup. The "try it" page runs the actual released wheel in your browser through Pyodide, so the refusals you see come from the real library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even if you don't use the library
&lt;/h2&gt;

&lt;p&gt;Go look at the retry logic around whatever your agents do in production and check what it does on a timeout. If it retries, you have this bug. It probably hasn't cost you anything yet because your volume is low and most timeouts really are failures.&lt;/p&gt;




&lt;p&gt;Code is at &lt;a href="https://github.com/CTRLRun/ctrlrun" rel="noopener noreferrer"&gt;github.com/CTRLRun/ctrlrun&lt;/a&gt;. Issues and pushback are both welcome, especially 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>
