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.

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.
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.

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 and twitter/search capabilities. Both cost $0.000556 per call in the 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.
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).
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). 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, CHANGELOG.md on GitHub). 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).
An r/LLMDevs post from October 28, 2026 has the title "My agent spent the whole night retrying one failed tool call" (post). A retry switch in the framework cannot stop this resend. Only the API can see that two calls are the same job.
| 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). The IETF draft for the Idempotency-Key header reached version 08 on October 22, 2026 (draft-ietf-httpapi-idempotency-key-header-08). Its goal is a standard header that makes POST safe to retry. RFC 9110 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). The OpenAPI document has the same header and error codes, and the site docs 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 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 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.
# 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.

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). 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).
Square follows the same rule. The same key with a changed request returns an error, but the behavior can differ per API (Square).
MDN describes one more common shape of these errors on its Idempotency-Key page (MDN). 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. 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.
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".
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 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 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 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).
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 shows how to connect a client to this endpoint step by step.

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).
The Claude Code MCP docs have a section on how to mark tools that are safe to call twice (Claude Code MCP docs). 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 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).
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). 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.
{
"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). The example in the docs sets max_retry_attempts to 3.

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). 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.
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 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.
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 (71 calls), threads/comments (41), youtube/transcript (19), threads/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 (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.
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 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.
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.
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). The Microsoft retry pattern also says that the receiver of a resent message must be an idempotent consumer (Azure retry pattern).
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 200One 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). 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).
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.
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 restartSome 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.
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). 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). Our article on Claude Code subagents for TikTok brand 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, Square, AWS EC2, Google AIP-155 (a UUID request_id field, used mainly for idempotency) and the IETF draft. "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 uses idempotency-key with up to 64 characters. PayPal 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). 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). 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).
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, the IETF datatracker, the Agents SDK releases and PyPI.
| 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, 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), 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). 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). 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 and the X (Twitter) data 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, and per-call prices are on the pricing page. The list of capabilities is in the data market.
References
- EveryInfra full developer notes (Retries and idempotency, Errors, Webhooks), EveryInfra, read 2026-11-09, https://api.everyinfra.com/llms-full.txt
- EveryInfra OpenAPI document, EveryInfra, read 2026-11-09, https://api.everyinfra.com/openapi.json
- EveryInfra data capability catalog, EveryInfra, read 2026-11-09, https://api.everyinfra.com/api/v1/social/catalog
- 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/
- EveryInfra MCP endpoint tools/list, EveryInfra, read 2026-11-09, https://api.everyinfra.com/mcp
- Connect Claude Code to tools via MCP, Anthropic, read 2026-11-09, https://code.claude.com/docs/en/mcp
- Claude Code environment variables, Anthropic, read 2026-11-09, https://code.claude.com/docs/en/env-vars
- Claude Code error reference, Anthropic, read 2026-11-09, https://code.claude.com/docs/en/errors
- 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
- Claude Code CHANGELOG.md, Anthropic (GitHub), read 2026-11-09, https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md
- Model context protocol (MCP), OpenAI Agents SDK docs, read 2026-11-09, https://openai.github.io/openai-agents-python/mcp/
- Models (runner-managed retries, model-call timeouts), OpenAI Agents SDK docs, read 2026-11-09, https://openai.github.io/openai-agents-python/models/
- openai-agents-python releases (v0.24.0-alpha.7), OpenAI (GitHub), read 2026-11-09, https://github.com/openai/openai-agents-python/releases
- openai-agents, PyPI, read 2026-11-09, https://pypi.org/project/openai-agents/
- MCP Specification 2026-07-28: Tools, Model Context Protocol, read 2026-11-09, https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- MCP Specification 2026-07-28: Schema (ToolAnnotations), Model Context Protocol, read 2026-11-09, https://modelcontextprotocol.io/specification/2026-07-28/schema
- MCP Specification 2026-07-28: Cancellation, Model Context Protocol, read 2026-11-09, https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation
- 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/
- Idempotency-Key header, MDN Web Docs, read 2026-11-09, https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Idempotency-Key
- RFC 9110 HTTP Semantics (Idempotent Methods), IETF, June 2022, https://www.rfc-editor.org/rfc/rfc9110#name-idempotent-methods
- RFC 9457 Problem Details for HTTP APIs, IETF, July 2023, https://www.rfc-editor.org/info/rfc9457/
- RFC 9562 Universally Unique IDentifiers (UUIDs), IETF, May 2024, https://www.rfc-editor.org/info/rfc9562/
- Idempotent requests, Stripe, read 2026-11-09, https://docs.stripe.com/api/idempotent_requests
- Receive Stripe events in your webhook endpoint, Stripe, read 2026-11-09, https://docs.stripe.com/webhooks
- API idempotency, Adyen, read 2026-11-09, https://docs.adyen.com/development-resources/api-idempotency
- Idempotency, PayPal Developer, read 2026-11-09, https://developer.paypal.com/api/rest/reference/idempotency
- Idempotency, Square Developer, read 2026-11-09, https://developer.squareup.com/docs/build-basics/common-api-patterns/idempotency
- Ensuring idempotency in Amazon EC2 API requests, AWS, read 2026-11-09, https://docs.aws.amazon.com/ec2/latest/devguide/ec2-api-idempotency.html
- Retry behavior, AWS SDKs and Tools, read 2026-11-09, https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html
- 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/
- Retry pattern, Microsoft Azure Architecture Center, read 2026-11-09, https://learn.microsoft.com/en-us/azure/architecture/patterns/retry
- Retry strategy, Google Cloud Storage, read 2026-11-09, https://docs.cloud.google.com/storage/docs/retry-strategy
- AIP-155 Request identification, Google, read 2026-11-09, https://google.aip.dev/155
- Timeouts, HTTPX, read 2026-11-09, https://www.python-httpx.org/advanced/timeouts/
- What is a Temporal Retry Policy?, Temporal, read 2026-11-09, https://docs.temporal.io/encyclopedia/retry-policies
- Graph API overview (RetryPolicy), LangChain, read 2026-11-09, https://docs.langchain.com/oss/python/langgraph/graph-api
- openai-python README (Retries), OpenAI (GitHub), read 2026-11-09, https://github.com/openai/openai-python
- Error codes, OpenAI API, read 2026-11-09, https://developers.openai.com/api/docs/guides/error-codes
- 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/
- 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/
- 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/
- 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/
Reference BL-115