Derive a Sume idempotency key from tenant, order, Format slug, version

Hash stable ids, not a random UUID, so a retry reuses the key. A failed run needs a new key, and a changed body with the same key returns 409.

5 min readSume
All posts

Build the key from the ids that make a run unique in your business: the tenant, the order, the Format slug and its version. Join them with a separator and hash or keep them readable. The key must be the same on every retry of the same request, so it must not contain a random value or a timestamp. A UUID created inside the retry loop gives each attempt a new key, and that defeats the protection, because every attempt then starts a new paid run.

Think of the key as a statement. It says: this tenant wants this Format, at this version, for this order, and that is exactly one paid run. Anything that does not change the meaning of that statement belongs outside the key, and anything that does belongs inside it.

What the server does with the key

The cookbook for embedding a Format recommends this approach. With the same key and the same body, Sume returns 200 with the original receipt and idempotency_hit: true, and a fresh run returns 202. Your code can treat both as success, and it can tell them apart by the status code or by the flag.

Build the key

The function below builds the key and a body fingerprint. It runs as is. Keep the readable form in your logs, because it makes a duplicate easy to find, and keep the key short enough to be pleasant in a URL or a header.

The attempt number is added only for a deliberate retry, such as after a failed run. The first request and all of its network retries share the key without a suffix. Store the current attempt next to the order, so the next process knows which key is the live one.

import hashlib, json

def run_key(tenant: str, order: str, format_slug: str, version: str, attempt: int = 1) -> str:
    parts = [tenant, order, format_slug, version]
    if attempt > 1:
        parts.append(f"attempt-{attempt}")
    return ":".join(parts)

def body_fingerprint(body: dict) -> str:
    raw = json.dumps(body, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(raw.encode()).hexdigest()[:12]

first = run_key("acme", "order-77", "product-promo", "v3")
retry_same_request = run_key("acme", "order-77", "product-promo", "v3")
after_failure = run_key("acme", "order-77", "product-promo", "v3", attempt=2)
print(first == retry_same_request, first == after_failure)
print(body_fingerprint({"input": {"product_url": "https://example.com/p/1"}}))

Reuse or change the key

Three cases decide whether you reuse the key or change it.

Keep the delimiter out of your ids, or escape it. A tenant named a:b and an order named c would otherwise collide with a tenant a and an order b:c. A hash of the joined parts avoids the problem, and the readable form can stay in a log line.

  • A network error, a timeout or a 5xx, where you do not know whether the run started. Reuse the key and the same body. You get the original receipt if the run exists.
  • A run that ended as failed. Use a new key, for example with an attempt number. The docs say a retry of a failed run needs a new key, because the old key would only hand back the failed receipt.
  • A change to the body, such as a new product URL or a new instruction. Use a new key. The same key with a different body is a 409 idempotency_conflict, and the fix is in your key derivation, not a retry.

Header, body and bulk queues

The header form and the body form both exist. The Idempotency-Key header is the usual choice, and a body field idempotency_key is accepted for the same purpose. If you send both, the header wins, so do not send two different values and hope the body one is used. For a bulk Format queue, mint a fresh key per batch, because replaying a spent key returns 202 with the old queue and starts nothing new.

If a run came back as failed because it hit its spend cap, a new key alone gives the same result with the same cap. Raise the generation_spend_cap_usd in the new request on purpose, up to the platform maximum of $500, and send it with the new key.

Save the key before the call

Write the key to your database before you call Sume, and commit it. If the process dies between the call and the response, the next process finds the row, reuses the key and gets the original receipt. Without that row, a restart derives the key again only if your derivation is stable, and that is exactly why a derivation from ids beats a stored random value.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume