July 29, 2026

AI log, day 18: retrying a tool call retries a side effect, not just an HTTP request

AI log series · part 18 of 18

Listen to the summary
0:00 / 0:00
AI log, day 18: retrying a tool call, cover graphic for erkshitiz.com.np

Day 17 guessed the next entry would be about how much a model should be trusted to choose between multiple similar tools. That guess did not hold up. What actually landed on my desk this week was smaller and further upstream: a tool call that simply timed out, and an agent that handled the timeout exactly the way it handles every other transient failure, by trying again.

The failure that looked completely routine

The tool was a “send confirmation email” step at the end of a support workflow, called by an agent that had just resolved a ticket. The call to the email provider’s API took slightly too long, the client-side timeout fired, and the agent’s loop did what a retry-on-failure policy is supposed to do: it treated the timeout as a failure, and called the tool again with the same arguments. The second call succeeded. Two confirmation emails went out for one resolved ticket. Nothing crashed, nothing logged an error, and the only reason anyone noticed was a customer replying to ask why they got the same message twice.

A timeout does not mean the thing didn’t happen

The instinct to retry on failure is not wrong, it is the same instinct day 12’s point about treating a model-behind-an-API call like an ordinary flaky HTTP request would predict. Most of the time a network call fails cleanly: connection refused, 500, something that tells you unambiguously the other side never did the work. A timeout is a different, uglier kind of failure, because it tells you nothing. The request might have never reached the email provider. It might have reached it, been processed completely, and the response just never made it back before the client gave up waiting. From the caller’s side those two outcomes are indistinguishable, and an agent’s tool-use loop, left to its own defaults, resolves that ambiguity by assuming the safer-looking option: it didn’t happen, so try again. For a read, that assumption costs nothing, a duplicate read just returns the same answer twice. For a tool with a side effect, that assumption is a coin flip on whether the action just happened twice.

This is not a new problem, it’s an old one wearing an agent’s clothes

I already have an answer for this, it’s just been sitting in an earlier post about writing idempotent API endpoints, because it’s the exact same shape of bug. A client’s HTTP request times out on a flaky connection, the client retries, and if the endpoint isn’t idempotent, the retry does the work a second time. POST /send-confirmation-email and send_confirmation_email as a tool definition are the same operation with the same failure mode, the only thing that changed is who’s holding the client role. In the API case it was a mobile app I didn’t fully control. In the tool-call case it’s the model’s own loop, which I do control, deciding on its own that a retry is safe without anything telling it whether the tool underneath is the kind where that’s true.

The backoff-and-jitter post is the other half of this same instinct showing up in a different shape: that post was about retry timing making an outage worse, this one is about retry semantics making a side effect happen twice, but both come from treating “call failed” as a single category instead of asking what a second attempt actually does to the world.

What actually needed to change

Not the retry logic, exactly, the tool boundary. The fix that stuck was the same idempotency key pattern from the API post, threaded through the tool call instead of an HTTP header: the agent (or the harness wrapping it) generates a key once per logical action and passes it as an argument every time that specific action is attempted, retries included. The email-sending tool stores which keys it has already handled, the way the original endpoint did, so a retried call with the same key returns “already sent” instead of sending again. That single change didn’t require making the model smarter about ambiguous timeouts, which isn’t really a solvable problem at the model layer, it required making the tool itself safe to call twice, the same way any backend engineer already makes a payment endpoint safe to call twice.

The other half of the fix was sorting tools into two categories before this happens again, not after: tools that are naturally safe to retry blindly because they only read (a lookup, a search, a status check), and tools with a side effect that need a dedup key by default, not as an opt-in. A schema-level convention, something as plain as requiring an idempotency_key argument on any tool tagged as a write, catches this at definition time instead of at the first duplicate-email complaint.

The part that’s easy to miss

None of this is really about the model being wrong. The model did what a retry policy is supposed to do, faced with a genuinely ambiguous failure. The mistake was upstream of it, treating “the model decided to retry” as automatically safer than a client SDK deciding the same thing, when it’s the identical decision with the identical risk. An agent’s tool call inherits every property of a normal API call it wraps, including the ones that only show up when something times out instead of failing cleanly.

What’s next

No fixed topic for day 19 yet. This one came from a tool having a side effect; the more interesting adjacent question, one I don’t have an answer to yet, is what changes when a single user turn causes an agent to make several tool calls that depend on each other, and only some of them actually completed before something failed partway through.

Regular posts continue in between, as always.