Skip to main content

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 as completed.
  • 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 by on_duplicate:
    • "raise" (default) -> raises DuplicateInFlightError immediately.
    • "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 -> raises TimeoutError), 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; pass SQLiteBackend(db_path=...) to use a different file.
  • RedisBackend (optional, only imported if redis is installed): pip install idempotent-tools[redis], then RedisBackend(url="redis://localhost:6379/0"). Note: its acquire is 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 a TypeError telling you to supply idempotency_key= or key_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)

Source distribution for idempotent-tools 0.1.1
File Size Uploaded
idempotent_tools-0.1.1.tar.gz 16.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for idempotent-tools 0.1.1
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

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