Skip to main content

justonce

Make side effects happen exactly once.

Your code charges a customer. The network hiccups. Your retry logic fires. The customer is charged twice.

def charge_customer(order):
    return payments.charge(order.customer, order.total)   # 💸 twice
from justonce import idempotent, operation_key

@idempotent(key=lambda order: operation_key("charge", order.id))
def charge_customer(order):
    return payments.charge(order.customer, order.total)   # ✅ once

That's it. The function now runs at most once per order — across retries, restarts, queue replays, and concurrent workers on different machines. And the outcome is recorded, so later you can ask "did anything get charged twice yesterday?" and get a real answer instead of a guess.


Why this exists

Every network call has three outcomes, not two: success, failure, and unknown. The unknown one — a timeout, a dropped connection, a process killed mid-write — is what creates duplicates, because the only safe response to "unknown" is to retry, and retrying something that already applied applies it twice.

"Exactly-once delivery" does not exist. What is achievable is at-least-once delivery with idempotent processing, which produces exactly-once effects. You cannot stop the duplicate arriving. This library makes it harmless.

The duplicates that matter are the irreversible ones: money moves, an email sends, inventory decrements, a webhook fires. Those aren't latency bugs — they're correctness bugs that reach customers, and they're usually discovered by finance rather than by monitoring.

Install

pip install justonce                 # SQLite included, no dependencies
pip install justonce[postgres]       # + Postgres store
pip install justonce[django]         # + Django store, uses your existing connection

Usage

import justonce
from justonce.stores import SqliteStore

justonce.configure(SqliteStore("effects.db"))

@justonce.idempotent(key=lambda order: justonce.operation_key("charge", order.id))
def charge_customer(order):
    return payments.charge(order.customer, order.total)

For a fleet, swap the store — nothing else changes:

from justonce.stores import PostgresStore
justonce.configure(PostgresStore("postgresql://localhost/app"))

Already on Django? Use the connection you have, on any backend Django supports:

from justonce.stores.django_store import DjangoStore
justonce.configure(DjangoStore())

One caveat worth reading before you ship it: a store on the default alias joins your ambient transaction.atomic() block. That is correct when the effect is a local write — claim and effect roll back together. It is wrong when the effect is an external call, because a rollback erases the claim while the charge stands, and the retry charges again. Point the store at a separate database alias in that case, and store.in_ambient_transaction() will tell you which mode you are actually in.

Choosing a store

Store Use when Not when
SqliteStore("path.db") single host — local dev, tests, a single-instance deployment you're running more than one machine or worker process
PostgresStore a fleet of workers sharing state, no existing Django app
DjangoStore you already run Django and want to reuse its connection you haven't read the transaction caveat above

Durability matters more than it looks like it should, and the default is not durable. SqliteStore() defaults to path=":memory:", so a store constructed with no arguments forgets every key the moment the process exits. That's fine for a REPL session or a test; it's a silent bug in production, because it means the one scenario idempotency exists for — a retry that arrives after a deploy or a crash — is exactly when the store has no memory of what already happened.

If a key needs to survive process restarts, pass a real path (SqliteStore("justonce.db")) or use PostgresStore or DjangoStore.

Knowing whether this call did the work

@justonce.idempotent(key=..., return_result=True)
def charge_customer(order): ...

result = charge_customer(order)
if result.deduplicated:
    log.info("already charged", extra={"response": result.value})

Handling the in-flight duplicate

Another worker holds the claim and hasn't finished. Choose deliberately:

justonce.configure(store, on_in_flight=justonce.OnInFlight.RAISE)   # 409, default
justonce.configure(store, on_in_flight=justonce.OnInFlight.WAIT)    # block for the result

Never let a second caller proceed because the first "seems stuck" — a stalled attempt whose fate is unknown is exactly when duplicating is most expensive.

Reconciliation

Prevention is never complete. When a process dies between the effect and recording it, the key is left UNKNOWN rather than cleaned up — because "we don't know whether the customer was charged" is a fact worth keeping.

for record in engine.unresolved():
    outcome = payments.lookup(idempotency_key=record.key)   # ask the provider
    ...

Alert on the age of the oldest unresolved record, not the count. A stuck reconciliation is invisible in a count that stays flat.

Retention

justonce.configure(store, retention_seconds=30 * 24 * 3600)
engine.sweep()   # nightly

Retention is a correctness parameter, not a storage optimisation. It must outlive the longest chain that can re-deliver the same intent — including a dead-letter queue replayed a week later, and any provider dispute window. A 24-hour TTL behind a 7-day DLQ is a duplicate waiting to happen.

Choosing a key

The key must be stable across retries of the same intent and different across distinct intents. Nearly every idempotency bug is a key that breaks one of those:

