Agent retries and idempotency — a timeout is not proof of failure
A timeout is not proof of failure. What idempotency keys and checkpoints do and do not guarantee when an agent retries a call that changes external state.
On this page
The external service finished the operation, but the agent never received the response. If the agent now sends a new request, the same operation can run a second time. Stripe's own documentation tells integrators to treat the result of an HTTP 500 response as indeterminate. Designing retries starts with separating “operations that are safe to run again” from “operations whose completion must be checked first.” [1]
1. Executive summary
kept separate from success and failure
they are at least this old [2]
three layers × 3 total attempts each
Idempotency is the property that applying the same operation several times has the same intended effect as applying it once. An idempotency key is an identifier attached to a request so that the server can recognize a resend of the same operation. Attaching a key is not enough on its own. The receiving server has to process the key, and the key's retention period and scope have to fit your case. [2]
This article is a design interpretation of official documentation. The figure of 27 is not a production measurement; it is a calculation that assumes every layer uses up its attempt limit. The exact conditions are in section 6.
2. The response is not the outcome
Suppose an agent calls an order-placement tool. If the connection drops right after the server saves the order, all the agent sees is a timeout. The table below is not a record of a specific incident. It is a hypothetical scenario that illustrates a lost response.
| Moment | External order service | What the agent knows |
|---|---|---|
| Before the request | No order yet | Has saved the order to place and its operation ID |
| Processing | Saves the order and sends a success response | Waits for the response |
| Connection drops | The order already exists | Got no response, so does not know whether the order went through |
| Recovery | Either recognizes the same operation or creates a new order | Depending on how it resends, a duplicate can result |
Stripe's documentation on network errors addresses this exact situation. It explains that the client may not know whether the server received the request, and that it should retry with the same idempotency key and the same parameters to get a definite result. For HTTP 500, it also advises against retrying with a new key, because external side effects may already have happened. [1]
The design proposal we draw from this is to split the outcome into separate states. Success means completion has been confirmed at the external service. Confirmed failure means you have evidence that the request was rejected and the intended external operation did not run. Unknown means there is not enough evidence to decide between success and failure. Requests that are still in flight are tracked separately, as an execution state.
Agents that “retry with a different tool when an error occurs” have the same problem. If the first service's completion is still unknown and you run the same task on another service, the first service's idempotency key does not protect you at the second service. Before switching to a fallback path, you also need to confirm the outcome of the original operation.
3. Identifying the same operation
The number of tool calls is not the number of operations you actually want done. In this article, a piece of work the user wants completed, such as placing an order, is called a business operation. If a user asked for one order and connection errors caused it to be sent three times, there is one business operation and three send attempts. If you do not keep the operation ID separate from the attempt ID, it becomes hard to tell whether a request is a retry of the same operation or a new one.
| Identifier | Lifetime | Our design proposal |
|---|---|---|
operation_id | One entire business operation | Saved before execution. Kept across restarts, resends and operator recovery |
provider_idempotency_key | All requests for the same operation sent to the external service | Store the key actually sent, together with the service, account, environment and endpoint |
attempt_id | One send attempt | Generated fresh for every attempt. Keeps logs, latency and errors apart per attempt |
provider_object_id | The object created at the external service | Saved as soon as it is confirmed. Used for later lookups and reconciliation |
These field names are internal application names proposed for illustration. They are not standard API field names provided by Stripe or LangGraph. We do not assume that the tool call ID in a model response is the same as the business operation ID.
Storing an internal operation ID does not by itself mean you sent the same key to the external service. Whether you derive the external key from the internal ID or issue it separately, save the value you will actually send before the first send, and reuse it unchanged on every retry. If one business operation consists of several different external operations, keep a separate key for each of those external operations.
Do not let the model regenerate the operation ID
It is better for the application to create the business operation when it accepts the user's request, and to save its ID together with the normalized input before any external call. Any path that accepts the same request again must also link to the existing operation. Generating a new UUID right before the call avoids string collisions, but each restart may then be treated as a different operation.
For example, the client can create a client_request_token once on the order confirmation screen and reuse it even after a lost response. The server puts a unique constraint on the combination of customer account, operation type and this token, and maps the combination to a single operation ID. Set the token's retention period with the time window for user resends and operator recovery in mind. Use a new token for a new order intent. This is an example implementation. Issuing an independent operation ID for each request as it arrives does not, on its own, merge concurrent duplicate requests.
Be careful, too, about using a hash of the input in place of an operation ID. A hash is a value computed from the input data for comparison. The same customer can deliberately order the same product again, so identical input does not always mean the same business operation. Decide whether it is the same operation by the operation ID, and check whether the input changed against the stored input or its hash. Include the customer account and operation type in the identity scope as well.
Requests that arrive at the same time must also map to one operation
If you run “look up the existing record → create it if missing” as two separate steps, two concurrent requests can both conclude that no record exists. Register operations with a database unique constraint that blocks duplicate rows for the same ID, or with a single atomic operation that creates the record only when none exists. Assign the executor in that same step, while checking the operation's current state. But when an executor stops and the work is handed to another executor, whether the first external call completed may still be unknown. Design the local lock and the external API's duplicate protection together.
4. What Stripe's guarantee covers
Stripe's idempotency documentation is more specific than “the same key means it's fine.” The table below reflects Stripe's official documentation as checked on 2026-09-10. These rules do not carry over unchanged to other APIs. [2]
| Condition | What the docs guarantee or limit | What it means for your design |
|---|---|---|
| Requests that accept a key | All POST requests accept idempotency keys | Check whether the external service and the specific endpoint support keys |
| Requests whose execution has started | The status code and body of the first result are saved — 500s included, not only successes | A resend with the same key does not necessarily turn into a success |
| Same key, different input | Compared with the original parameters; an error if they differ | Use the stored values so that the model cannot change the input during retries |
| Key retention | Keys at least 24 hours old can be deleted. Reusing a key after deletion creates a new request | For long-pending operations, do not resend just because you still have the same key string |
| Validation failure or conflict with a concurrent request | Endpoint execution never started, so no idempotent result is saved | Distinguish a replay of a saved result from a rejection that happened before execution |
Do not read the 24 hours as permanent, business-level duplicate protection. Nor does it mean “keys are always deleted at exactly 24 hours.” If you cannot confirm whether a key older than that is still retained, it is safer not to resend on the assumption that the guarantee still holds. If an operator might reprocess an operation days later, use your local business records and lookups of the external object together. A resend after the key has been deleted may not return the earlier result. [2]
Stripe's advanced error handling documentation treats HTTP 500 results as indeterminate. It also describes putting a local identifier in the metadata so that objects created later can be matched up through webhooks. Reconciliation is this procedure: checking local operation records against the actual results at the external service. [1]
So whoever handles recovery has to ask not only “should I resend with the same key?” but also “does an object already exist?”, “are we still inside the key's protection window?” and “was the response replayed while the external state changed later?” Even if a lookup returns nothing, when that API's lookups lag behind its writes you cannot immediately treat the empty result as evidence that nothing ran.
5. Execution left outside the checkpoint
A checkpoint is workflow state saved so that execution can resume later. LangGraph saves state at step boundaries (it calls these steps “super-steps”) and says that even if another node in the same step fails, the saved results of the nodes that succeeded can be used for recovery. A node here is a unit of execution inside the workflow. [3]
But there is a gap between success at the external service and the local save of that result. If a node places an order and stops before saving the result, the saved graph state alone does not let you conclude that no order exists. This is a design inference that combines the checkpoint description with the lost-response scenario in section 2. It is not a duplicate rate measured in LangGraph itself.
| Mechanism | Problem it addresses | Boundary to check separately |
|---|---|---|
| Checkpoint | Recovery from saved execution state | External operations that finished before their result was saved |
| External API idempotency key | Keeping the effect of the same request from being duplicated | Key scope, matching input, retention period |
| Transactional outbox | Committing the DB change and the record of the event to send together | Event resends and duplicate handling at the consumer |
Even with the outgoing event saved alongside the data, consumers must handle duplicates
A transactional outbox records the business data change and the event to be sent in the same database transaction, after which a separate relay sends the event. AWS describes it as the answer to the dual-write problem, where only one of the database write and the message send succeeds. It also states explicitly that events can be sent more than once, so consumers should track the messages they have processed and handle them idempotently. [5]
Applied to an agent's tool execution, this lets you review two things separately: “not losing work that should run” and “not duplicating external side effects.” Adding a job table or a queue does not by itself guarantee that an external side effect happens only once. Check the guarantees about message delivery and the guarantees about state changes at the external service, each on its own.
6. Retry budgets and recovery procedures
Start by agreeing on what a retry count means. LangGraph's RetryPolicy.max_attempts is the total number of attempts, including the first, and its documented default is 3. That is the parameter's default when you use a retry policy; it does not mean retries are applied to every agent automatically. [4]
The following is our own worked example, not the default combination of any particular product. It assumes that tool retries by the agent, node retries in the workflow and resends by the HTTP client are stacked on top of each other.
| Assumed layer | Total attempts per call from the layer above | Upper bound on calls accumulated down to the bottom layer |
|---|---|---|
| Agent tool retries | 3 attempts | 3 calls |
| Workflow node retries | 3 attempts | 3 × 3 = 9 calls |
| HTTP client resends | 3 attempts | 3 × 3 × 3 = 27 calls |
This is the upper bound when every layer treats the error as retryable and uses up its limit each time, with no success, cancellation or expiry of an overall deadline. If your “3 retries” setting means 3 additional attempts after the first call, that is 4 total attempts per layer, so putting 3 into the same calculation would be wrong.
Backoff means waiting longer before each next attempt as failures repeat, and jitter means adding random variation to the wait. LangGraph provides both settings, but tuning the wait and preventing duplicate execution are separate problems. [4]
Decide who owns retries and when everything stops
Our recommendation is to manage the total attempt count and an overall deadline per business operation in the application, and to include the automatic retries of lower-level SDKs in that budget. Once the allowed attempts are used up, check the operation's state so that the model cannot register the same business operation again as a new one. Give reads and requests that change external state separate retry policies, each suited to its nature.
| Confirmed state | Next step | What to avoid |
|---|---|---|
| External success confirmed | Save the result and external object ID, and return the existing result | Creating a new business operation because the local response was lost |
| Rejection, with non-execution confirmed | Fix the cause; if needed, approve and register a new operation | Resending the same bad input indefinitely |
| Network error with no response received, duplicate-protection conditions met | A limited resend with the same key and input, or a result lookup | Changing the key, or switching straight to another service |
| An HTTP 500 result saved by Stripe | Confirm the actual outcome through webhooks, object lookups and reconciliation against external records | Reading a replay of the same 500 as settling success or failure, or running the operation with a new key |
| Outcome unknown, with key retention unclear, protection expired or keys unsupported | Hand off to reconciliation against external records or to a person's review | Running it again without checking and closing it as a success |
If the external service does not guarantee idempotency and offers no lookup that can reliably confirm completion, it is hard for automatic recovery alone to both prevent duplicates and guarantee that the operation completes. In that case, a clear stopping procedure is to record the original request, the last attempt and whatever could not be confirmed, so that a person can decide.
7. Checklist to apply today
- Pick one tool that changes external state. Start with operations whose effects add up when repeated, such as placing an order, publishing a post or creating an account.
- Save the business operation ID before execution. Check that restarts and user resends link to the same operation.
- Freeze the execution inputs. Make sure the model does not regenerate the target, amount or content during retries.
- Write down the external API's contract. Record whether keys are supported, their scope, the retention period, and how concurrent calls and error responses are handled.
- Create an “unknown” outcome state. Decide where an operation goes once the retry budget is exhausted: a lookup, reconciliation or confirmation by the person responsible.
- Count the total calls. Add up the attempts of the model, the workflow and the SDK, and set a limit per operation.
Instead of the happy path, break it at these four points
Run this in a test environment, not on real customer operations, and observe the external results independently. The tests below are what this article proposes. They are not measurements we ran for this report.
| Fault or condition to inject | Pass criterion |
|---|---|
| Response lost after external success | Recovers with the same key; no additional external object is created |
| Crash after external success, before the local result is saved | After restart, looks up and recovers the same business operation without creating a duplicate |
| Two requests for the same business operation arrive at once | Both map to one business operation, and only one external side effect occurs |
| An unknown operation whose external key can no longer be assumed to be retained | Records whether retention is unclear or protection has expired, and moves to the reconciliation path instead of resending automatically |
Looking only at the retry success rate can hide duplicate operations. It is better to also record the number of external objects relative to the number of business operations, how long unknown operations wait, and the actual number of sends per operation. Log collection itself is covered in the agent observability conventions report.
8. What this is not yet
- We did not measure production duplicate rates. This article is a design guide that interprets official product documentation and distributed-systems patterns. It gives no figures for cost savings or failure reduction from the proposed structure.
- Stripe's 24-hour condition is not a rule for every API. Check other services' key retention periods and scope separately, in the documentation for the endpoint in question.
- Online documentation alone cannot verify how the LangGraph version you have installed behaves. We checked the online docs on 2026-09-10; compare the retry settings against your installed version. How checkpoints are stored and your persistence settings also affect what can be recovered.
- The figure of 27 assumes every layer uses up its attempt limit. Real call counts vary with error classification, deadlines, cancellation, success and SDK policy.
- Idempotency does not prevent a wrong first execution. Running once against the wrong target is a problem for permissions, input validation and approval steps. Security controls are covered separately in the coding agent security report.
- Idempotency keys and a job table alone cannot guarantee that every external side effect happens only once. There will still be cases where automatic recovery has to stop, depending on whether the external service supports keys, whether its lookups are conclusive, late responses and retention policies.
9. Sources
All are primary sources checked on 2026-09-10. Product documentation describes the vendor's own behavior; it is not independent experimentation. Operation states, field names and test setups that do not appear in these sources are marked in the body as our design proposals.
| Source | What we checked | Scope of the evidence |
|---|---|---|
| [1] Stripe · Advanced error handling | Retrying network errors, the indeterminate result of a 500, reconciliation through webhooks and metadata | Stripe API's error-handling contract |
| [2] Stripe · Idempotent requests | Saving the first result, comparing inputs, keys deletable once at least 24 hours old, rejection before execution | Stripe's description of its idempotency implementation |
| [3] LangGraph · Checkpointers | Checkpoints at step boundaries, saving and recovering the results of successful nodes | Framework state persistence. Not a transactional guarantee that covers external APIs |
| [4] LangGraph · Fault tolerance | RetryPolicy's total attempt count, backoff and jitter parameters | Policy definitions in the current docs. Not measured in an installed environment |
| [5] AWS · Transactional outbox pattern | Recording business data and events together, duplicate events and consumer idempotency | A distributed-systems design pattern. Not an agent-specific performance study |
Editorial standard: we did not treat a timeout as proof that the remote operation failed, and we kept the guarantees the products document separate from the recovery procedures the application has to add.