idempotent-tools
Idempotency-key decorator for agent tool calls — local-only, zero-config, no network call in the hot path.
Agent frameworks (LangGraph, CrewAI, LangChain) retry or resume tool
invocations after an interrupt, checkpoint replay, or failure. If the tool
has a side effect (charging a card, sending an email, placing a trade,
writing to an external API), that retry can fire it twice. There's no
packaged library that owns this problem today — teams are hand-rolling the
same "check a local store before running" decorator repeatedly (see e.g.
crewAI issue #5802 and multiple independent write-ups of the same DIY
pattern). idempotent-tools packages that pattern: a decorator that dedupes
calls against a small local store, so a retried/resumed call returns the
prior result instead of re-executing.
Some competing solutions in this space are cloud-hybrid products — a hosted API for the dedup check, with a metered/paid production tier. This library is not that: it's a single decorator backed by SQLite (or Redis if you already run one), MIT-licensed, with no account, token, or outbound network call required to use it. That also means it doesn't do everything a hosted service can (see Out of scope below) — it trades distributed cross-worker locking for simplicity and zero external dependency.
Install
pip install idempotent-tools
# or, with Redis backend support:
pip install "idempotent-tools[redis]"
For local development (editable install from a clone):
pip install -e .
Requires Python >= 3.9. No required dependencies for the default (SQLite) backend.
Quick start
from idempotent_tools import idempotent
@idempotent
def charge_card(order_id: str, amount: float) -> dict:
... # side-effecting call
charge_card("order-42", 19.99) # runs
charge_card("order-42", 19.99) # returns the cached result, does not re-run
By default the idempotency key is a SHA-256 hash of the function name plus
its JSON-serialized args/kwargs, stored in a zero-config SQLite file at
~/.idempotent_tools/store.db. You can also pass an explicit key at call
time:
charge_card("order-42", 19.99, idempotency_key="charge-order-42")
or derive one from framework context (thread id, task id, step, etc.) via
context= or key_fn= — see the integration examples below.
Execution semantics
Every call, the decorator looks up its key and does one of:
- Not seen (or a stored record has expired past
ttl) -> function runs, result is stored ascompleted. - Seen, status
completed-> the stored JSON result is returned; the function does not run. - Seen, status
pending(another call with the same key is currently in flight) -> behavior is controlled byon_duplicate:"raise"(default) -> raisesDuplicateInFlightErrorimmediately."block"-> polls (poll_interval, default 0.2s) until the in-flight call resolves, then returns its result. If it fails or times out (poll_timeout, default 30s -> raisesTimeoutError), falls through and re-runs."retry"-> runs the function again immediately, without waiting, overwriting the stored result when it finishes.
- Seen, status
failed(the previous attempt raised) -> treated as not-seen; the function runs again.
ttl (seconds, default None = never expires) makes a record older than
the TTL treated as not-seen on the next call, regardless of its status.
Non-JSON-serializable arguments raise TypeError at call time when no
idempotency_key= or key_fn= is supplied (there's no fallback
serialization — see "Out of scope" below).
Storage backends
Both backends implement the same interface (get, acquire, complete,
fail, list_records, clear) defined in
idempotent_tools.backends.base.Backend, so you can pass either as
backend= to @idempotent.
SQLiteBackend(default): file-based, zero config, thread-safe (guarded by an internal lock; stress-tested under concurrent threads). Defaults to~/.idempotent_tools/store.db; passSQLiteBackend(db_path=...)to use a different file.RedisBackend(optional, only imported ifredisis installed):pip install idempotent-tools[redis], thenRedisBackend(url="redis://localhost:6379/0"). Note: itsacquireis a read-then-write check, not a single atomic Redis operation — fine for single-process use, but it does not provide true cross-process/cross-worker locking (see "Out of scope").
Framework integration
idempotent_tools.integrations has two thin helper modules for deriving a
context= value from framework state — they don't wrap the frameworks
themselves, you still apply @idempotent directly to your tool function.
LangGraph
from langchain_core.tools import tool
from idempotent_tools import idempotent
from idempotent_tools.integrations.langgraph_shim import thread_step_context
@tool
@idempotent(context=lambda: thread_step_context(current_config(), step=current_step()))
def send_payment(order_id: str, amount: float) -> dict:
...
current_config() / current_step() are stand-ins for however your graph
node exposes the current RunnableConfig and step number (closure
variables, or LangGraph's own config accessor). Put @idempotent closer
to the plain function than @tool, so it wraps the raw callable before
LangChain adapts the calling convention.
CrewAI
from crewai.tools import tool
from idempotent_tools import idempotent
from idempotent_tools.integrations.crewai_shim import task_context
current_task = None # set by a hook/callback before each tool invocation
@tool("send_email")
@idempotent(context=lambda: task_context(current_task))
def send_email(to: str, subject: str) -> dict:
...
def before_task_attempt(task):
global current_task
current_task = task
Wire before_task_attempt into a CrewAI Task/Agent callback so
current_task reflects the task being (re)attempted. task_context keys
off the task's id/key only (not its retry count), so repeated attempts
of the same task map to the same idempotency key.
CLI
idempotent-tools inspect # list all stored records
idempotent-tools inspect --key K # show one record
idempotent-tools clear # clear all records
idempotent-tools clear --key K # clear one record
idempotent-tools --db path/to.db inspect # point at a specific SQLite file
The CLI only operates on the SQLite backend/file; it has no Redis support.
Manual demo
python demo.py
Runs charge_card twice with the same key against a temp SQLite file and
asserts it only actually executed once.
Tests
pip install -e ".[test]"
pytest
Out of scope for v1
- Distributed cross-worker locking/consensus.
acquire()on both backends is not a single atomic compare-and-swap across processes/hosts; don't rely on it to prevent two independent workers racing on the same key at the exact same instant beyond what SQLite's own locking or Redis's per-command atomicity gives you. - Non-JSON-serializable arguments/results. Auto-keying and result
storage both go through
json.dumps; pass objects, and you'll get aTypeErrortelling you to supplyidempotency_key=orkey_fn=instead. - Dashboard/UI. Inspection is CLI-only (
idempotent-tools inspect).
Metadata
Release files for idempotent-tools 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 | |
|---|---|---|---|
| idempotent_tools-0.1.1.tar.gz | 16.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| idempotent_tools-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 31.3 kB
Release files / idempotent_tools-0.1.1.tar.gz
| Download URL | idempotent_tools-0.1.1.tar.gz |
|---|---|
| Size | 16.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a4813fb487722fdcf240067e419a60f76e1b20bdaceadd3383b3f262a968d957
|
|
BLAKE2b-256 checksum How to use checksums |
005e0661e79d92695a40548a699151c0a1b9c8dca1258e75a352068f60436af2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / idempotent_tools-0.1.1-py3-none-any.whl
| Download URL | idempotent_tools-0.1.1-py3-none-any.whl |
|---|---|
| Size | 15.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e14246a7376169fdb302deb8fa305584366d8e38b70a9a18ce11c3ac702e2b4d
|
|
BLAKE2b-256 checksum How to use checksums |
5a4b0a962dc9a6d87a9aafcffd4ee79231ce125ed4384b96641e95d708051451
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|