uuid4()                        # ✗ new key per attempt — every retry is a new charge
f"{user_id}:{amount}"          # ✗ two legitimate $50 charges collapse into one
hash(cart.contents)            # ✗ key changes if the cart is edited mid-retry
f"{order.id}:{time.time()}"    # ✗ a timestamp is uuid4() wearing a hat

operation_key("charge", order.id)          # ✓ derived from an immutable identifier
request.headers["Idempotency-Key"]         # ✓ client-supplied, reused on retry

The key comes from the initiating event or the client — never from the layer doing the retrying.

What it guarantees

Situation Behaviour
Same key, same payload, called again Effect runs once; recorded response returned
Same key, different payload KeyReuseError — never serves the wrong response
Two workers, same key, same instant Exactly one runs the effect
Crash after effect, before recording Key left UNKNOWN; retries refuse until reconciled
Effect raised a transient error Claim released; a later attempt may retry
Effect raised a permanent error Key burned; no retry
Holder died mid-flight Claim reclaimable once its lease expires

Each row is a test in tests/test_exactly_once.py. If a guarantee isn't defended by a test that fails when you remove the logic, it isn't a guarantee.

How it works

claim ──won──> run effect ──> record outcome ──> return
  │
  └──lost──> terminal?  ──> return recorded response
             in-flight? ──> reject, or wait
             unknown?   ──> refuse; reconcile

The claim is a single atomic write guarded by a unique constraint — INSERT ... ON CONFLICT DO NOTHING. The database picks the winner. There is no SELECT before it, because a check followed by an act is a race:

# ✗ TOCTOU: both callers read "not seen", both charge
if not db.exists(key):
    charge_card(amount)
    db.insert(key)

The unique constraint is the mechanism. If a backend can't enforce uniqueness atomically, it can't be a store.

Compared to durable execution

Temporal, Restate, DBOS and friends solve a broader problem, and solve it well — but they ask you to restructure your application into workflows, which is why adoption stalls in existing codebases.

Durable execution justonce
Unit of protection The workflow One function call
Adoption cost Rewrite the app Add a decorator
Runtime required A server or cluster A table
"What did this actually do?" Via workflow history The core primitive

Use justonce when you want one dangerous call made safe this afternoon. Use a workflow engine when you need orchestration, timers, and long-running state.

FastAPI payment example

The copyable examples/fastapi_payment.py endpoint accepts an Idempotency-Key, uses a credential-free payment provider, and returns the recorded response on replay. Run it from the repository root:

uv run --extra examples uvicorn examples.fastapi_payment:app --reload

Open http://127.0.0.1:8000/docs to try the successful, replay, key-reuse (422), and concurrent in-flight (409) paths.

Contributing

The core is small on purpose. Most of the value is at the edges, and that's where help is most useful:

  • A store for your database — MySQL, DynamoDB, MongoDB, Redis, Spanner, D1. The contract is six methods, and justonce.conformance is an executable version of it. If your store passes the suite, it's correct by this project's definition.
  • A framework integration — Django, Flask, Celery, Dramatiq, Airflow, FastAPI middleware.
  • A provider adapter — map justonce keys onto Stripe, Adyen, Razorpay, PayPal native idempotency, so both sides agree on identity.

Adding a store is genuinely one file plus one conformance class. See CONTRIBUTING.md and the good first issues.

Licence

MIT

Download files

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

Source Distribution

justonce-0.2.0.tar.gz (41.3 kB view details)

Uploaded Source

Built Distribution

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

justonce-0.2.0-py3-none-any.whl (34.6 kB view details)

Uploaded Python 3

File details

Details for the file justonce-0.2.0.tar.gz.

File metadata

  • Download URL: justonce-0.2.0.tar.gz
  • Upload date:
  • Size: 41.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for justonce-0.2.0.tar.gz
Algorithm Hash digest
SHA256 9f9194331fcafc33875fc450b5cb42ecab60f323972d2fddd373f23c6ea63a1d
MD5 858fe7b362ef673e704e2c6e91cd6497
BLAKE2b-256 7e1a64a255b780cd8ad2a5b5a1ce9ce6c1f80dc756a902b04c0f54e3a0c44c4a

See more details on using hashes here.

Provenance

The following attestation bundles were made for justonce-0.2.0.tar.gz:

Publisher: release.yml on abhisheksharma2411/justonce

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file justonce-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: justonce-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 34.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for justonce-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 82faa94843a5291125347188d91a9d1941badfd4350c0ad0bc650e632a39d06c
MD5 f2b065ca52af7d6db84b31db8dd6e6be
BLAKE2b-256 3bef44fc58de1f279341da3ac68f9ff05f9a6f336895e69f221ffcf09b3b2a30

See more details on using hashes here.

Provenance

The following attestation bundles were made for justonce-0.2.0-py3-none-any.whl:

Publisher: release.yml on abhisheksharma2411/justonce

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.1.0

2 files

Supported by

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