To course

The agent loop

One step of an agent loopPractice: one step of the loopThe loop in codePractice: the loop in codeStopping and retryingPractice: stopping and retryingWhat only code can decidePractice: what code decides

Reliability in production

Timeout and idempotency keyPractice: timeout and idempotency key

Agent designs

Workflow or agentPractice: workflow or agentReAct and plan-and-executePractice: ReAct and plan-and-executeOne agent or a swarmPractice: one agent or a swarm

Context and cost

Why the agent bill growsPractice: tokens and cacheContext engineeringPractice: managing the windowAgent memoryPractice: what the agent keeps

Tools and protocols

MCPPractice: MCP
←→RUSign in
Agents › Reliability in production

Timeout and idempotency key

Builds onWhat only code can decide

This block explains how one refund stays one refund across every attempt: who creates the idempotency key, where it lives, what happens when two requests carry it at once, and what to do when the outcome stays unknown.

The Northwind agent calls issue_refund for order 4417, $38.00. The payment service does not answer before the timeout. The customer writes: "try again", and the model asks for the refund again. The first refund may already have gone through, so what should your tool send?

The form on the desk

An earlier block compared the key to a request number you quote to the bank. Add one detail. Before you ring the bank, you write the number on a paper form and leave the form on the desk. Whoever rings next reads the number off that form: you on the second attempt, you again after the customer says "try again", or a colleague on tomorrow's shift.

In your system the form is a row in a database table. One row is one refund operation.

Two tool calls with different ids reach the same row, so the payment service sees one key.
Two tool calls with different ids reach the same row, so the payment service sees one key.

Here is the order of events:

  1. The model asks for the refund. This is tool call toolu_01.
  2. Your tool runs its checks, creates a row with a fresh key and commits it. No payment request has been sent yet.
  3. Your tool sends the refund with that key and times out. It retries with backoff and the same key, up to a limit, and reports the refund as unconfirmed.
  4. The customer writes "try again" and the model asks again. This is a new tool call, toolu_02, with its own id.
  5. Your tool finds the open row for order 4417 and $38.00 and sends the same key.

The row:

{
  "operation_id": "op_5521",
  "order_id": "4417",
  "amount_cents": 3800,
  "idempotency_key": "c41f7a2e-9b3d-4e16-a0c7-5d2f81e6b7aa",
  "status": "open",
  "attempts": 3
}

Where the key comes from decides what "try again" does:

The key comes from

After "try again"

The model's arguments

Whatever the model writes this time

The tool_use id

A new id, so a new key and possibly a second refund

A variable in the process

Gone after a restart, so a new key

A row committed before the first attempt

The same key

Stripe's guidance matches the last row: when retries cross SDK calls or process restarts, generate a key for the operation, persist it and supply it on every attempt.

Finding the row has a race of its own. Two tool calls can both look, both see nothing and both create a row, which gives two keys. A unique index lets the database choose one:

CREATE UNIQUE INDEX one_open_refund
  ON refund_ops (order_id, amount_cents)
  WHERE status IN ('open', 'unknown');

The insert that loses gets a conflict, and your code reads the winner's row.

A confirmed refund closes the row, and so does a definite refusal. If the customer writes "try again" after a confirmed refund, a new operation could open, so the amount check at the till has to count refunds already made.

Two requests at the same moment

One key does not stop two requests from overlapping. A retry can fire while the first attempt is still running. Picture two colleagues ringing the bank at once with the same number. The clerk tells the second one: this request is in progress, ring back.

The second request is told to wait, retries with the same key and receives the first request's result.
The second request is told to wait, retries with the same key and receives the first request's result.

On Stripe's API v1 the overlapping request gets a 409, and no result is saved for it. Stripe's guidance is to wait and retry with the same key and parameters, because a new key can start a second operation. The IETF's Idempotency-Key draft, still not an RFC, also says a server should answer 409 here.

If you build such an endpoint yourself, claim the key with one atomic insert before doing any work.

Stripe's API v1 also returns an error when the same key arrives with different parameters. A changed amount is a different operation, and Stripe says to use a new key only after establishing that the original did not succeed and is not still running.

How long the bank remembers

The bank does not keep its register forever. Once the page is gone, your number means nothing to it.

Retention at Stripe API v1. Other APIs set their own windows.
Retention at Stripe API v1. Other APIs set their own windows.

Stripe's API v1 keeps keys for at least 24 hours and can remove them after that. A key reused after removal can run a new request. Its API v2 has different replay behavior and a 30-day window. Treat the window of the API you call as a deadline for every retry, including one from a run that was paused for a day. If the outcome is still unknown when Stripe's 24 hours end, Stripe says not to retry with either the old key or a new one.

When nobody knows

The same key does not always give you an answer. On API v1 Stripe saves a 500 and returns that 500 on retries with that key for as long as it keeps the key, and it tells you to treat the outcome of a 500 as indeterminate. A retry budget can also simply run out.

Then your tool marks the row unknown and sends nothing more. Every later tool call for this refund gets this result:

{
  "status": "checking",
  "operation_id": "op_5521",
  "note": "Outcome unknown. A person is checking. Do not say the refund succeeded or failed."
}

One tool call gets a few attempts and then reports unconfirmed, so the customer can ask again. The operation has its own limit across all calls, and reaching it makes the row unknown.

The code that keeps this rule:

MAX_PER_CALL, MAX_PER_OPERATION = 3, 6

def send_refund(order, amount):
    op = refund_ops.open_or_get(order.id, amount)  # committed before any attempt
    if op.status == "unknown":
        return {"status": "checking", "operation_id": op.id}
    for attempt in range(MAX_PER_CALL):
        if op.attempts >= MAX_PER_OPERATION:
            break
        refund_ops.count_attempt(op)
        try:
            refund = payments.refund(order.id, amount, idempotency_key=op.key)
        except ServerError:  # a saved 500 only repeats itself
            refund_ops.mark_unknown(op)
            return {"status": "checking", "operation_id": op.id}
        except (Timeout, KeyInProgress):
            sleep(backoff_with_jitter(attempt))
            continue
        refund_ops.close(op, refund.id)
        return {"status": "refunded", "refund_id": refund.id}
    if op.attempts >= MAX_PER_OPERATION:
        refund_ops.mark_unknown(op)
        return {"status": "checking", "operation_id": op.id}
    return {"status": "unconfirmed", "operation_id": op.id}

A person or a reconciliation job then finds out what happened. Stripe suggests retrieving the object, inspecting the request in the Dashboard, using webhook events, and contacting support if that does not settle it. Put a local identifier in the refund's metadata when you create it, so a refund that arrives through a webhook can be matched to your row.

The customer gets the truth:

We can't confirm your refund yet. A colleague is checking it and will reply in this chat.

A new key while the outcome is unknown can pay twice.

Conclusion

Go deeper

Advanced error handling (Stripe)What to do for each error class, including 409 and an outcome that stays unknown.Designing robust and predictable APIs with idempotency (Stripe)The three points where a call can fail, and why the client can be left unsure what happened.The Idempotency-Key HTTP Header Field (IETF draft)How a server should treat retries, concurrent requests and changed payloads. Still a draft.Idempotent requests (Stripe API reference)The exact behaviour of API v1 and API v2.
‹ Previous
Suggest an improvementFound a mistake