How idempotent retries work, and why a replay costs 0 credits

The Onekey team4 min read

A timeout does not tell you whether you were billed. The Idempotency-Key header does. What the server stores, every outcome, and a retry loop you can run.

You send a billed request. The connection drops. Did the server get it? Did it run? Did it charge you? From the client there is no way to tell, and the two obvious choices are both bad: do not retry and you lose the answer, retry and you may pay twice.

An idempotency key removes the dilemma. This post walks through what Onekey does with the Idempotency-Key header, using real calls made on 2026-09-30, and then gives you a retry loop that uses it.

The contract#

Send a UUID in the Idempotency-Key header on any billed data call. The rules:

  1. The key is claimed before anything is billed, and is scoped to your account.
  2. If the request completes with 200, the response is stored under that key for 24 hours.
  3. The same request with the same key returns the stored response with credits_charged: 0.
  4. A request that fails (any status other than 200) releases the key, so the retry runs for real.

Here is the first call. The balance was 503.658 credits before it:

shell
KEY=$(python -c "import uuid; print(uuid.uuid4())")

curl -si "https://api.onekey.dev/v1/github/repo?owner=torvalds&repo=linux" \
  -H "x-api-key: $ONEKEY_KEY" -H "Idempotency-Key: $KEY" | grep -iE "^HTTP|x-cache|x-credits-remaining|x-idempotent"
# HTTP/1.1 200 OK
# x-cache: MISS
# x-credits-remaining: 502.658

It cost 1 credit. (If someone had fetched that repository a moment earlier, the first call would itself be a cache hit and free; the replay behaves the same either way.) Now the identical request, same key:

shell
curl -si "https://api.onekey.dev/v1/github/repo?owner=torvalds&repo=linux" \
  -H "x-api-key: $ONEKEY_KEY" -H "Idempotency-Key: $KEY" | grep -iE "^HTTP|x-cache|x-credits-remaining|x-idempotent"
# HTTP/1.1 200 OK
# x-cache: HIT
# x-idempotent-replay: true

No x-credits-remaining, because nothing was charged so the balance was not re-read. In the body, the replay is marked:

json
"metadata": {
  "platform": "github", "endpoint": "repo",
  "cached": false, "age_s": 0.008, "fetched_at": "2026-09-30T06:37:50.556181+00:00",
  "credits_charged": 0, "request_id": "8221d10754114f4c9516a47768209226",
  "idempotent_replay": true
}

Two details matter. credits_charged is 0 and idempotent_replay is true. And fetched_at and age_s are the original call's, so they describe the answer you first got, not the moment of the replay. The request_id is new, so a replay can be traced on its own.

Every outcome#

SituationAnswerCharged
First call succeeds200, stored for 24 hoursThe endpoint price (or free if it was a cache hit)
Same request, same key200 replay, credits_charged: 0Nothing
First call still running409 IDEMPOTENCY_IN_PROGRESS with Retry-After: 1Nothing
Different request, same key422 IDEMPOTENCY_KEY_REUSEDNothing
Key is not a UUID400 IDEMPOTENCY_KEY_INVALIDNothing
First call failedThe failure, and the key is releasedNothing

The two rows people ask about most:

Reuse is refused, not guessed at. I sent the same key with repo=other instead of repo=linux. The answer was 422, with error.type IDEMPOTENCY_KEY_REUSED. The server compares the request, so a key can never make a different call return the wrong stored body.

A malformed key is refused too. Idempotency-Key: not-a-uuid returns 400 IDEMPOTENCY_KEY_INVALID. Neither costs anything.

A few edge cases worth designing around:

  • Responses over 64 KB are returned normally the first time but not stored, so a replay answers 409 IDEMPOTENCY_REPLAY_UNAVAILABLE rather than silently billing again. Keep large list results on your side.
  • The key is claimed atomically. Two identical requests sent at the same instant cannot both be billed: one runs and the other gets 409 IDEMPOTENCY_IN_PROGRESS.
  • If the store cannot be reached, you get 503 IDEMPOTENCY_STORE_UNAVAILABLE, refused before billing. Refusing is safer than risking a double charge.
  • A request that dies midway holds its key for at most 5 minutes.
  • A replay still counts against your rate limit.

A retry loop that uses it#

The one rule for clients: generate the key once per logical request, and reuse it across every retry of that request. A new key per attempt defeats the whole thing.

import os, time, uuid, requests

API = "https://api.onekey.dev"

def get_with_retry(path, params, tries=4):
    key = str(uuid.uuid4())              # once, outside the loop
    for attempt in range(tries):
        try:
            r = requests.get(API + path, params=params, timeout=30,
                             headers={"x-api-key": os.environ["ONEKEY_KEY"], "Idempotency-Key": key})
        except requests.RequestException:
            time.sleep(2 ** attempt)     # dropped connection: same key, try again
            continue
        if r.status_code in (409, 429, 503):
            time.sleep(int(r.headers.get("Retry-After", 1)))
            continue
        if r.status_code in (500, 502):
            time.sleep(2 ** attempt)
            continue
        return r.json()                  # 200, or an error that retrying will not fix
    raise RuntimeError("gave up")

body = get_with_retry("/v1/github/repo", {"owner": "torvalds", "repo": "linux"})
print(body["metadata"]["credits_charged"], body["metadata"].get("idempotent_replay"))

Read it as three cases. A dropped connection or a 5xx means "I do not know", so retry with the same key. A 409, 429 or 503 carries a Retry-After, so wait exactly that long. Everything else, including 404 and 422, will fail the same way again, so return it. If the first attempt did succeed, the retry is a free replay. If it did not, the key was released and the retry runs for real.

What it costs#

Nothing extra. The first success costs the endpoint price as usual; every replay within 24 hours is free; the 409, 422, 400 and 503 answers are never charged. The same header works on batch requests, where the key is bound to the exact body bytes, so reformatting the JSON makes it a different request.

The reference is the idempotency page. If you retry on rate limits, read rate limits and headers too. The terms are in the glossary: idempotency key and idempotent replay.