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 -e .
# or, with Redis backend support:
pip install -e ".[redis]"

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

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

idempotent_tools-0.1.0.tar.gz (15.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

idempotent_tools-0.1.0-py3-none-any.whl (15.0 kB view details)

Uploaded Python 3

File details

Details for the file idempotent_tools-0.1.0.tar.gz.

File metadata

  • Download URL: idempotent_tools-0.1.0.tar.gz
  • Upload date:
  • Size: 15.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.6

File hashes

Hashes for idempotent_tools-0.1.0.tar.gz
Algorithm Hash digest
SHA256 65da45d984571438749fab7aba29d5b85d1529787a50dd861710c1b2c588899a
MD5 3fb747effbfc5d3b4847dee15030b793
BLAKE2b-256 bdf7a8ca799ac611637514524526607d6c5db27e009b44ca861f6a217855fb10

See more details on using hashes here.

File details

Details for the file idempotent_tools-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for idempotent_tools-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e286fa31269c10a528e2a18005ce688e944e699c527f599872638117ba33078c
MD5 8e521a1013a0ffc242de9df8313eb425
BLAKE2b-256 d76ccb9527152f0bd96202d8805e0fa4befc130cae3d44972587a3406743fb97

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page