Skip to main content

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_id incidentally 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)

Source distribution for idemcheck 0.2.0
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)

Table of built distributions (wheels) for idemcheck 0.2.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

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

Select from 2 files

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 log

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

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page