Your agent asks for a $120 refund. The request goes out. Thirty seconds later the connection times out and nothing has come back.
Did the refund happen?
You do not know. That is the whole answer, and most agent tooling gets it wrong by pretending otherwise. This post is about what "I don't know" should look like in a system that performs side effects for an agent, and why the safe default is the opposite of what most retry libraries do.
Failed and unknown are different outcomes
When a call to an external API does not return a clean response, there are two very different situations hiding under the word "error":
- It failed. The request never left, or the destination received it and refused it: bad input, a DNS failure, a connection that never opened, a
4xxreply. The destination did not act on it. Fix the cause before you send it again. - It is unknown. The request was written to the socket and then the connection died, or the destination took longer than your timeout, or it answered with a
5xx. The destination may have processed it. Retrying may do it twice.
A generic retry wrapper cannot tell these apart, so it treats them the same, and it treats them as "try again". For a webhook, that is fine: the receiver is expected to be idempotent about duplicates. For a refund, an email to a customer or a DELETE, it is how one side effect becomes two.
Agents make this worse in two ways. They are more likely to hit slow, flaky third-party APIs than a typical backend. And the model itself is a retry loop: told that a tool call errored, it will very often just call it again.
What Anlyon does
With Anlyon, your agent does not make the production call itself. It invokes an action by name, and Anlyon makes the request. That puts Anlyon in the position to know which of the two cases happened, and it records them separately:
| Status | Meaning | What to do |
|---|---|---|
succeeded | Sent, and the destination replied with a 2xx. | Read the reply. |
failed | The destination refused it, or it never left. A received 4xx is failed. A 402 from Stripe is a declined card, and responseStatus carries the code. | Fix the cause before you send it again. |
unknown | The request may have reached the destination and the outcome cannot be confirmed. A timeout, a dropped connection and a received 5xx all land here. | Reconcile with the destination first. Do not retry blind. |
Added 3 October 2026. This table was corrected. Since 24 September 2026 a received 4xx is recorded as failed and a received 5xx as unknown. An earlier version of this post called a 402 a successful invocation and said failed always meant nothing was sent.
And then, deliberately, Anlyon does not retry an action invocation. An unknown invocation stops there, with a message that says so: the destination may have received this request, Anlyon cannot confirm the outcome, reconcile before retrying.
Retrying would be easy. It would also be how one refund becomes two.
Reading the outcome
const { data } = await anlyon.actions.invocation(invocationId);
switch (data!.status) {
case 'succeeded':
console.log('Stripe replied', data!.responseStatus);
break;
case 'failed':
// Stripe refused it, or it never left. Check responseStatus and fix the cause.
break;
case 'unknown':
// May have happened. Look at Stripe before doing anything else.
break;
}
In an agent, this is the information worth returning to the model. "Refund failed, Stripe refused it" and "refund outcome unknown, do not retry, a human will check" lead to very different next steps, and a model told only "error" will pick the wrong one.
Making a retry safe: idempotency keys
When you do want to retry, retry with the same idempotency key. Send an Idempotency-Key with the invocation and Anlyon claims it atomically before anything runs, so concurrent retries perform the side effect at most once.
const { data } = await anlyon.actions.invoke(
'refund-order',
{ charge: 'ch_3P9x', amount: 12000 },
{ idempotencyKey: `refund:${orderId}` },
);
The rules, exactly:
| Situation | What happens |
|---|---|
| Same key, same request | The current invocation state returns with X-Idempotent-Replay: current-state. Nothing is sent again. |
| Same key, different request | 409 idempotency_key_reuse. |
| Duplicate arrives while the first is in flight | It waits, then replays. If the first does not finish in time, 409 idempotency_request_in_progress. |
| The invocation failed or is unknown | Its key remains attached. A new execution requires the documented resolution and explicit retry flow. |
| After 24 hours | Action keys remain on the invocation while it exists, even after the short-term cache expires. |
Two things to get right:
- Derive the key from the operation, not the attempt.
refund:ord_42is the point.refund:${Date.now()}defeats it. - Pass the key through to the destination when it supports one. Provider idempotency is an additional defense with its own retention rules. It does not replace Anlyon's reconciliation flow. For a template invocation, resolve an unknown outcome with evidence before considering a new execution using a new key and
retryOf. Adapter-backed actions use effect recovery.
Where retries do belong
None of this means Anlyon does not retry. It retries a great deal, in the places where retrying is correct:
- Messages and queues: durable delivery with exponential backoff, and a dead-letter queue for what exhausts its retries.
- Workflows: a failed step retries, and completed steps never re-run.
- Schedules: the same delivery guarantees underneath.
The line is between delivery, where the receiver is built to tolerate duplicates, and action execution, where a duplicate is a second refund.
Checklist for agent side effects
- Separate "did not happen" from "may have happened" in every tool your agent calls. If your HTTP client cannot tell you which, assume the second.
- Never let a generic retry wrapper sit in front of a call that moves money or messages a person.
- Put an idempotency key on every side effect, derived from the operation.
- Tell the model which case it is in, in words it cannot misread.
- Give a human a way to reconcile
unknownoutcomes, and make that the only way forward.
Free during Early Beta Access, no credit card.
Start building → · Outcomes and receipts in the docs → · Idempotency and retries →
