idemkey
Drop-in idempotency keys for agent tool calls: dedup, safe retry, and partial-success verification. Zero dependencies, SQLite-backed.
The problem: every agent failure mode in 2026 was an idempotency problem — the order-capture agent retried with a fresh UUID each attempt (duplicate orders), the webhook retried (inventory decremented twice), the triage agent filed six duplicate tickets by morning. An agent can't tell "the call never ran" from "the call ran but the response was lost," so it re-executes a non-idempotent write.
The fix: wrap the tool. The agent passes a stable key per logical operation; retries reuse the key; the wrapper deduplicates and replays the stored result instead of re-executing.
from idemkey import guard, new_key
def capture_order(order_id: str) -> dict: ... # your real tool
capture = guard(capture_order, verify=order_exists_in_db)
key = new_key() # or "order-7f3a" — the agent owns the key
capture("ord-123", idemkey=key) # executes
capture("ord-123", idemkey=key) # timeout retry -> replays stored result, never re-charges
Install
pip install idemkey # not yet published — v1 is local-only
No dependencies. Python 3.9+.
Usage
The one rule
No key → no retry, no dedup. A wrapped call without a key executes exactly once and is never retried. Retries and dedup happen only when a key is present. This is deliberate: silently retrying a keyless write is how duplicate orders happen.
Three ways to supply the key:
# 1. Per-call kwarg (recommended — the agent owns the key lifecycle)
capture(order, idemkey="order-7f3a")
# 2. Fixed key at wrap time (rare; every call shares it)
guard(fn, key="singleton-op")
# 3. Derived key, opt-in (only for by-construction-idempotent operations
# like ensure_webhook_registered — NEVER for transfer()-style calls an
# agent may legitimately invoke twice with identical args)
guard(fn, derive=True, scope="webhooks")
The kwarg name defaults to idemkey and is popped before your function is
called (configurable via key_arg=).
Safe retry
Only exceptions you classify as retryable are retried — default
(TimeoutError, RetryableError), exponential backoff with full jitter,
3 retries. A returned result is never retried: after a result comes back
you can't know whether the write landed, so it's verified and committed
instead. Non-retryable exceptions propagate immediately with no second
execution, and the key is marked failed so a later retry with the same key
re-executes cleanly.
from idemkey import RetryableError
@guard(max_retries=5, retry_on=(TimeoutError, RetryableError))
def flaky_tool(...): ...
Partial-success verification
The verify hook runs after the write and before success is reported —
this catches "response lost but write landed":
capture = guard(raw_capture, verify=lambda result: order_exists(result["id"]))
# verify returns False (or raises) -> VerificationError, key marked failed.
# NOTE: the write may still have landed; outcome is "unknown", not "failed".
Same key, different args → loud error
The key locks to a fingerprint of (tool, args) on first use. Reusing a key
with different arguments raises FingerprintMismatchError — that's two
different operations sharing a key, or arg drift between retries, and
silently replaying call A's result for call B is worse than crashing.
Override with fingerprint_policy="warn" or "replay" if you mean it.
MCP adapter
MCP tools are plain functions, so guard already covers them — mcp_tool
just attaches the tool metadata in one step:
from mcp.server.fastmcp import FastMCP
from idemkey import mcp_tool
mcp = FastMCP("orders")
@mcp.tool()
@mcp_tool(verify=order_exists)
def capture_order(order_id: str) -> dict: ...
# LangChain / CrewAI: guard() returns a plain callable, so
# langchain_core.tools.tool(guard(fn, ...)) composes directly.
CLI
idemkey log --status failed --limit 20 # recent entries
idemkey show order-7f3a # full row incl. replayed result
idemkey stats # counts by status
idemkey purge --older-than 24 # delete entries older than 24h (default TTL)
Store location: --store PATH, or IDEMKEY_STORE env, or
~/.idemkey/store.db. (python -m idemkey works too.)
API reference
| Name | Kind |
|---|---|
guard(fn, **opts) |
decorator / wrapper factory (also @guard, @guard(...)) |
mcp_tool(fn, *, name, description, **guard_opts) |
MCP-tool wrapper |
new_key(prefix="idem_") |
random key |
derive_key(tool, args, kwargs, scope) |
deterministic key (opt-in) |
DedupStore(path) |
SQLite store (WAL, thread-safe) |
| Errors | IdemKeyError, RetryableError, FingerprintMismatchError, VerificationError, ResultNotReplayableError, PreviousAttemptFailedError, InflightTimeoutError, StoreCorruptedError |
Key guard() options: key, key_arg="idemkey", tool, derive=False,
scope="default", store, max_retries=3, backoff_base=0.5,
backoff_cap=30.0, retry_on=(TimeoutError, RetryableError),
verify=None, fingerprint_policy="error", claim_ttl=300.0,
inflight_timeout=60.0, result_max_bytes=1_000_000.
Limitations (honest)
- At-least-once execution, exactly-once effect. Nothing makes a network call truly exactly-once. The store turns the second execution into a replay; if the process dies between your write and the store commit, the next retry re-executes. Design your tools so re-execution after a failed key is safe.
- Wall-clock TTL. Stale-claim reclamation uses wall-clock heartbeats. An
NTP jump forward can prematurely stale a live claim. Set
claim_ttlabove your max expected execution time; the wrapper heartbeats during execution to cover the normal case. - Single-file SQLite. No cross-host dedup without a shared filesystem, and SQLite WAL on NFS is unsupported — keep the store on local disk.
verifycan't un-land a write. A false/lying verify hook reports success for a write that never landed. Make verify a real read-back check, keep it fast and bounded.- Replay returns the original result, even if the world changed since
(Stripe parity). Results go through a JSON round-trip: tuples come back as
lists, arbitrary objects as
repr()strings, payloads overresult_max_bytesaren't replayable (ResultNotReplayableErroron dup). - Derived keys dedup across processes sharing a store when (scope, tool, args) collide — by design; scope accordingly, and never derive keys for intentionally-repeatable operations.
- Keys are bearer tokens for replay. Anyone who can guess a key gets the
stored result. The store file holds results in plaintext — idemkey creates
the file
0600(and directories it creates0700) automatically, but an operator can still loosen that afterwards, so treat the store as sensitive. - Fork-safe. A
DedupStoreopened beforeos.fork()reconnects transparently in the child — SQLite connections are never shared across a fork. Threads share the one connection behind a lock, as before. - No async API in v1. Wrap sync callables;
asynciosupport is on the roadmap. - Fingerprinting is of serialized arguments. Objects with identical
repr()share a fingerprint; keyword spelling is canonicalised via the function signature, but exotic callables without introspectable signatures fall back to raw args.
Metadata
Release files for idemkey 0.1.1
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 | |
|---|---|---|---|---|
| idemkey-0.1.1.tar.gz | 24.7 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| idemkey-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.1 kB
Release files / idemkey-0.1.1.tar.gz
| Download URL | idemkey-0.1.1.tar.gz |
|---|---|
| Size | 24.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
17dae0333792ec129f81a365aa33d6ef9ff17f8f7202af0362ac9d403799f3f5
|
|
BLAKE2b-256 checksum How to use checksums |
caf528c171fdf41db0405a5ab8f2db4eaf4a4edd0abe7685afb4e33855e0ae56
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Release files / idemkey-0.1.1-py3-none-any.whl
| Download URL | idemkey-0.1.1-py3-none-any.whl |
|---|---|
| Size | 18.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
375053801218f596fbe26dab2e6844cc1a9e005e0342de15c8c00c5e3935c122
|
|
BLAKE2b-256 checksum How to use checksums |
fe6611d7b91a18fd2381c5d76e040eb1b258df44e8652204da97564e5078b7f8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Security for idemkey 0.1.1
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
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.