idemkit: make any Python operation safe to retry (HTTP, queues, functions)
The Problem
A few years ago, I spent about a month cleaning up duplicate charges. Every service involved already had idempotency "handled": the client sends an Idempotency-Key header, we store the response under it, and if the key comes back we replay the stored response. Textbook. And it still double-charged people.
What finally clicked for me is that the key is just a name for the request. It doesn't do anything on its own. Whatever safety you have comes from the code around it, and that code is a small distributed state machine that's easy to get wrong.
Failure Modes
A few of the ways it went wrong for us:
- Two requests with the same key arrive a millisecond apart. Both read "no record yet," both write one, both run. A check-then-act race. The fix is to make the write itself the check, one atomic insert, so exactly one wins.
- A worker charges the card, then dies before it records "done." The key is stuck, and depending on how you wrote it, retries either hang forever or someone clears the key and you charge again. You need a lease that expires, and a way to reject the dead worker's late write (a fencing token). Otherwise crash recovery quietly turns into double execution.
- Someone reuses one key for a $200 request and then a $500 one. If you key only on the token, you cheerfully hand back the $200 response. You have to fingerprint the request and reject the mismatch instead of replaying the wrong thing.
None of these are exotic. They're the normal failure modes, and I kept re-solving them, slightly differently and slightly wrong, in every service.
The Solution: idemkit
So I pulled it into one library, idemkit. The whole thing is one idea: call an operation twice, it runs once, and the duplicates get the first result back.
The core is the part worth caring about: an atomic claim, a lease that expires on the storage clock, and a fencing token so a late write from a dead worker gets rejected. The same core sits behind whichever surface you use: middleware for FastAPI/Flask/Django, a queue consumer, or an @idempotent decorator on a function. An HTTP retry, a redelivered message, and an agent re-running a tool call all go through one code path.
Backends and Testing
Backends are in-memory, Redis, Postgres, Mongo or DynamoDB, and every guarantee has a test that runs against the real servers, because a fake doesn't reproduce the races.
Limitations
Where it stops: you get effectively-once, not exactly-once. There's no such thing as exactly-once delivery in a distributed system, so I don't pretend there is. idemkit keeps the record correct and replays it to every duplicate, but it can't undo a side effect that already happened. If a stalled worker fires the charge and only then loses its lease, idemkit rejects its write, but the money has already moved. The README says exactly where that can bite, and on anything involving money you still pass the key downstream so the next hop dedupes too.
Install and Links
pip install idemkit
- Repo: https://github.com/idemkit/idemkit
- Docs: https://github.com/idemkit/idemkit/blob/main/python/README.md
- The longer version of the argument, on InfoWorld: https://www.infoworld.com/article/4191741/why-an-idempotency-key-isnt-an-idempotency-guarantee.html
It's early (v0.1, Apache-2.0). If you're wiring up idempotency right now, give idemkit a shot and tell me what's missing for your case.
Comments
No comments yet. Start the discussion.