# API Idempotency Key Test: AI Agent Tool Call Retry Without Double Charges

> Same Idempotency-Key sent 3 times, charged once. Changed params got a 409. No key, sync resend: charged twice. 20 live calls plus agent retry settings.

- Canonical page: https://everyinfra.com/en/blog/api-idempotency-key-ai-agent-retry
- Published: 2026-11-09T03:12:00+08:00 · Fact-checked: 2026-11-09T03:08:00+08:00

On November 9, 2026, from 00:17 to 00:22 Singapore time, we sent 20 live calls to the EveryInfra data API (6 more were blocked by the rate limit and cost nothing). Each call tested one retry pattern that AI agents produce. We sent the same `Idempotency-Key` three times, and the account log shows one charge of $0.000556. We sent the same key with a changed parameter, and the API returned `409 idempotency_conflict` with no run and no charge. We sent the same key while the first call was still running, and the API returned `409 idempotency_in_progress` with `Retry-After: 5`. Without a key, two identical sync calls gave two charges and two different result sets.

The default MCP read timeout in the OpenAI Agents SDK is 5 seconds. Over 30 days, at least 22.1% of the successful calls on our test account took longer than that. In a slower 10-hour sample of 314 sync calls, the share was 66.6%. So an agent that resends after a 5-second timeout without a key pays twice for one in five to two in three successful calls. The official Python SDK sends a key on every call, so Python users avoid this cost.

> **Key points**
>
> - Same Idempotency-Key sent 3 times (one with reordered fields): three HTTP 200 responses, one charge of $0.000556, replays in 0.12–0.14 s.
> - Same key with changed params: 409 idempotency_conflict. Same key during the first run: 409 idempotency_in_progress, Retry-After 5. No charge for either.
> - A 422 parameter error and an empty result cost nothing. After a 422, the key is free again.
> - No key, sync resend: two charges. Async resend within 5 minutes: one charge. MCP sync resend: two charges.
> - 10,000 calls a day at $0.000556, a 5-second client timeout and no key: at least about $37 extra a month on 30-day data, $111.02 on the slower 10-hour sample. With a key: $0.

This article answers one question. When an AI agent retries a call to the EveryInfra data API, how do you stop double charges and duplicate processing? Every number comes from our live responses, the account log or official docs. We did not run Claude Code or the OpenAI Agents SDK for this test, so their settings below are configuration examples.

![Illustration: on the left, a robot arm presses the same round button three times, shown as three translucent ghost images. A teal key-shaped tag hangs on the button and stands for the Idempotency-Key. On the right, an open ledger holds one teal coin. Two gray coins hit a clear shield and bounce back toward the arm, which shows that a resend adds no second entry.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/img-explain.webp)

One key gives one real charge. A resend gets the first result back. [Illustration made for EveryInfra](https://everyinfra.com/en/blog/api-idempotency-key-ai-agent-retry)

## The results first: 19 live calls in 9 retry patterns

The table below shows all live calls. Later sections explain each row. All calls went to `POST https://api.everyinfra.com/api/v1/social` with the [`reddit/search`](/en/reddit-api/search) and [`twitter/search`](/en/twitter-api/search) capabilities. Both cost $0.000556 per call in the [catalog](https://api.everyinfra.com/api/v1/social/catalog). The "actual charges" column does not come from the response body. We checked each `request_id` in the account log (`GET /portal/api/logs`), and only a row with `charged=true` counts as money spent.

We use the log because a replayed response copies the `billing` object of the first call, with `charged: true` and `charged_usd: 0.000556`. That does not mean a second charge. Use the log for cost and the response for data.

| Pattern | Singapore time | Sends | Responses | Actual charges |
| --- | --- | --- | --- | --- |
| Same key, same request (one with reordered fields) | 00:17:18–00:17:37 | 3 | 200, 200 replay, 200 replay | 1 × $0.000556 |
| Same key, changed params (limit 10 to 5) | 00:17:44 | 1 | 409 idempotency_conflict | 0 |
| Same key while the first call runs | 00:18:02–00:18:28 | 3 | 200 (23.4 s), 409 in progress, 200 replay | 1 × $0.000556 |
| Same key after a 422, with fixed params | 00:19:47–00:20:02 | 2 | 422, 200 | 1 × $0.000556 |
| Same key after an empty result | 00:20:15–00:20:26 | 2 | 200 count 0, 200 replay count 0 | 0 |
| No key, sync resend | 00:19:02–00:19:14 | 2 | 200 (1 row), 200 (2 rows) | 2 × $0.000556 |
| No key, async resend within 5 minutes | 00:19:31–00:19:40 | 2 | 202, 202 replay | 1 × $0.000556 |
| MCP sync resend (no key parameter) | 00:20:40–00:21:52 | 2 | 200, 200 | 2 × $0.000556 |
| MCP async resend within 5 minutes | 00:21:06–00:22:11 | 2 | 200 job, 200 replay | 1 × $0.000556 |

The 19 calls in the table produced 9 charges, a total of $0.005. Two groups are not in the table. One is a key format check at 00:17:11 (a 422 with no charge, described below). The other is 6 calls that the rate limit of the test key (10 calls per minute) blocked. Four were REST calls with HTTP 429, and two were MCP calls with rate_limited. None of them cost anything.

The two extra charges came from one pattern only: a sync resend without a key, once over REST and once over MCP.

This is the main finding of the test. With a key, or with async mode, many resends still cost one charge. The expensive mistake is a sync call with no key that the client sends again after a timeout.

![Grouped horizontal bar chart of 9 patterns. Each pattern has two bars: light gray for sends and dark gray or teal for actual charges. Same key, same request: 3 sends, 1 charge. Same key, changed params: 1 send, 0 charges. Same key while running: 3 sends, 1 charge. Same key after a 422: 2 sends, 1 charge. Same key after an empty result: 2 sends, 0 charges. No key, sync: 2 sends, 2 charges, in teal. No key, async: 2 sends, 1 charge. MCP sync: 2 sends, 2 charges, in teal. MCP async: 2 sends, 1 charge. Title: 19 sends in 9 patterns gave 9 charges, and both extra charges came from sync resends without a key.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-04-sends-vs-charges-bar.en.light.svg)

## AI agent tool call retry: timeouts, dropped connections and the model itself

There are three causes of a resend, and each needs a different fix. The first cause is a client timeout. The framework sets a wait limit for each tool call and treats a late call as a failure. But a timeout only tells you that the client stopped waiting. The server can still finish the request and charge for it. The MCP specification says that a cancel notice can arrive after the server finished the request or sent the response ([MCP 2026-07-28 Cancellation](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation)).

The second cause is a dropped connection. The Claude Code error reference has an entry for "MCP server connection closed before the tool call was answered" ([Claude Code errors](https://code.claude.com/docs/en/errors)). Claude Code then cannot know if the tool ran, and a repeat call can repeat its action. Since version 2.1.307 on October 21, 2026, it resends only read-only or idempotent tools ([Claude Code changelog](https://code.claude.com/docs/en/changelog), [CHANGELOG.md on GitHub](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md)). Before that release, the client itself could send one paid call two times.

The third cause is the hardest to stop: the model calls the tool again. It gets a result that it does not understand, a timeout text or nothing, and decides that the first call failed. In one r/LangChain thread, one reply says that a tool error with no readable feedback can leave the model stuck in retries. Another developer says that his agents keep hitting the same tool when the result has an unexpected shape ([thread](https://www.reddit.com/r/LangChain/comments/1pal3x4/has_anyone_dealt_with_duplicate_tool_calls_when/)).

An r/LLMDevs post from October 28, 2026 has the title "My agent spent the whole night retrying one failed tool call" ([post](https://www.reddit.com/r/LLMDevs/comments/1xhlg75/my_agent_spent_the_whole_night_retrying_one/)). A retry switch in the framework cannot stop this resend. Only the API can see that two calls are the same job.

![Animated flow with five boxes from left to right. 1 Send: POST with key k, the server starts, about 23 s. 2 Client timeout: no reply in 5 s, the server still runs, not a failure. 3 Same key again: 409 in progress, Retry-After 5, no run, no charge. 4 Wait and resend: same request, the first one is done. 5 Result: 200 replayed, same billing, charged once. A dashed line under steps 2 and 3 says same key, same params. A teal dot runs the loop in 10 seconds, and teal marks the one call that runs and is charged.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-02-retry-flow.en.light.svg)

| Cause | Example | Does a framework retry switch stop it? | Fix with EveryInfra |
| --- | --- | --- | --- |
| Client timeout | Agents SDK gives up after 5 s, the server needs 23 s | Only if retries are off | Same key on resend, or async mode |
| Dropped connection | MCP connection closes before the answer | Claude Code 2.1.307 and later does not resend non-idempotent tools | Same key on resend over REST |
| The model calls again | The model reads "timeout" and calls the tool again | No | A key fixed by the wrapper, not by the model |

In all three cases, the client does not know the result of the first call when it resends. An idempotency key solves this problem. The client gives one logical request one name for every send, and the server does the work once and returns the first result.

Stripe describes this as a way to retry requests safely after a connection error without a second object ([Stripe idempotent requests](https://docs.stripe.com/api/idempotent_requests)). The IETF draft for the Idempotency-Key header reached version 08 on October 22, 2026 ([draft-ietf-httpapi-idempotency-key-header-08](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)). Its goal is a standard header that makes POST safe to retry. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-idempotent-methods) defines GET, PUT and DELETE as idempotent and POST as not idempotent. Almost every data API query is a POST.

## How the API idempotency key works on EveryInfra: rules and status codes

EveryInfra publishes the rules in its full developer notes, in the section "Retries and idempotency" ([llms-full.txt](https://api.everyinfra.com/llms-full.txt)). The [OpenAPI document](https://api.everyinfra.com/openapi.json) has the same header and error codes, and the site [docs](/en/docs#quickstart) use the same text. We compared the three sources rule by rule, and they agree. Then we tested each rule with live calls.

`POST /api/v1/social` accepts an `Idempotency-Key` request header. The value is 1 to 255 visible ASCII characters with no spaces. Use one new key for each logical request. For sync calls, a resend of the same request with the same key within 24 hours returns the first successful response. The API does not run the job again and does not charge again. The response body gets `replayed: true`, and the response headers get `Idempotent-Replayed: true`.

| What you send | What the API returns | Charge | Next step |
| --- | --- | --- | --- |
| New key, first send | Normal 200 or 202 | Only on success with data | Save the key and request_id |
| Same key, same request, within 24 hours | 200, replayed true, Idempotent-Replayed header | No new charge | Use the result |
| Same key, different request | 409 idempotency_conflict, retryable false | None | Use a new key for a new request |
| Same key while the first call runs | 409 idempotency_in_progress, Retry-After | None | Wait, then send the same request |
| Bad key format (space, over 255) | 422 invalid_idempotency_key | None | Fix the key |
| First call failed (bad params, low balance, rate limit, refunded failure) | The original error code | None | The key is free; the next send runs |
| 504 response_timeout or 502 response_interrupted | Edge response, request_id starts with cf- | Maybe | Send again with the same key |
| 503 refund_outcome_unknown or submission_outcome_unknown | The result is still being confirmed | Unknown | Do not resend; keep the request_id and check later |

The last two rows are important for agent builders. When the API gives no answer within 100 seconds, the edge returns `504 response_timeout`. The docs say that the request can still finish, so you should send it again with the same Idempotency-Key. A cut connection returns `502 response_interrupted` with the same advice. The two 503 codes mean the opposite: the charge or the submission is still being confirmed. The docs tell you not to resend and to check later with the request ID or `job_id`.

An agent that retries on every error does the wrong thing on these two 503 codes. An agent that reads the `retryable` field does the right thing. Every error body has `fault`, `retryable`, `retry_after` and `next_steps`. These fields are written for a model to read. Our build guide on [self-healing API errors](/en/build/api-errors-self-healing) shows how an agent can use them.

Here is a minimal request with a key. You choose the key format. In our test we used a prefix, a run number and a scenario name. In production, a UUID is a good choice. [RFC 9562](https://www.rfc-editor.org/info/rfc9562/) defines UUIDv7, which holds a timestamp and sorts by time, so it is easier to index than a random UUIDv4. Stripe recommends a V4 UUID or another random string, and it says not to put personal data such as an email address in the key.

```bash
# One logical request, one key. After a timeout or a dropped connection,
# send again with the same key and the same body.
KEY="agent-run42-step7-$(uuidgen)"
curl -sS https://api.everyinfra.com/api/v1/social \
  -H "Authorization: Bearer $EVERYINFRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"platform":"reddit","action":"search","params":{"keyword":"idempotency key","limit":10}}' \
  -D - | grep -i -E "^(HTTP|idempotent-replayed|retry-after|x-request-id)"
```

## Test 1: the same key three times, one charge

At 00:17:18 Singapore time we sent the first call: `reddit/search`, keyword "idempotency key", `limit` 10, with a key. After 4.4 seconds the API returned 200 with `count` 3. The `id` was `req_876177aa43924339`, and `billing.charged_usd` was 0.000556. The newest of the three posts was from November 3, 2026, Singapore time. Its title was about five cases that break a naive idempotency key implementation. That is the same topic as this article.

At 00:17:30 we sent the same body with the same key. The response came back in 0.116 seconds with HTTP 200 and the header `Idempotent-Replayed: true`. The body had `replayed: true`, the same `id` and the same three posts. At 00:17:37 we sent a third time with all JSON fields in a new order. We put `params` before `action` and `platform`, and `limit` before `keyword`. The replay came back in 0.142 seconds with the same result.

![Screenshot of real EveryInfra API responses in three parts. Part 1, 00:17:30, same key and same request: HTTP 200 and the header idempotent-replayed true in a teal box. The body has id req_876177aa43924339, count 3, charged true, charged_usd 0.000556 and replayed true. Part 2, 00:17:44, same key with limit changed from 10 to 5: HTTP 409, error code idempotency_conflict, retryable false. Part 3, 00:18:02, same key while the first call runs: HTTP 409, retry-after 5, error code idempotency_in_progress, retry_after 5, retryable true.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/img-api-replay.webp)

Real responses for three kinds of resend. The billing object in a replay is a copy of the first call. It does not mean a second charge. Captured November 9, 2026, Singapore time. [EveryInfra API response](https://api.everyinfra.com/llms-full.txt)

The docs do not mention two details of these replays, but client authors need them. First, the `X-Request-Id` response header is new on each replay. On the second send it was `req_cce7840d402b4dc1`, while the body `id` stayed the same. If your logs use the header, you see three IDs. If they use the body `id`, you see one. Use the body `id` for billing, and use the header only to trace one network round trip.

Second, the replay copies the whole `billing` object, including `balance_after_usd` from the first call. If your client adds up `charged_usd` from every response, three sends with one key count as three charges. Skip the cost when you see `replayed: true`, or use `GET /portal/api/logs` as the record. We checked the log. The three sends show as one row, `req_876177aa43924339`, with `charged=true` and $0.000556.

The reordered fields also replayed, so the API compares the content of the request and not its raw bytes. This matters for agents. When a model writes the tool arguments again, the JSON field order often changes. A byte comparison would treat the same intent as a different request and return a 409. Stripe says that its idempotency layer compares the parameters of the new request with the original and returns an error when they differ ([Stripe](https://docs.stripe.com/api/idempotent_requests)). In our test, EveryInfra did not treat field order as a difference.

## Test 2: the same key with changed parameters returns 409

At 00:17:44 Singapore time we used the same key again and changed only `limit` from 10 to 5. After 0.146 seconds the API returned `409 idempotency_conflict`. The message said that the key was already used in the last 24 hours for a different request (different platform, action or params). It told us to use a new key for the new request. `retryable` was false, and `next_steps` said "Nothing was charged". The account log has no charge for this call.

This rule looks strict, but it protects the agent. Assume the model sends `reddit/search` with `limit` 10, the call times out, and the retry has `limit` 5. A replay would give 10 rows to an agent that asked for 5. A new run would make the key useless. A clear rejection tells the client that this is not the original job anymore.

This rule gives a hard requirement for agent code. The framework or the tool wrapper must make the key before the call and keep it fixed. Do not let the model write a new key on each attempt. One r/LangChain thread from 2026 puts it well: make the key before the call, store it, and use it again on every retry ([thread](https://www.reddit.com/r/LangChain/comments/1xcbih6/how_do_you_catch_an_ai_agent_repeating_an_action/)).

Square follows the same rule. The same key with a changed request returns an error, but the behavior can differ per API ([Square](https://developer.squareup.com/docs/build-basics/common-api-patterns/idempotency)).

MDN describes one more common shape of these errors on its Idempotency-Key page ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Idempotency-Key)). It suggests 409 Conflict when a request with the same key is still in progress. It suggests 422 Unprocessable Content when the key is already used for a different payload. EveryInfra uses 409 for both cases and separates them with the error code. Your client must read the code, not only the status number.

The error body format differs too. The MDN examples use the `application/problem+json` format of [RFC 9457](https://www.rfc-editor.org/info/rfc9457/). EveryInfra returns plain JSON with `code`, `message`, `fault`, `retryable` and `next_steps`, and the `retryable` field can drive a retry decision directly.

| Case | MDN page and IETF draft 08 | EveryInfra (live test) | What the client should do |
| --- | --- | --- | --- |
| Same key, still in progress | 409 Conflict | 409 idempotency_in_progress, Retry-After 5 | Wait for Retry-After, then send the same request |
| Same key, different payload | 422 Unprocessable Content | 409 idempotency_conflict | Stop, use a new key |
| Required key missing | 400 Bad Request | Key is optional on EveryInfra | Add a key for any call that can repeat |
| Bad key format | Server defines it | 422 invalid_idempotency_key | Fix the format |

## Test 3: a resend while the first call runs returns 409 and Retry-After

This is the most common case for agents, and the most risky one. The client gives up after 5 seconds, while the server needs more than 20 seconds.

We used a slower capability, `twitter/search`, with the same keyword and `limit` 20. We sent the first call and then sent the same key again 2.5 seconds later. On the first attempt, both calls hit the rate limit of 10 calls per minute on the test key. At 00:17:55 and 00:17:57 Singapore time we got two 429 responses that asked us to wait 5 and 3 seconds. These 429 calls cost nothing and did not lock the key.

At 00:18:02 we sent both calls again at almost the same time. The first call ran for 23.4 seconds and returned 200 with `count` 20 and a charge of $0.000556.

The second call came back in 0.107 seconds with status 409 and error code `idempotency_in_progress`. It had the header `Retry-After: 5`, `retryable` true and `max_retries` 3. The message said that the first request with this key was still running. It also said that the same request after 5 seconds would get its result. At 00:18:28, after the first call finished, we sent again and got a replay in 0.131 seconds with the same `id` and 20 rows.

![Horizontal timeline bar chart. The x-axis shows seconds since the first send, from 0 to 36. Call 1 at 0 s was blocked by a 429 in 0.2 s. Call 2 at 2 s was blocked by a 429 in 0.1 s. Call 1 sent again at 7 s ran for real, and its teal bar runs to about 30 s: 200 in 23.4 s. Call 2 sent again at 7 s returned 409 in progress in 0.1 s. A send at 33 s, after the first call finished, returned a 200 replay in 0.1 s. Title: the same key during the first run gets only a 409 and Retry-After, and the job does not run twice.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-07-inflight-timeline.en.light.svg)

This test gives two results. The first is about money. Three sends gave one charge, and the resend during the run did not start a second search or a second charge. The second is about behavior. A 409 in progress means "wait", not "fail".

![Grouped horizontal bar chart of the time of the first call and of the resend in four pairs, in seconds. reddit/search with the same key: first call 4.4 s, resend 0.142 s. twitter/search during the run: first call 23.4 s, resend 0.131 s. Empty result with the same key: 4.3 s and 0.120 s. Async without a key: 1.8 s and 0.148 s. First calls are teal and resends are gray. Title: a resend does not run again, and replays and 409 responses return in under 0.15 s.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-08-replay-latency-bar.en.light.svg)

The chart puts the four pairs side by side. Every first call took 1.8 to 23.4 seconds, because it ran the search. Every resend took 0.107 to 0.148 seconds, because it ran nothing.

Generic retry libraries do not agree on 409. The [OpenAI Python SDK](https://github.com/openai/openai-python) README says that it retries connection errors, 408, 409, 429 and 500+ two times by default. So 409 is on its list. The [AWS SDK retry behavior](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) page decides by error class. It retries transient errors, such as network timeouts, and throttling errors, and a 409 is neither.

The first style also resends an `idempotency_conflict` two times for nothing. The second style gives up on an `idempotency_in_progress`. So do not rely on library defaults. In your tool wrapper, handle `idempotency_in_progress` as its own case. Read `Retry-After`, wait that number of seconds, and send the same body with the same key. Mark `idempotency_conflict` as a stop. The `max_retries: 3` value gives a limit. After three waits, switch to async mode and poll the job.

| Client | 409 idempotency_in_progress | 409 idempotency_conflict | Source |
| --- | --- | --- | --- |
| OpenAI Python SDK (default) | Retried, up to 2 times | Retried, up to 2 times, for nothing | README |
| AWS SDKs (standard mode) | Not retried: no transient or throttling error code | Not retried, which is correct | AWS retry behavior page |
| The wrapper later in this article | Waits for Retry-After, then resends | Stops | Our code |

## Failures and empty results cost nothing, and a 422 frees the key

The docs state the general rule: a call costs money only when it succeeds and returns data. Failures, empty results and rejected requests are free. We tested this rule with two pairs of calls, and we checked the state of the key after a failure. At 00:19:47 Singapore time we sent a call with a key and a bad parameter: `reddit/search` with `sort` set to `bestest`.

The API returned `422 invalid_value` in 0.113 seconds. The error body had `param: sort` and an `allowed` list of 6 valid values: controversial, newest, popular, relevance, rising and top. It also had a `catalog_url` that points to the catalog. A model can use this structure to fix its own arguments.

At 00:20:02 we sent the same key with `sort` set to `top`. If the first call still held the key, this call would get a 409 conflict. It got a 200, ran for real and cost $0.000556. This confirms the rule that a failed first call frees the key.

The empty result pair shows a different rule. At 00:20:15 we sent a key with a keyword that cannot match anything. After 4.3 seconds the API returned 200 with `count` 0, `charged: false`, `charged_usd: 0` and `billing.reason` set to `empty_result_refunded`. The body also had `refund.status: succeeded`. At 00:20:26 we sent the same key again and got a replay in 0.12 seconds, again with 0 rows and $0.

So the API treats an empty result as a finished job, and it does not free the key. Within 24 hours, the same key returns the same empty result. If your agent wants a new search a few minutes later, for example to look for new posts, it must use a new key. Otherwise it gets the stored empty result each time. Our [failure and refund reconciliation guide](/en/build/failure-refund-reconciliation) shows how to match these refunds against your own records.

We also tested the key format before the other calls. At 00:17:11 Singapore time we sent a key with a space in it. The API returned `422 invalid_idempotency_key`. The message asked for 1 to 255 visible ASCII characters with no spaces, and the call cost nothing.

The 30-day statistics of the test account show why this rule matters. From October 10 to November 8, 2026 (UTC), the account made 8,201 calls. Of these, 7,449 succeeded, 245 returned empty results (2.99%) and 752 failed (9.17%). Of the failures, 668 had a server-side cause, 61 hit a rate limit and 8 had a problem in the request. The other 15 have no category (`GET /portal/api/quality?days=30`).

![Horizontal bar chart on a log scale of 30-day call outcomes on the test account, October 10 to November 8, 2026 (UTC). Success with data, charged: 7,449, in teal. Empty result, free: 245. Server failure, free: 668. Rate limit 429, free: 61. Other failure, free: 15. Bad request, free: 8. Title: 8,201 calls in 30 days, and the 997 empty results and failures cost nothing.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-09-outcomes-30d-bar.en.light.svg)

None of these empty results and failures, about 12% of all calls, cost money. So a retry after a failure costs almost nothing. The costly retry is a different one: the first call succeeded and was charged, but the client did not wait for the answer.

## What happens without a key: two charges for sync, one merge for async

At 00:19:02 and 00:19:14 Singapore time we sent the same sync call two times without a key. It was `reddit/search` with the keyword "agent retry duplicate charge" and `limit` 5. We got two different IDs, `req_00b3776d1bb5491c` and `req_a744369a248d4463`, and two charges of $0.000556 each. The content also changed. The first call returned 1 post from October 26, 2026. The second call returned 2 different posts, one from October 28, 2026 and one from October 13, 2026.

The same query gave different results within 12 seconds, because social search changes all the time. So a resend without a key can also give the agent two sets of data that do not agree. If the agent writes both sets to a table, that is duplicate processing. If it keeps only the second set, the first charge buys nothing. A keyed replay gives one answer for one job.

Async mode has a different rule. At 00:19:31 we sent a call with `"mode": "async"` and no key. After 1.8 seconds the API returned 202 with `status: running`, a `job_id` and a `poll_url`.

At 00:19:40 we sent the same call again without a key. It came back in 0.148 seconds with 202, `replayed: true` and the same `job_id`. The body `id` was new (`req_4832d1bbe32943e8`). The account log shows only one charge, for the first call.

The docs say that, without a key, identical async submits in the same 5-minute window merge in the same way. A poll with `GET /api/v1/jobs/{job_id}` is free and does not count against the per-minute limit. We polled this job at about 00:20. Its status was `succeeded`, with `billing_state` set to `charged` and `refund_state` set to `not_applicable`.

This gives a practical rule. Some hosted agent platforms let you set only a URL and a body for an HTTP tool, with no custom headers. Some agents use MCP, which has no key parameter today. In both cases, send slow capabilities in async mode and get a 5-minute merge window for free. The cost is one more step to poll the job. The benefit is that timeouts become rare, because an async submit returns in about 2 seconds.

## The MCP side: everyinfra_call_api has no idempotency parameter

Many agents do not call REST directly. They connect to the remote MCP endpoint at `https://api.everyinfra.com/mcp`. At 00:14 Singapore time we read the raw `tools/list` response of this endpoint, which lists 12 tools. The main data tool, `everyinfra_call_api`, accepts four inputs: `platform`, `action`, `params` and `mode`. It has no idempotency key, but the ticket tool `everyinfra_create_ticket` has an `idempotency_key` input.

The annotations of `everyinfra_call_api` are `readOnlyHint: false`, `destructiveHint: true` and `openWorldHint: true`. It does not declare `idempotentHint`. Our [remote MCP setup guide](/en/build/remote-mcp-setup) shows how to connect a client to this endpoint step by step.

![Screenshot of the real tools/list response of the EveryInfra MCP endpoint, November 9, 2026, 00:14 Singapore time, with 12 tools. The top part shows everyinfra_call_api: annotations readOnlyHint false, openWorldHint true and destructiveHint true, with destructiveHint true in a teal box. Its inputSchema has only platform, action, params and mode, and a teal note at the end says there is no idempotency_key. The bottom part shows everyinfra_create_ticket with subject, description, category, request_id, job_id, contact_email and idempotency_key, with idempotency_key in a teal box.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/img-mcp-tools-list.webp)

The MCP data tool has no idempotency parameter. The ticket tool has one. Captured November 9, 2026, 00:14 Singapore time. [EveryInfra MCP tools/list response](https://api.everyinfra.com/mcp)

In the MCP specification, `idempotentHint` means that repeated calls with the same arguments have no additional effect. Its default is false, and annotations are only hints that a client must not trust from an untrusted server ([MCP 2026-07-28 schema](https://modelcontextprotocol.io/specification/2026-07-28/schema)).

The Claude Code MCP docs have a section on how to mark tools that are safe to call twice ([Claude Code MCP docs](https://code.claude.com/docs/en/mcp)). After a drop, Claude Code resends only tools with `idempotentHint` or `readOnlyHint` set to true. So it does not resend `everyinfra_call_api`, which is correct, because a sync MCP call has no key.

We tested this with live calls. At 00:20:40 Singapore time we called `reddit/search` over MCP in sync mode, with the keyword "mcp tool retry" and `limit` 5. It returned 5 rows in 5.1 seconds and cost $0.000556. At 00:21:52 we made the same call again (the call at 00:20:52 hit the rate limit). It returned in 4.0 seconds, cost another $0.000556 and had a different `id`.

For the async pair, the first try at 00:20:59 hit the rate limit. We called again with `mode: async` at 00:21:06 and got a `job_id`. At 00:22:11 the same call returned the same `job_id` with `replayed: true`, and the log shows one charge. So MCP behaves exactly like REST.

| Method | Can it carry a key | Same request within 24 hours | Measured charges | Good for |
| --- | --- | --- | --- | --- |
| REST sync with Idempotency-Key | Yes | Replay, no new charge | 3 sends, 1 charge | Agents with their own tool wrapper |
| REST sync without a key | No | Runs again | 2 sends, 2 charges | Not for flows that retry |
| REST async, with or without a key | Yes | Merge within 5 minutes (by key when present) | 2 sends, 1 charge | Slow capabilities, hosted HTTP tools |
| MCP sync everyinfra_call_api | No | Runs again | 2 sends, 2 charges | Fast capabilities, long client timeouts |
| MCP async everyinfra_call_api plus everyinfra_get_job | No, but it merges | Merge within 5 minutes | 2 sends, 1 charge | MCP clients with short timeouts |

For MCP, our advice is simple. For any capability with a median latency above a few seconds, tell the agent to pass `mode: async` and get the result with `everyinfra_get_job`. Put this rule in the system prompt or the tool description. That is more reliable than a decision by the model. We also sent a product suggestion to our own team: add an `idempotency_key` input to `everyinfra_call_api`, like the one on `everyinfra_create_ticket`, and then declare `idempotentHint`. Until that change ships, the table above shows the real state. Our article on the [MCP 2026 specification in production](/en/blog/mcp-2026-spec-production-readiness) covers other parts of the specification that matter in production.

## Claude Code retry settings: what it resends and what it does not

Most retry variables in Claude Code control its calls to the model API, and they do not make your MCP tool run again. One variable is related to this article. The note for `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK` says that behind some proxies or gateways, the non-streaming fallback after a streaming failure can run a tool twice. This variable turns that fallback off ([environment variables](https://code.claude.com/docs/en/env-vars)).

For the tool call itself, Claude Code uses three time limits. The first is a wall-clock limit: the `timeout` field (in milliseconds) in the server config, or the `MCP_TOOL_TIMEOUT` environment variable. Without a value, the limit is about 28 hours.

The second is a per-request timer. For HTTP, SSE and claude.ai connectors, it runs until the first byte of the response. Its value is the largest of 60 seconds, the tool timeout of that server, and `MCP_TIMEOUT`. So the default is at least 60 seconds, and a smaller value does not make it shorter.

The third is an idle timeout. A network server with no response and no progress notice for 5 minutes is stopped, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` changes this value ([Claude Code MCP docs](https://code.claude.com/docs/en/mcp)). Also, a tool call in the main session that runs for more than 2 minutes moves to the background.

Now compare these rules with our latency data. The default 60-second timer in Claude Code covers 98.4% of the 314 recent sync calls on our test account. Only 5 calls (1.6%) took longer than 60 seconds. Claude Code also resends only tools marked as idempotent after a dropped connection. So with default settings, Claude Code causes few double charges from client timeouts.

The remaining risk is the model that calls again. Only async mode or a keyed wrapper can stop that. Here is a configuration example. We did not run Claude Code on our machine for this test, and the field names come from the official docs.

```text
{
  "mcpServers": {
    "everyinfra": {
      "type": "http",
      "url": "https://api.everyinfra.com/mcp",
      "headers": { "Authorization": "Bearer ${EVERYINFRA_API_KEY}" },
      "timeout": 120000
    }
  }
}
```

The `timeout` here is 120 seconds, a bit more than the 85-second limit for EveryInfra sync calls. The docs say that a sync call longer than 85 seconds returns `503 timeout`, which costs nothing. The error tells you to lower `limit` or use async mode. When the client timeout is longer than the server limit, the client always gets a clear answer, success or 503. It does not give up while the server still works.

## OpenAI Agents SDK retry settings: a 5-second default read timeout

The OpenAI Agents SDK for Python connects to MCP servers with classes such as `MCPServerStreamableHttp`. The docs say that `client_session_timeout_seconds` sets the read timeout of the MCP client session. Its default is 5 seconds on `MCPServerStdio`, `MCPServerSse` and `MCPServerStreamableHttp`. `max_retry_attempts` and `retry_backoff_seconds_base` add automatic retries to `list_tools()` and `call_tool()`, and `retry_backoff_seconds_max` has no cap by default ([Agents SDK MCP docs](https://openai.github.io/openai-agents-python/mcp/)). The example in the docs sets `max_retry_attempts` to 3.

![Screenshot of the MCP page in the OpenAI Agents SDK docs. At the top is the end of a Python example with Runner.run and asyncio.run. Under the list "The constructor accepts additional options", two items are in teal boxes. The first says that client_session_timeout_seconds sets the MCP ClientSession read timeout. The default is 5 seconds on MCPServerStdio, MCPServerSse and MCPServerStreamableHttp, and None or 0 turns it off. The second says that max_retry_attempts and retry_backoff_seconds_base add automatic retries to list_tools() and call_tool().](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/img-doc-agents-sdk-timeout.webp)

The MCP read timeout default and the tool call retry options in the Agents SDK docs. The teal boxes mark the two items we cite. Captured November 9, 2026, Singapore time. [OpenAI Agents SDK MCP docs screenshot](https://openai.github.io/openai-agents-python/mcp/)

A 5-second default is too short for a data API. The median latency of 314 recent sync calls on our test account was 7.8 seconds. Of these calls, 209 (66.6%) took longer than 5 seconds. The server still finishes and charges for the calls that the client gave up on.

With `max_retry_attempts` on, the SDK sends the same tool call again after each timeout, and each send is a new charge. Without it, the same docs page says that an MCP tool failure returns model-visible error text by default (the default formatter applies when `failure_error_function` is unset, and `None` makes it raise instead). A model that reads "timeout" often calls the tool again. The docs do not say if a timeout takes this path or what text the model sees, and we did not test it.

At the model request level, the Agents SDK is careful. Retries are opt-in. By default, the SDK does not replay a request that started to stream or has local side effects ([Agents SDK models](https://openai.github.io/openai-agents-python/models/)). But the read timeout for remote MCP tools still defaults to 5 seconds.

Here is a configuration example. We did not run it on our machine, and the parameter names come from the docs above. It sets the read timeout to 90 seconds, a bit more than the 85-second sync limit. The official Python SDK waits 300 seconds by default for direct REST calls. An MCP client must wait more than 85 seconds. It does not set `max_retry_attempts`, so retries happen only in code where you control the key.

```text
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp
import os

async def main():
    async with MCPServerStreamableHttp(
        name="everyinfra",
        params={
            "url": "https://api.everyinfra.com/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['EVERYINFRA_API_KEY']}"},
        },
        client_session_timeout_seconds=90,   # default is 5 s, below our 7.8 s median latency
        # no max_retry_attempts: sync MCP calls have no idempotency key, so a resend is a new charge
        cache_tools_list=True,
    ) as server:
        agent = Agent(
            name="researcher",
            instructions="For slow capabilities, call everyinfra_call_api with mode=async, then read the result with everyinfra_get_job.",
            mcp_servers=[server],
        )
        result = await Runner.run(agent, "Find recent Reddit posts about idempotency keys")
        print(result.final_output)
```

| Setting | Claude Code (MCP tools) | OpenAI Agents SDK (MCPServerStreamableHttp) | Advice for EveryInfra |
| --- | --- | --- | --- |
| Wait per tool call | Per-request timer of at least 60 s; wall clock about 28 h | client_session_timeout_seconds, default 5 s | 90–120 s, above the 85 s sync limit |
| Idle stop | 5 minutes for network servers | Set by the read timeout | Use async mode for long jobs |
| Automatic resend after a drop or timeout | Only tools with idempotentHint or readOnlyHint, one time | call_tool retries only when max_retry_attempts is set | No automatic resend for everyinfra_call_api |
| What the model sees after an error | After a drop, an error text that says to check if the tool ran | Error text from the default formatter (failure_error_function) | Tell the model to check the log before a retry |

Our article on an [OpenAI Agents SDK review monitoring agent](/en/blog/openai-agents-api-review-monitoring-agent) builds a full agent with this SDK. The wrapper later in this article fits into it.

## How slow is the API: latency of 314 sync calls

We used "66.6% above 5 seconds" many times, so here is the source. We took the request log of the test account from November 8, 2026, 14:06 to November 9, 2026, 00:22 Singapore time (`GET /portal/api/logs`, two pages of 200 rows). After removing duplicate `request_id` values we had 330 calls, and 314 of them were sync calls with HTTP 200. `latency_ms` is the time that the server records.

The median was 7.8 seconds, p90 was 20.6 seconds and p95 was 30.3 seconds. The longest call took 85.0 seconds, which is the sync limit. Of the 314 calls, 209 (66.6%) took longer than 5 seconds, 83 (26.4%) longer than 10 seconds and 19 (6.0%) longer than 30 seconds. Only 5 (1.6%) took longer than 60 seconds and 1 (0.3%) longer than 85 seconds.

Eight of the 314 calls were our own test calls (the longest took 23.3 seconds). Without them, 67.6% of the other 306 calls took longer than 5 seconds. That is a little more than 66.6%, so our tests do not inflate the result.

![Horizontal bar chart of the share of 314 successful sync calls that took longer than each threshold. Above 5 s (the Agents SDK MCP default read timeout): 66.6%, 209 calls, in teal. Above 10 s: 26.4%, 83 calls. Above 30 s: 6.0%, 19 calls. Above 60 s (the Claude Code remote MCP per-request default): 1.6%, 5 calls. Above 85 s (the EveryInfra sync limit): 0.3%, 1 call. Title: the median sync call takes 7.8 s, so a client that gives up at 5 s misses two of every three results.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-03-latency-share-bar.en.light.svg)

This sample has a slow mix of capabilities, and we must state that limit. Five capabilities with 10 or more calls had a median above 5 seconds. They are [`twitter/comments`](/en/twitter-api/comments) (71 calls), `threads/comments` (41), `youtube/transcript` (19), [`threads/search`](/en/threads-api/search) (14) and `youtube/search` (10). `xiaohongshu/comments` (71 calls) had a median of only 2.0 seconds, and only 5.6% of its calls took longer than 5 seconds. The share was 15.4% for [`reddit/search`](/en/reddit-api/search) (13 calls) and 18.2% for `xiaohongshu/search` (11 calls). So 66.6% describes this account in these ten hours, and a different mix gives a different number.

For a more stable lower bound, we used the 30-day quality statistics. Of 95 capabilities with latency data, 67 had a median success latency above 5 seconds. At least half of the calls of each of those capabilities took longer than 5 seconds. So at least 22.1% of the 7,449 successful calls in 30 days took longer than 5 seconds. With the same method, 19 capabilities had a p95 above 60 seconds, so at least 0.48% of calls took longer than 60 seconds.

![Grouped horizontal bar chart of the 12 capabilities with the most calls in 30 days. Each has two bars in seconds: dark gray for median latency and light gray for p95. Two teal dashed lines mark 5 s and 60 s. twitter/comments, 821 calls: 1.9 s and 19.1 s. xiaohongshu/comments, 648: 2.0 s and 5.1 s. twitter/search, 537: 22.7 s and 57.4 s. youtube/comments, 484: 2.0 s and 26.6 s. xiaohongshu/note, 453: 2.1 s and 16.4 s. search/web, 452: 1.7 s and 3.2 s. bilibili/comments, 375: 1.6 s and 11.3 s. xiaohongshu/search, 373: 3.1 s and 17.3 s. threads/search, 313: 11.7 s and 22.5 s. threads/comments, 232: 1.9 s and 30.9 s. youtube/search, 163: 9.8 s and 21.5 s. zhihu/content_comments, 159: 1.9 s and 11.9 s. Title: of the 12 most used capabilities in 30 days, 3 have a median above 5 s.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-06-capability-p50-p95-bar.en.light.svg)

Use this chart to choose which capabilities use async mode by default. `twitter/search` has a median of 22.7 seconds, `threads/search` 11.7 seconds and `youtube/search` 9.8 seconds. With a 5-second timeout, a client gives up on these three almost every time.

A second group needs care too: capabilities with a fast median and a slow p95. `twitter/comments` has a median of 1.9 seconds and a p95 of 19.1 seconds. `threads/comments` has 1.9 seconds and 30.9 seconds. These are usually fast but sometimes take 20 or 30 seconds, and that is when an agent is most likely to think the call failed. `search/web` has a median of 1.7 seconds and a p95 of 3.2 seconds, so a 5-second timeout is rarely a problem for it.

## The cost: how much an agent with X% retries pays without a key

How much more does an agent without a key pay? It depends on X, the share of its calls that it resends each day. X depends on the client timeout. We did not want to guess X, so we took it from the latency data above. Assume a client timeout of T seconds, and one blind resend without a key after each timeout. Then X is the share of successful calls that take longer than T, and each of those resends is one more charge.

We count only one resend per timeout. In real use the resend can also time out and trigger another resend, so the numbers below are a floor. The unit price is $0.000556, the catalog price of [`reddit/search`](/en/reddit-api/search) and 115 other capabilities. The 571 available capabilities in the catalog had a median price of $0.001389 and a range of $0.000556 to $0.28.

![Horizontal bar chart of the number of available capabilities per price band, read November 9, 2026. $0.000556: 116, in teal. $0.000557 to $0.001388: 80. $0.001389, the median: 257, in teal. $0.00139 to $0.005: 23. $0.005 to $0.01: 54. $0.01 to $0.05: 38. Above $0.05: 3. Title: of 571 available capabilities, 453 cost $0.001389 or less per call.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-10-price-bands-bar.en.light.svg)

The chart shows that 453 of 571 capabilities cost $0.001389 or less. So the two price columns in the table below cover most real agents. For a capability at a higher price, multiply the row by the price ratio.

| Client timeout T | X (share above T) | 10,000 calls a day at $0.000556: extra per month | 10,000 calls a day at the $0.001389 median: extra per month |
| --- | --- | --- | --- |
| 5 s (Agents SDK MCP default) | 66.6% (30-day floor 22.1%) | $111.02 (floor about $36.8) | $277.36 (floor about $92) |
| 10 s | 26.4% | $44.09 | about $110 |
| 30 s | 6.0% | $10.09 | about $25 |
| 60 s (Claude Code per-request default) | 1.6% | $2.65 | about $6.6 |
| 85 s (EveryInfra sync limit) | 0.3% | $0.53 | about $1.3 |
| With Idempotency-Key (any T) | No double charge | $0.00 | $0.00 |

The formula is: extra per month = calls per day × X × unit price × 30. For the first row: 10,000 × 0.6656 × $0.000556 × 30 = $111.02. At the median price, the 5-second cell is 10,000 × 0.6656 × $0.001389 × 30 = $277.36. We rounded the other cells in that column and marked them "about".

We also checked the result against real money spent by the test account. Of the 314 successful sync calls, 291 had a charge, for a total of $0.6588. The 187 charged calls that took longer than 5 seconds cost $0.2150, which is 32.6% of the total. Now assume an agent with a 5-second timeout, no key and one blind resend per timeout. It would raise the cost of these ten hours from $0.66 to at least $0.87. The share by money (32.6%) is lower than the share by count (66.6%), because many slow capabilities have low prices.

![Horizontal bar chart of the extra cost per month for 10,000 calls a day at $0.000556, with one blind resend per timeout. Client timeout 5 s: $111.02. 10 s: $44.09. 30 s: $10.09. 60 s: $2.65. 85 s: $0.53. With Idempotency-Key, any timeout: $0.00. Title: 10,000 calls a day, a 5-second timeout and no key cost at least $111 extra a month.](https://everyinfra.com/articles/api-idempotency-key-ai-agent-retry/fig-05-retry-cost-bar.en.light.svg)

This estimate has three limits. First, the latency data comes from ten hours on one test account with a slow mix of capabilities. An agent that mostly uses fast capabilities such as `xiaohongshu/comments` and `search/web` gets a much smaller number in the 5-second row. The 30-day floor is 22.1%, which gives at least about $36.8 a month.

Second, we assume exactly one resend after each timeout. A real agent can skip the resend and report a failure. It can also resend many times, like the agent in the Reddit post that retried all night. Third, a resend without a key also gets new data, as in our test where 1 row became 2 rows in 12 seconds. The $0 in the keyed row needs no assumption, because a replay does not run.

## Deduplicate webhooks by X-EveryInfra-Event-Id

Duplicate processing can also happen in the other direction, when the API notifies you. EveryInfra webhooks (Settings → Webhooks in the console) have two event types. Each delivery is a POST with the body `{"id", "type", "created_at", "data"}`.

| Event | When it fires | Main fields in data | What to do next |
| --- | --- | --- | --- |
| balance.low | Once per crossing, when the balance falls to or below your USD threshold | The docs do not list them | Top up or lower spend |
| job.finished | An async job succeeds or fails, after the charge or refund is final | job_id, request_id, status, result_count, billing_state, refund_state, result_url | Get rows with GET /api/v1/jobs/{job_id} and the key that submitted the job |

The docs promise at-least-once delivery. Any 2xx response counts as delivered. Otherwise the API sends the event again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours and 6 hours. That is 7 attempts in total. So the same event can reach you twice, for example when your service finishes its work but times out before it returns 200.

The docs say to deduplicate by `X-EveryInfra-Event-Id` and to verify the signature. `X-EveryInfra-Signature: v1=<hex>` must equal an HMAC-SHA256 of the `X-EveryInfra-Timestamp` value, a dot and the raw body, made with your signing secret. Reject a timestamp that is more than 5 minutes off. Stripe gives the same advice for its webhooks: log the event IDs that you processed and skip the ones you already have ([Stripe webhooks](https://docs.stripe.com/webhooks)). The Microsoft retry pattern also says that the receiver of a resent message must be an idempotent consumer ([Azure retry pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/retry)).

```text
import hashlib, hmac, json, time

def handle_everyinfra_webhook(headers, raw_body: bytes, secret: bytes, db):
    ts = headers["X-EveryInfra-Timestamp"]
    sig = headers["X-EveryInfra-Signature"].removeprefix("v1=")
    want = hmac.new(secret, ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, want) or abs(time.time() - int(ts)) > 300:
        return 400
    event_id = headers["X-EveryInfra-Event-Id"]
    # A unique index makes sure each event runs once. A repeat insert fails, so return 200.
    if not db.insert_if_absent("webhook_events", event_id):
        return 200
    event = json.loads(raw_body)
    if event["type"] == "job.finished":
        db.enqueue_fetch(event["data"]["job_id"])   # read rows with GET /api/v1/jobs/{job_id}
    return 200
```

One detail here matters for agents. Write the event ID first and process second. Do not check first and process second.

One reply in the r/LangChain thread explains why ([thread](https://www.reddit.com/r/LangChain/comments/1xcbih6/how_do_you_catch_an_ai_agent_repeating_an_action/)). Two deliveries can arrive at almost the same time. Both check, both find nothing and both process the event. So the key must work as a lock, and a unique index closes that gap. The side whose insert succeeds does the work, and the other side returns 200. Python users can also verify signatures with `from everyinfra.webhooks import verify_signature` in the SDK.

## A tool wrapper you can use: make the key first, keep it on retry

For Python, the simplest way is the official SDK: `pip install everyinfra` (version 0.3.0, Python 3.9 or later, no dependencies). Its README says that `data()` sends an `Idempotency-Key` on every call, sync and async. After a dropped connection, the SDK resends. It gets the first response for a sync call or the same job for an async call, with no second charge. To retry the same logical request across processes, pass your own stable key with `idempotency_key=` ([everyinfra on PyPI](https://pypi.org/project/everyinfra/)).

The README also says to keep the default timeout of 300 seconds. If the client stops before the server finishes, you get the worst case: the charge is made and the result is lost. This article measured exactly that case.

```text
import os
from everyinfra import EveryInfra

ei = EveryInfra(os.environ["EVERYINFRA_API_KEY"])
posts = ei.data("reddit", "search", keyword="idempotency key", limit=10,
                idempotency_key="run42-step7")   # the same step uses the same key after a restart
```

Some agents cannot use the Python SDK. Examples are an agent in JavaScript, an HTTP tool on a hosted platform, and a framework where you control all retries.

For those, a data tool wrapper needs to do four things. It makes the key before the call and keeps it fixed. It resends with the same key after a timeout, a dropped connection, a 504 or a 502. It waits for `Retry-After` on a 409 in progress. It stops on a 409 conflict, on other 4xx errors and on the two 503 codes with an unknown result. The Python code below uses only the standard library, and you can port it to any language.

```text
import json, os, time, uuid, urllib.error, urllib.request

API = "https://api.everyinfra.com/api/v1/social"
STOP = {"idempotency_conflict", "refund_outcome_unknown", "submission_outcome_unknown"}

def call_social(body: dict, key=None, timeout: float = 90, attempts: int = 4):
    key = key or f"agent-{uuid.uuid4()}"          # one logical request, one key; store it in the task state
    for i in range(attempts):
        req = urllib.request.Request(API, data=json.dumps(body).encode(), method="POST", headers={
            "Authorization": "Bearer " + os.environ["EVERYINFRA_API_KEY"],
            "Content-Type": "application/json",
            "Idempotency-Key": key,
        })
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                data = json.load(r)
                data["_cost_usd"] = 0 if data.get("replayed") else data["billing"]["charged_usd"]
                return data
        except urllib.error.HTTPError as e:
            try:
                err = (json.load(e) or {}).get("error", {})
            except ValueError:                        # a non-JSON body, such as an HTML page from the edge
                err = {"retryable": e.code in (502, 504)}
            if err.get("code") in STOP or not err.get("retryable"):
                raise RuntimeError(f"{e.code} {err.get('code')}: {err.get('message')} key={key}")
            time.sleep(float(e.headers.get("Retry-After") or err.get("retry_after") or 2 ** i))
        except (TimeoutError, urllib.error.URLError):
            time.sleep(2 ** i)                        # result unknown: send again with the same key
    raise RuntimeError(f"gave up after {attempts} tries, Idempotency-Key={key}")
```

`_cost_usd` is 0 on a replay, so the copied `billing` object does not count twice. The retry decision reads the `retryable` field, so you do not need your own table of status codes. On EveryInfra, 429, 409 in progress and retryable server errors have `retryable: true`. A 409 conflict, 422, 401, 402 and 403 have false.

The wait time comes from the `Retry-After` header first. This is common practice, and the OpenAI error code guide also says to follow `Retry-After` on a 429 ([OpenAI error codes](https://developers.openai.com/api/docs/guides/error-codes)). If the error body is not JSON, for example a page from the edge, only 502 and 504 count as retryable. The default timeout is 90 seconds, a bit above the 85-second sync limit. The official SDK uses 300 seconds, which is safer.

When the caller passes a key, build it from the agent run ID and the step number, for example `run42-step7`. Then, if the agent process crashes and restarts, the same step uses the same key and gets the original result within 24 hours. It does not pay again. One reply in the r/LangChain thread recommends this same idea of a key from the run and step IDs.

The LangGraph docs give the same advice. After an interrupt or a retry, a node runs again from the start, and its side effects run again. So the docs recommend idempotency keys, upserts or read-before-write checks ([LangGraph Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api)). Our article on [Claude Code subagents for TikTok brand accounts](/en/blog/claude-code-subagents-tiktok-accounts) shows backoff for 429 errors, and it uses the same `Retry-After` logic.

| Response | retryable | What the wrapper does | Why |
| --- | --- | --- | --- |
| 200 with replayed true | — | Return the data, cost 0 | The first call already paid |
| 409 idempotency_in_progress | true | Wait Retry-After, send the same request | The first call still runs |
| 429 rate limit | true | Wait Retry-After, send the same request | No charge, key not locked |
| 504 response_timeout, 502 response_interrupted | true | Send again with the same key | The job can still finish |
| Client timeout or connection error | unknown | Send again with the same key | A replay is free if the job finished |
| 409 idempotency_conflict | false | Stop | The request is no longer the same job |
| 422, 401, 402, 403 | false | Stop | The request needs a fix first |
| 503 refund_outcome_unknown, submission_outcome_unknown | false | Stop, check the request_id later | The charge is still being confirmed |

## Idempotent API retry rules at other providers

It helps to compare EveryInfra with other services, so you can see which rules are common and which need their own handling. We list only facts that the docs of each service state, and we read all of them on November 9, 2026.

| Service | Header or field | Key length | Retention | Same key, different params |
| --- | --- | --- | --- | --- |
| EveryInfra data API | Idempotency-Key | 1–255 visible ASCII characters | 24 hours (5-minute merge for async without a key) | 409 idempotency_conflict |
| Stripe API v1 | Idempotency-Key | Up to 255 characters | Can be removed after at least 24 hours | Error |
| Square | idempotency_key field | Not stated | Not stated | Error (can differ per API) |
| AWS EC2 | ClientToken | Up to 64 ASCII characters | Not stated | Error |
| Google AIP-155 | request_id field | UUID | Set by each API | Set by each API |
| IETF draft 08 | Idempotency-Key | Server defines it | Server publishes it | 422 suggested (409 while in progress) |

Sources: [Stripe](https://docs.stripe.com/api/idempotent_requests), [Square](https://developer.squareup.com/docs/build-basics/common-api-patterns/idempotency), [AWS EC2](https://docs.aws.amazon.com/ec2/latest/devguide/ec2-api-idempotency.html), [Google AIP-155](https://google.aip.dev/155) (a UUID `request_id` field, used mainly for idempotency) and the [IETF draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/). "Not stated" means that the page we read gave no number, and we do not invent one. Two payment APIs use other header names. [Adyen](https://docs.adyen.com/development-resources/api-idempotency) uses `idempotency-key` with up to 64 characters. [PayPal](https://developer.paypal.com/api/rest/reference/idempotency) uses `PayPal-Request-Id`, which returns the latest status of the earlier request.

EveryInfra is closest to Stripe. Both use the same header name, a 255-character limit, 24 hours and an error for the same key with different params.

There is one difference. Stripe API v1 saves the status code and body of the first call after the endpoint starts to run. So a retry with the same key returns even a 500 error again. EveryInfra saves only successful results. A failure, including a refunded server failure, frees the key, and the next send runs.

For a data API, the EveryInfra rule makes more sense, because a failed search should not replay its failure for 24 hours. The Amazon Builders' Library article "Making retries safe with idempotent APIs" also covers what a replay should return. A plain "resource already exists" error has no side effect, but the caller still cannot tell if its own request made the resource. So the better design returns a semantically equivalent response for the same request ID for some interval ([Amazon Builders' Library](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-apis/)). EveryInfra does this: it replays the full first response.

## Limits and things to watch

A key stops the same job from running twice, not an old job from running once with stale data. One r/AI_Agents post says this clearly ([post](https://www.reddit.com/r/AI_Agents/comments/1v9vjp2/your_agents_retry_logic_dies_when_the_agent_does/)). A key blocks a repeated action, but not an outdated action that runs once and is wrong. The same key replays the first data for 24 hours, so use a new key for fresh data. The task state of the agent must separate a retry from a new query.

MCP sync calls have no idempotency parameter on the test day. Use async mode, or put your own REST wrapper in front of the MCP client. A replay works only within 24 hours, and after that the same key counts as a new request. The merge window for async calls without a key is only 5 minutes.

Rate limit responses (429) do not lock the key and cost nothing, but they can break your retry timing. Our test had 6 rate-limited calls (4 REST 429 responses and 2 on MCP), because we limited the test key to 10 calls per minute (the account default is 60). A spend limit on the key (`spend_limit_usd`, which returns `403 key_spend_limit_reached`) stops spending on the server side. That is more reliable than a budget counter inside the agent process. One reply under the Reddit post about the night of retries makes the same point. A per-run cap resets when the job restarts, so put a daily budget on the API key ([post](https://www.reddit.com/r/LLMDevs/comments/1xhlg75/my_agent_spent_the_whole_night_retrying_one/)).

Some things we could not test. The docs do not say if the server stops or refunds a job after the client disconnects. So our cost estimate assumes a charge in every such case. We did not test Claude Code or the Agents SDK on our machine. If your own tests show a different result, trust your tests.

## Recent changes

Four client changes and one draft standard affect how agents retry. The sources are the [Claude Code changelog](https://code.claude.com/docs/en/changelog), the [IETF datatracker](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/), the [Agents SDK releases](https://github.com/openai/openai-agents-python/releases) and [PyPI](https://pypi.org/project/openai-agents/).

| Date | Change | What it means for you |
| --- | --- | --- |
| November 6, 2026 | Claude Code 2.1.319 can move MCP tasks to the background in the terminal | Long data calls do not block the main session, which fits async mode |
| November 4, 2026 | Claude Code 2.1.318 keeps the interrupted turn after a restart that follows retried tool calls | Fewer lost tool results, but the account log is still the record of cost |
| October 22, 2026 | IETF Idempotency-Key draft 08, valid until April 25, 2027 | A client that follows the header now needs few changes later |
| October 21, 2026 | Claude Code 2.1.307 retries only read-only or idempotent MCP tools after a drop | Mark safe tools with idempotentHint in your own MCP server |
| Latest preview on November 9, 2026 | Agents SDK v0.24.0-alpha.7 session stores can report calls with an uncertain result and do not replay them | The idempotency key is how you check such a call |

## FAQ

### How do I generate an api idempotency key?

Use a UUID, for example a UUIDv7 from [RFC 9562](https://www.rfc-editor.org/info/rfc9562/), which sorts by time. The format matters less than the timing. Make the key before the first call and store it in the task state. Use it again on every retry. EveryInfra requires 1 to 255 visible ASCII characters with no spaces, or it returns `422 invalid_idempotency_key`. Do not put personal data such as an email address in the key.

### How long should the timeout be for an idempotent api retry?

With a key, a short timeout only adds free replays. Without a key, a 5-second timeout at 10,000 calls a day costs at least about $37 extra a month on 30-day data. On our slower sample it costs $111.02, and a 60-second timeout costs $2.65. For EveryInfra, set an MCP client timeout to 90–120 seconds, above the 85-second sync limit. Keep the 300-second default of the official Python SDK, and use async mode for slow capabilities. Common HTTP libraries such as HTTPX time out after 5 seconds by default ([HTTPX timeouts](https://www.python-httpx.org/advanced/timeouts/)), so set a longer value in your tool code.

### Does an ai agent tool call retry cause a double charge?

This is also the question of the r/LangChain thread "How do you catch an AI agent repeating an action after a timeout?" ([thread](https://www.reddit.com/r/LangChain/comments/1xcbih6/how_do_you_catch_an_ai_agent_repeating_an_action/)). It depends on how you send it. In our test, three REST sends with one key gave one charge. Two sync sends without a key, over REST or MCP, gave two charges, and two async sends gave one. Failures and empty results cost nothing. A replay copies the first `billing` object, so `charged: true` is still there, but it is not a second charge. When you see `replayed: true`, do not add the amount to your cost.

### Does a replay return the original result or only a "duplicate" error?

One reply in an r/LangChain thread warns about this ([thread](https://www.reddit.com/r/LangChain/comments/1xcbih6/how_do_you_catch_an_ai_agent_repeating_an_action/)). It says that many APIs return only a duplicate error, so the agent still does not know the first result. EveryInfra returns the original result. The same key returns the full first response for 24 hours. In our test the three rows were identical. While the first call runs, you get a 409 and `Retry-After`, and then the result.

### Can webhooks arrive twice?

Yes, and the "Handle duplicate events" section of the Stripe webhook docs covers the same case. EveryInfra webhooks use at-least-once delivery. Without a 2xx response, the event comes again between 30 seconds and 6 hours later, up to 7 attempts. Deduplicate by `X-EveryInfra-Event-Id` with a unique database index (insert first, then process), and verify the signature and the 5-minute time window.

## Get started

Step 1: create an API key in the console and put it in the environment variable `EVERYINFRA_API_KEY`. Keep it out of your code and your logs. Step 2: read the free catalog at `GET /api/v1/social/catalog` and find the capability, its params and its `price_usd`. For example, the [Reddit data API](/en/reddit-api) and the [X (Twitter) data API](/en/twitter-api) each have search, comments and other capabilities.

Step 3: send a first request with an `Idempotency-Key`, as in the curl example above. Then send the same request again and check for the `Idempotent-Replayed: true` header.

Step 4: in Python, run `pip install everyinfra` and call `ei.data(...)`, which sends a key for you. In other languages, port `call_social` to a tool in your agent framework. In an MCP client, set the read timeout to 90 seconds or more and pass `mode: async` for slow capabilities. The full quick start is in the [docs](/en/docs#quickstart), and per-call prices are on the [pricing page](/en/pricing). The list of capabilities is in the [data market](/en/market).

## References

1. EveryInfra full developer notes (Retries and idempotency, Errors, Webhooks), EveryInfra, read 2026-11-09, https://api.everyinfra.com/llms-full.txt
1. EveryInfra OpenAPI document, EveryInfra, read 2026-11-09, https://api.everyinfra.com/openapi.json
1. EveryInfra data capability catalog, EveryInfra, read 2026-11-09, https://api.everyinfra.com/api/v1/social/catalog
1. everyinfra 0.3.0 (official Python SDK; README: sends Idempotency-Key automatically, 300-second default timeout), PyPI, read 2026-11-09, https://pypi.org/project/everyinfra/
1. EveryInfra MCP endpoint tools/list, EveryInfra, read 2026-11-09, https://api.everyinfra.com/mcp
1. Connect Claude Code to tools via MCP, Anthropic, read 2026-11-09, https://code.claude.com/docs/en/mcp
1. Claude Code environment variables, Anthropic, read 2026-11-09, https://code.claude.com/docs/en/env-vars
1. Claude Code error reference, Anthropic, read 2026-11-09, https://code.claude.com/docs/en/errors
1. Claude Code changelog (2.1.307, 2.1.318, 2.1.319), Anthropic, October 21, 2026 to November 6, 2026, https://code.claude.com/docs/en/changelog
1. Claude Code CHANGELOG.md, Anthropic (GitHub), read 2026-11-09, https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md
1. Model context protocol (MCP), OpenAI Agents SDK docs, read 2026-11-09, https://openai.github.io/openai-agents-python/mcp/
1. Models (runner-managed retries, model-call timeouts), OpenAI Agents SDK docs, read 2026-11-09, https://openai.github.io/openai-agents-python/models/
1. openai-agents-python releases (v0.24.0-alpha.7), OpenAI (GitHub), read 2026-11-09, https://github.com/openai/openai-agents-python/releases
1. openai-agents, PyPI, read 2026-11-09, https://pypi.org/project/openai-agents/
1. MCP Specification 2026-07-28: Tools, Model Context Protocol, read 2026-11-09, https://modelcontextprotocol.io/specification/2026-07-28/server/tools
1. MCP Specification 2026-07-28: Schema (ToolAnnotations), Model Context Protocol, read 2026-11-09, https://modelcontextprotocol.io/specification/2026-07-28/schema
1. MCP Specification 2026-07-28: Cancellation, Model Context Protocol, read 2026-11-09, https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation
1. The Idempotency-Key HTTP Header Field (draft-08), IETF HTTPAPI working group, October 22, 2026, https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
1. Idempotency-Key header, MDN Web Docs, read 2026-11-09, https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Idempotency-Key
1. RFC 9110 HTTP Semantics (Idempotent Methods), IETF, June 2022, https://www.rfc-editor.org/rfc/rfc9110#name-idempotent-methods
1. RFC 9457 Problem Details for HTTP APIs, IETF, July 2023, https://www.rfc-editor.org/info/rfc9457/
1. RFC 9562 Universally Unique IDentifiers (UUIDs), IETF, May 2024, https://www.rfc-editor.org/info/rfc9562/
1. Idempotent requests, Stripe, read 2026-11-09, https://docs.stripe.com/api/idempotent_requests
1. Receive Stripe events in your webhook endpoint, Stripe, read 2026-11-09, https://docs.stripe.com/webhooks
1. API idempotency, Adyen, read 2026-11-09, https://docs.adyen.com/development-resources/api-idempotency
1. Idempotency, PayPal Developer, read 2026-11-09, https://developer.paypal.com/api/rest/reference/idempotency
1. Idempotency, Square Developer, read 2026-11-09, https://developer.squareup.com/docs/build-basics/common-api-patterns/idempotency
1. Ensuring idempotency in Amazon EC2 API requests, AWS, read 2026-11-09, https://docs.aws.amazon.com/ec2/latest/devguide/ec2-api-idempotency.html
1. Retry behavior, AWS SDKs and Tools, read 2026-11-09, https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html
1. Making retries safe with idempotent APIs, Amazon Builders' Library, read 2026-11-09, https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-apis/
1. Retry pattern, Microsoft Azure Architecture Center, read 2026-11-09, https://learn.microsoft.com/en-us/azure/architecture/patterns/retry
1. Retry strategy, Google Cloud Storage, read 2026-11-09, https://docs.cloud.google.com/storage/docs/retry-strategy
1. AIP-155 Request identification, Google, read 2026-11-09, https://google.aip.dev/155
1. Timeouts, HTTPX, read 2026-11-09, https://www.python-httpx.org/advanced/timeouts/
1. What is a Temporal Retry Policy?, Temporal, read 2026-11-09, https://docs.temporal.io/encyclopedia/retry-policies
1. Graph API overview (RetryPolicy), LangChain, read 2026-11-09, https://docs.langchain.com/oss/python/langgraph/graph-api
1. openai-python README (Retries), OpenAI (GitHub), read 2026-11-09, https://github.com/openai/openai-python
1. Error codes, OpenAI API, read 2026-11-09, https://developers.openai.com/api/docs/guides/error-codes
1. How do you catch an AI agent repeating an action after a timeout?, Reddit r/LangChain, read 2026-11-09, https://www.reddit.com/r/LangChain/comments/1xcbih6/how_do_you_catch_an_ai_agent_repeating_an_action/
1. Has anyone dealt with duplicate tool calls when agents retry the tool calls?, Reddit r/LangChain, read 2026-11-09, https://www.reddit.com/r/LangChain/comments/1pal3x4/has_anyone_dealt_with_duplicate_tool_calls_when/
1. My agent spent the whole night retrying one failed tool call, Reddit r/LLMDevs, October 28, 2026, https://www.reddit.com/r/LLMDevs/comments/1xhlg75/my_agent_spent_the_whole_night_retrying_one/
1. Your agent's retry logic dies when the agent does, Reddit r/AI_Agents, read 2026-11-09, https://www.reddit.com/r/AI_Agents/comments/1v9vjp2/your_agents_retry_logic_dies_when_the_agent_does/
