idemcheck
Find the HTTP handlers that will double-charge when a client retries.
A client retries because the network lied. If your handler writes before it can answer, the retry writes again. Idempotency libraries exist for every framework, and nothing tells you which of your routes is missing the guard.
$ idemcheck examples/
idemcheck: 1 finding(s) across 2 mutating route(s) in 1 file(s), worst high
[HIGH] examples/app.py:12 (missing-idempotency)
route: POST /charge
why: charge with no idempotency key, dedupe store or unique constraint
calls: db.orders.insert_one, db.session.commit
fix: read an Idempotency-Key and store it before the first write, or make the write
itself unique so a second delivery cannot land twice
The example file contains two POST handlers: /charge writes and is flagged, /charge-safe
reads an Idempotency-Key first and is not.
Install
pipx install idemcheck # once it is on PyPI
# or straight from the repo:
pipx install git+https://github.com/mgkvibez/idemcheck
Python 3.9 or newer. No runtime dependencies.
Usage
idemcheck # scan the current directory
idemcheck src/ app.py # scan specific paths
idemcheck --json | jq . # machine-readable
idemcheck --fail-on medium # stricter gate, for a payments service
idemcheck --include-tests # include tests/ (skipped by default)
Exit codes: 0 nothing at or above the threshold, 1 findings, 2 usage error.
--fail-on defaults to high, so a PUT that writes without a guard reports and exits 0
until you ask otherwise.
The runtime: stop the second charge, not just report it
The linter tells you which handlers are undefended. idemcheck.runtime defends them.
from idemcheck.runtime import SQLiteStore, idempotent
store = SQLiteStore("idempotency.db")
@idempotent(store=store)
def charge_card(amount, idempotency_key=None):
gateway.charge(amount) # runs once per key, ever
return {"charged": amount}
$ python examples/runtime_demo.py
first: {'charged': 2500, 'key': 'order-41'}
retry: {'charged': 2500, 'key': 'order-41'}
charges that reached the gateway: []
Four outcomes, and they are genuinely different:
| Situation | What happens |
|---|---|
| the key is new | the function runs, and the result is stored |
| the key completed already | the stored result is returned, nothing runs |
| the key is in flight | IdempotencyConflict, rather than a second charge |
| the key came back with a different payload | IdempotencyMismatch, because a key identifies one operation |
A failure is never remembered: if the function raises, the key is released so the retry can work. Caching an exception would make a transient gateway timeout permanent.
Two stores ship: MemoryStore for tests and single workers, SQLiteStore for anything that
has to survive a restart, shared by every worker that opens the same file. The claim is one
atomic insert, so two workers racing on one key produce one winner and one conflict, which
tests/test_runtime.py proves with two real threads. Async handlers work the same way, and
current_idempotency_key() gives you the key of the call in progress for logs and audit.
on_conflict="raise" is the default. "wait" blocks for the in-flight call and replays it;
"run" lets the work through, which is only right when repeating it is already harmless.
What it looks for
A handler is a finding when it answers a mutating method and calls something that writes and nothing in it mentions a guard.
| Severity | When |
|---|---|
| high | POST, or any outbound money/HTTP send, with unguarded writes |
| medium | PUT/PATCH/DELETE with unguarded writes (they should already be idempotent) |
Writes are recognised by method name: commit, flush, insert*, update*, delete*,
save, upsert, execute, add, plus money movement like charge, capture,
transfer, refund, and queue sends like delay and enqueue.
A guard is recognised by any of: idempotency, idempotent, idem_key, request_id,
dedupe, dedup_key, nonce, client_reference, get_or_create, update_or_create,
on_conflict, unique_together.
Routes are read from the decorator: @app.post, @router.put, and the
methods=[...] keyword on @app.route and @app.api_route (Flask and FastAPI).
What this deliberately does not do
Static analysis cannot prove a handler is safe, and this tool does not claim to.
- A guard installed by a decorator defined in another module is invisible here.
- An ORM that commits on your behalf, or a write hidden inside a helper function, is invisible here.
- Django class-based views, generic routers and dynamically constructed handlers are not detected at all.
- A handler that mentions
request_idincidentally is treated as guarded, even when it is not. That is a false negative, and it is the direction this tool errs on purpose: a checker that cries wolf gets uninstalled after one afternoon.
The clean line is exactly what it says: no unguarded write was recognised.
Tests
python -m pytest -q # 33 tests
bash scripts/verify.sh # the release gate
Contributing
Adding a framework means teaching the decorator reader one more name in analyze.py, and
adding a fixture that proves both the flagged and the guarded case. A rule that cannot show
both is not ready.
License
MIT.
Metadata
Release files for idemcheck 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| idemcheck-0.2.0.tar.gz | 17.9 kB | With provenance | Details for idemcheck-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| idemcheck-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for idemcheck-0.2.0-py3-none-any.whl |
Total release size: 32.1 kB
Release files / idemcheck-0.2.0.tar.gz
| Download URL | idemcheck-0.2.0.tar.gz |
|---|---|
| Size | 17.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
799335db01b5c3523b0cf151ae6bae1114bdd60b3ca1c8ea1531c53a0dc06a8f
|
|
BLAKE2b-256 checksum How to use checksums |
4c6a8d6ace42008115051dd105597a086c9578edfdfe48e316fea76fbc2e7225
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logRelease files / idemcheck-0.2.0-py3-none-any.whl
| Download URL | idemcheck-0.2.0-py3-none-any.whl |
|---|---|
| Size | 14.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c283e363c0b9d07450c100a8114af1582aa4c686abb70c68edd58f6467202baa
|
|
BLAKE2b-256 checksum How to use checksums |
d947891e6d60200c0cdbcc1996a581048bdb250c4e4000e102a9dddcd9892cae
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logSecurity for idemcheck 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for idemcheck-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.