Agent reliability · 7 min read
Your AI Agent Timed Out. Did It Still Send the Refund?
A practical design for agent retries, durable state, and duplicate prevention, using a customer refund workflow.

The refund that disappeared from the screen
Picture a support lead at the end of a busy afternoon. There is one last ticket before she closes her laptop. A customer returned a pair of headphones, the warehouse confirmed receipt, and the refund is overdue. She asks the new AI assistant to sort it out.
The assistant finds the order. It checks the return. It presents the amount for approval. She approves, watches the spinner, and then sees: “Something went wrong. Please try again.”
She clicks again. A moment later, the customer receives two refund notifications.
This is a fictional incident walkthrough, built around a real class of distributed-systems failure. It is not a story about a particular employer or customer. I use it because the mistake is easy to recognize: the interface turned uncertainty into a retry button, and the system treated the retry as a new business action.
The interesting part starts when the engineer opens the logs. There is no spectacular model failure. No jailbreak. No bizarre reasoning. The assistant selected the right order and the right amount. The first HTTP request simply ended before the client received a response.
The provider's ledger says the refund succeeded. The application's ledger says the attempt timed out. Both records accurately describe what their respective systems observed. The bug lives in the gap between them.
At that point, changing the prompt to “be careful not to refund twice” would be an appealing distraction. The model cannot recover a fact that the application never gave it. The workflow needs a way to represent an action whose outcome is not yet known.
Separate the decision from the action
Let the model help interpret the request and prepare a proposed action. Let application code enforce eligibility, limits, and authorization. A convincing explanation from a model should not become permission to issue a refund.
Before execution, create an operation record containing the customer, order, amount, currency, approval reference, and a stable operation ID. Persist it before making the external request. The operation ID belongs to the business action, not to one network attempt.
If a customer deliberately requests a second, different refund, that is a new operation. If the network retries the first request, it is the same operation. Mixing those cases is how a helpful retry policy becomes a billing incident.
Treat a timeout as an unknown outcome
I would use states such as proposed, approved, submitting, confirmed, and outcome_unknown. The last one matters. It prevents the interface from confidently saying “failed” when the system has lost visibility.
After a timeout, query the provider using its request or transaction reference when supported. Reconcile webhooks against the same operation record. If you retry, use the same idempotency key and equivalent parameters within the provider's documented retention window.
Stripe documents idempotent requests as a way to retry supported operations without repeating the side effect. The exact retention and failure behavior belong to the provider contract; don't assume every API behaves like Stripe.
The first fix that still leaves a hole
In our walkthrough, the first patch adds a boolean called refund_sent. Before issuing a refund, the worker checks that flag. After success, it sets the flag to true. The happy-path test passes, and the patch looks wonderfully small.
Then someone asks a difficult question: what happens if the process dies between those two steps?
If the flag is written after the external request, a crash can leave it false even though the money moved. If it is written before the request, a crash can leave it true even though nothing happened. Moving the line of code changes the failure; it does not remove it.
This is the moment the design stops being about the assistant and starts being about the operation. We create an operation record before execution, give it a stable identity, and carry that identity through retries and reconciliation. The customer-facing interaction is just one way to inspect that record.
Consider operation refund-order-482-attempted-return-17. The exact naming scheme is unimportant; its scope is not. It must identify this intended refund, not every refund the customer might ever receive. The record also captures the approved amount and currency so a retry cannot quietly mutate the original proposal.
Now the second click does not create another refund. It asks about the existing operation. The screen can say, “The request was submitted, but confirmation is delayed. We are checking it.” That is less dramatic than an immediate success banner and much more accurate than a failure banner.
An operator can also inspect the same record. Support no longer has to compare a chat transcript, an application log, and a payment dashboard by hand just to answer a simple question.
A checkpoint alone is not enough
Suppose your worker crashes after the provider accepts a request but before your database records success. Saving more progress messages does not close that gap.
You need cooperation from the destination, a reconciliation path, or a deliberate manual queue for uncertain outcomes. A database transaction in your application cannot atomically include an unrelated third-party API.
Concurrent workers are another trap. Both can read “approved” before either records “submitting.” Use a transactional claim or equivalent coordination, and still keep destination-side duplicate protection. Locks expire; processes pause; networks partition.
Test the uncomfortable timing
Use a fake refund provider in a local test. Have it record the operation and then deliberately drop the connection. Restart the worker before it writes the result. Deliver the same webhook twice. Run two workers against one approved action.
For every case, assert on the provider's ledger, not just on your agent's final sentence. There should be one business refund and a recoverable local record explaining what happened.
Also test a changed amount with an existing operation ID. It should be rejected or treated as a new proposal requiring approval, never silently accepted as a retry.
What I would ship first
Start with one action, a stable operation ID, an audit trail, and an explicit uncertain-outcome queue. Measure duplicate actions, unresolved operations, and time to reconciliation.
Only then add more autonomy. An agent that pauses with an honest “I need to confirm whether that went through” is much more useful than one that apologizes politely after doing it twice.
Returning to the support lead
Replay the afternoon with that design in place. The connection still breaks; networks do not become reliable because the application is better written. But the support lead sees a pending confirmation instead of an invitation to repeat the action.
The reconciliation job finds the original transaction. It marks the operation confirmed, records the provider reference, and updates the ticket. The assistant can now report the result based on a durable fact. If reconciliation cannot establish the outcome, the case stays visible for manual investigation.
There is a trade-off here. The interface needs another state, the backend needs a reconciliation path, and the team needs to decide how long an uncertain operation may remain unresolved. That is more work than adding retries. It is also work you can reason about, test, and explain to a customer.
For a prototype that only drafts a response, this machinery would be excessive. For a system that moves money, changes bookings, or sends invitations, the same question keeps returning: did the action fail, or did we fail to observe its success?
I would put that question in the design review before discussing how autonomous the agent should be. When a user presses “try again,” they are asking the product to finish the original job. The architecture should preserve that intention.
Sources & further reading
Worked scenarios are illustrative. Technical references were checked on September 10, 2026.
Working through a similar problem?
Tell me what you are building and where it gets stuck.
Let’s talk ↗

