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).
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file idempotent_tools-0.1.1.tar.gz.
File metadata
- Download URL: idempotent_tools-0.1.1.tar.gz
- Upload date:
- Size: 16.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a4813fb487722fdcf240067e419a60f76e1b20bdaceadd3383b3f262a968d957
|
|
| MD5 |
c03cbdfc3f03f04755c66ce5e3a71eb5
|
|
| BLAKE2b-256 |
005e0661e79d92695a40548a699151c0a1b9c8dca1258e75a352068f60436af2
|
File details
Details for the file idempotent_tools-0.1.1-py3-none-any.whl.
File metadata
- Download URL: idempotent_tools-0.1.1-py3-none-any.whl
- Upload date:
- Size: 15.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e14246a7376169fdb302deb8fa305584366d8e38b70a9a18ce11c3ac702e2b4d
|
|
| MD5 |
3d5b8e7cca3bb17d74ac6abb10febb93
|
|
| BLAKE2b-256 |
5a4b0a962dc9a6d87a9aafcffd4ee79231ce125ed4384b96641e95d708051451
|