Every agent guardrail asks may this happen? This one asks did I already?
One customer, one broken order, one refund. It arrives as two support tickets, gets retried after a lost response, and lands on three workers that share nothing. Every "may I?" check says yes to all of them, and the card is charged three times.
from once_guard import idempotent
@idempotent("charge_id", "cents") # the arguments that ARE the call
def charge(charge_id, cents, note=""):
return stripe.PaymentIntent.create(...)One decorator, one directory, no dependencies, no daemon, no broker, no network. Python 3.8+, POSIX, MIT.
dir= is optional. Without it, the claim directory is resolved at call time:
CLAIM_DIR when set, then $XDG_STATE_HOME/once-guard/claims, otherwise the
durable per-user path ~/.local/state/once-guard/claims. The default directory
is created with mode 0700; any directory that already exists, including one
selected through CLAIM_DIR, keeps its existing mode. Use an explicit dir=
when intentionally coordinating through a shared filesystem.
$ python3 demo.py
once-guard · one $42.00 charge, asked for many times
scenario | off | on
------------------------------------------------+--------------------------------------+----------------------------------------------------------------
40 concurrent processes, identical call | executed 40; replayed 0; in-flight 0 | executed 1 (always); replayed 38 (varies); in-flight 1 (varies)
4 processes, four different note arguments | executed 4; distinct answers 1 | executed 1; replayed 3; distinct answers 1
the same four, with note wrongly inside the key | executed 4 | executed 4 (wrong key)
lost response, client retries | executed 3; same result 3/3 | executed 1; replayed 2; same result 3/3
the claimer dies before finishing | immediate retry: executed 1 | before TTL: InFlight; after TTL (2.0s): executed 1
the side effect outlives its TTL | executed 2 | executed 2
duplicate charges: 44 (off) -> 0 (on) across the four scenarios with a correct key
extra money charged: $1,848.00 -> $0.00
control — key names the wrong argument: 4 -> 4 ($126.00 extra in both columns)
limit — the guard cannot make one execution out of two that both really happened
off calls the identical undecorated function; on applies @idempotent to
it. In the first row, process_calls(..., concurrent=True) starts all 40 OS
processes before joining any of them, so it is a real overlapping process
race. Here replayed means “returned a stored result,” so off shows
replayed 0 even though all 40 bare calls return normally. executed 1 is
fixed; the other 39 callers split by timing between stored-result replay and
InFlight, so both values are explicitly marked varies. A separate
barrier-synchronised expired-claim race lives in
tests/test_expiry_race.py; the regression suite runs it with 16 workers for
40 trials.
Real OS processes, not threads — threads would pass on the GIL alone and prove nothing about what runs in production. The third row shows the configuration failure: put a value the model rewrites into the key and every retry gets a new identity. That wrong-key row is a control and is reported separately from the four correctly keyed treatment scenarios.
The sixth row is a measured limit, not part of the 44 -> 0 headline: after a
real side effect has outlived its TTL, the old owner and its legitimate
replacement have both already executed.
$ python3 -m pytest -q
71 passed
The first claimant creates <sig>.gen0.claim using
os.open(path, os.O_CREAT | os.O_EXCL). O_EXCL is a compare-and-set on file
existence: exactly one process can create that generation. A successful call
creates the matching immutable <sig>.gen0.result the same way.
If the claim expires, reclaim does not remove it. Callers find the highest
existing generation and compete to create <sig>.gen{N+1}.claim with the same
O_EXCL operation.
Result and liveness are separate invariants. For replay, the highest
generation with a terminal result wins; higher claims without a result do not
hide that stored success. For liveness, only the highest claim's age decides
whether a new generation may be created. Thus a late gen0 result cannot
overwrite a completed gen1 result, while a dead, empty gen1 cannot erase a
valid gen0 replay.
Generation lookup probes <sig>.gen0.claim, <sig>.gen1.claim, and so on with
os.stat; it does not scan unrelated keys. The family must therefore remain
contiguous. The library never deletes or replaces a claim or result, has no
directory-wide lock, and has no steal sentinel or five-second recovery
heuristic. Different keys do not share a takeover lock.
Depending on state, a later caller may receive permission to create and run the
next generation, the highest terminal stored result, InFlight for a live
claim or a result inode still inside its 2.0-second write grace period, or a
documented terminal/storage exception. If it becomes the new owner and the
wrapped function raises, that function's exception also propagates unchanged.
If an old owner records its result after a higher claim exists, it receives
SupersededExecution; the result remains on disk for reconciliation, but the
caller is not allowed to mistake its private answer for the current one.
Function identity is fn.__module__ + "." + fn.__qualname__, so same-named
functions in different modules do not share a key family. The holder token is
diagnostic metadata, not authorization; diagnostic messages bound it to 64
printable ASCII characters. Earlier repository revisions used bare
fn.__name__; those hashes are not compatible, so reconcile any live old claim
directory before upgrading instead of silently treating it as current state.
A normally completed signature uses two files: one claim and one result. Each completed TTL takeover adds two more; an unfinished generation adds one. There is no automatic sweeper. Lookup probes that signature's contiguous generations in order and does not scan unrelated files.
-
One host, or a shared filesystem. Two machines with separate disks do not see each other's claims.
O_EXCLon older NFS servers is not atomic; test it before trusting it there. This older-NFS warning is operational guidance and is not tested in this repository. Clock skew is handled conservatively: an exact, non-boolean numericatis used only when it is in the past, and then claim age is the younger of the recorded age and mtime age; otherwise only mtime is used. Editingatalone cannot make a claim old because its mtime caps the age. After reconciling that an owner is dead, manual expiry must move both clocks to the same past instant (os.utimesupplies the second clock):$ python3 -c 'import json,os,sys,time; from pathlib import Path; p=Path(sys.argv[1]); old=time.time()-3600; record=json.loads(p.read_text(encoding="utf-8")); record["at"]=old; p.write_text(json.dumps(record), encoding="utf-8"); os.utime(str(p),(old,old)); print("record age: %ds; mtime age: %ds" % (round(time.time()-record["at"]), round(time.time()-p.stat().st_mtime)))' /path/to/SIGNATURE.gen0.claim record age: 3600s; mtime age: 3600sTaking the younger age avoids stealing a live worker's claim when its clock jumps backward. The cost is that a dead worker's claim can remain locked longer; shared deployments still need trustworthy filesystem mtimes.
-
TTL is a liveness trade, not an exactly-once proof. A worker that claims and then dies blocks until the TTL. Scenario 5 measures this cost:
InFlightbefore the 2.0-second TTL, then one execution after it expires. If a live side effect exceeds its TTL, a newer generation may also execute; scenario 6 measures two executions with the guard both off and on. The external operation has already happened twice. The late owner getsSupersededExecution, but that notification cannot undo either operation. Set TTL above the slowest real side effect. -
The local result marker closes only the tested process-retry path. If result-file creation succeeds but its write or
fsyncfails, the empty or partial result inode isInFlightfor a 2.0-second write grace period; after that it becomes a terminalResultSerializationError, and process retries refuse instead of rearming. The tests cover process retry and process death, not power loss. The result file isfsynced but its parent directory is not, so no directory-entry durability across power loss is claimed here. If the filesystem rejects creation of that marker itself after the external operation has landed (EROFS, exhausted inodes, or someENOSPCfailures), no local record may remain to distinguish success from a dead worker; after TTL a new generation can repeat the operation. Using the provider's own idempotency key for that boundary and manually reconciling ambiguous outcomes are operational recommendations; provider integration is not tested here. -
A function exception keeps the key locked until TTL. An exception does not prove whether the side effect landed, so
release_on_erroris not an API. Immediate automatic retry after exceptions is intentionally unavailable. -
The stored result is your function's return value, not the provider's receipt. The result is JSON, so replay is not type-faithful outside JSON's data model: a Python
tupleis returned to the winner but becomes alistfor a later caller. Return JSON-native values when winner and replay must compare equal. -
A result that JSON cannot serialize is not replayable. The side effect has already completed, so once-guard creates a
result_unavailablerecord and raisesResultSerializationError. Later callers receive the same refusal rather than running the side effect again. -
The key is only as stable as the arguments you name. Key names are checked against the function signature, and positional calls are bound through that signature. Keys use type-tagged canonical encoding:
dictkeys retain their types, andtuplediffers fromlist. Exact built-in JSON scalar/container types plustupleare accepted;IntEnum, scalar subclasses, custom objects,Decimal, and non-finite floats raiseUnstableKeyErrorbefore the side effect. Anything the model rewrites freely — a note, correlation id, or timestamp — must stay out. -
Storage failure is not an in-flight worker.
InFlightmeans a live claim (or the brief result-write grace period).ClaimContentionErroris its retryable subclass for a claim race that did not settle.ClaimStorageErroris a sibling underOnceGuardError, so read-only or unavailable storage is not accidentally retried forever as worker contention. -
Nothing sweeps the claim directory. TTL advances an unfinished call; it does not expire completed results. The retry-horizon and provider-receipt cleanup rules below are unmeasured operational guidance; neither is tested here. The safe cleanup lower bound is the retry horizon: the greatest delay after which the same request can still arrive. Generation files form one signature family (
<sig>.gen*.claimplus<sig>.gen*.result). Removing only a.resultintentionally re-arms that family after TTL; removing an older claim creates a gap that direct probing cannot cross. Do not remove individual files or use genericfind -deleteon live state. Keep every generation contiguous through the retry horizon. Remove the entire reconciled family only when its provider receipt proves that late retries can no longer arrive. Removing the whole family re-arms the side effect — in this demo the next identical request is one extra $42.00 charge.
It came out of answering crewAI #5802 — "Tool re-execution on task retry has no idempotency guard — duplicate payments, emails, trades possible" — which asked for exactly this design and has no maintainer response and no merged fix. Agent frameworks retry; retry without a claim is how one refund becomes two. This small module keeps the mechanism inspectable without claiming the design was absent from that discussion.
Five more layers, each with an included demo and an MIT license:
- llm-guardrails — prompt injection in, secrets out
- mcp-permission-server — permission grants and a tamper-evident audit log
- agent-approval-gate — money and human approval
- agent-reliability-kit — retry, timeout, circuit breaker
- readonly-guard — read-only dry runs, and the six ways it can be bypassed
They do not compose — separate configs, separate logs, separate ideas of what "denied" means. GuardStack is the composition: it is designed to benchmark the cost of each gate and document the limits shared by the set. It remains separate from these repositories. Assembling the six yourself is a legitimate choice and these repos stay free either way.
MIT © 2026 Jigon Yoo