Skip to content

feat: store a fingerprint of the request and refuse a key reused for another - #30

Merged
AlexeyShalaev merged 1 commit into
masterfrom
feat/fingerprint
Sep 7, 2026
Merged

feat: store a fingerprint of the request and refuse a key reused for another#30
AlexeyShalaev merged 1 commit into
masterfrom
feat/fingerprint

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Contributor

Built on the #26 branch, and rebased onto master once #28 was squashed there; the branch is one commit on top of 0dbf68b.

After a charge of 1999 was recorded under order-42, the same key came back with amount 5 and got the 1999 charge back, with nothing to say that the request was a different one. The key is the whole identity by design, and a well-behaved client never does this; a client that derives its key from the order id and then makes a second, different request on the same order does, and the library answered it with a result for another request. Your script on master, second block:

--- the same key again, with a different amount ---
requested amount 5, got back: charge_id='ch_1' amount=1999

IdempotencyRecord gained fingerprint: str | None = None, stored beside the result; a record already in Redis reads back with None, so nothing is migrated. coordinate() takes idempotency_fingerprint: str | None = None as its one keyword, declared after *args so that it stays out of the action's **kwargs — that name rather than fingerprint because everything keyword-shaped in coordinate() is forwarded to the action, which makes the name one the action can never have again, and fingerprint is a plausible parameter in an auth-shaped service (a device fingerprint) where idempotency_fingerprint mirrors idempotency_key and is not. The fingerprint goes onto the pending record as well as the completed one, and the comparison happens on the read, before the pending check and before the action: a record whose fingerprint differs from the caller's raises IdempotencyKeyReuseError, carrying operation, key, stored_fingerprint and fingerprint, out of coordinate() and the decorator. It is a caller error, not storage trouble, so it is the second exception those two let through, next to IdempotencyInProgressError; the HTTP layer maps it to 422 as Stripe does, and it is counted as record_error(operation, "key_reuse") since a client reusing keys is worth an alert. Comparing on the pending record matters: a waiter with a different payload is refused at once rather than handed someone else's result after waiting for it. Either side missing means no comparison, so a record without a fingerprint never raises and a caller without one gets the key-only behaviour — nothing changes for anyone who does not opt in. In in_flight="run" mode the check is still on the read; a mismatch that only shows on the collision after both callers ran is logged, counted, and the caller keeps its own result, because raising after the side effect would make the caller retry a completed operation.

The decorator takes fingerprint_params: tuple[str, ...] | None = None, the names of the parameters that identify the request, in the same shape as key_param and infra_param. It binds the call to the signature with defaults applied, so an argument passed at its default and one left out agree; turns the named values into JSON-able Python with pydantic's to_jsonable_python, which covers Pydantic models, dataclasses, UUIDs, datetimes and Decimals without a case per type; serialises with sorted keys and no whitespace so equal dicts built in different orders agree; and takes the SHA-256 hex, a 64-character string no payload can bloat a record with. A name the function does not have, or a signature inspect cannot describe, raises TypeError at decoration time. The hashing is public as fingerprint_of(**values), exported from the root, so a caller of coordinate() and the decorator agree on what "the same request" means for one operation; that is the one new name beyond the error, the field and the two arguments. The decorator passes idempotency_fingerprint only when fingerprint_params is set, so an existing assertion on how it calls coordinate() keeps holding.

Rejected: hashing every argument by default, which would close the whole class of mistake unasked — a request often carries something that legitimately differs between identical retries (a timestamp, a trace id, self), and a default that rejects honest retries as key reuse is worse than the documented status quo, so it is opt-in per operation with the parameters named. A callable form on the decorator alongside the tuple: the tuple covers the case in the issue, fingerprint_of plus coordinate() covers any other notion of sameness, and one shape is easier to hold than two.

Docs: rule 1 on the agents page keeps the key as the identity and gains the fingerprint as the guard for when the key is wrong; rules 23 and 24 say what to fingerprint and that idempotency_fingerprint is the coordinator's keyword; the wiring example, the API tables, the errors table and a new Common-mistakes example follow. The user guide has a "Key reuse and fingerprints" section with the decorator, the coordinate() form, the 422 mapping and the semantics; the API reference gains the fields, fingerprint_of and an entry for async_idempotent it did not have; the README and quickstart mention it. CHANGELOG.md untouched.

Tests: tests/unit/core/test_fingerprint.py holds your scenario against a fake Redis — 1999 charged under the key with fingerprint_of(amount=1999), the key back with fingerprint_of(amount=5) raising IdempotencyKeyReuseError with both fingerprints on it and the action not run — then the same fingerprint replaying, no fingerprint replaying as before, a record stored without a fingerprint (the pre-change JSON shape, written straight into Redis) replaying for a caller with one, a caller without one replaying a record that has one, a pending record with another fingerprint refusing the waiter at once, the key_reuse count, and both run-mode paths. test_entities.py decodes the pre-change JSON shape with fingerprint is None; test_idempotent.py checks the decorator hands coordinate() the fingerprint of the named arguments, that a defaulted argument passed and omitted agree, and the two TypeErrors at decoration; fingerprint_of has its own cases for key order, differing values, the non-JSON types and the digest shape; the reuse also runs against real Redis in the integration suite. As a negative control against origin/master in a worktree: the module cannot collect there (ImportError: cannot import name 'IdempotencyKeyReuseError'), and a call with only the new keyword fails as TypeError: charge_card() got an unexpected keyword argument 'idempotency_fingerprint' — on master the keyword goes to the action.

The gate, before touching anything on this branch and again at the end:

$ make check
uv run ruff check .
All checks passed!
uv run ruff format --check .
53 files already formatted
uv run mypy idempotency_kit
Success: no issues found in 32 source files

$ make test
Required test coverage of 90% reached. Total coverage: 96.31%
======================== 176 passed, 1 warning in 3.88s ========================

(On master after #28: 151 passed, 96.07%; the warning is the pre-existing testcontainers.redis deprecation.) uv.lock untouched.

Your script does not pass a fingerprint, so a copy of it does, fingerprint_of(amount=1999) on the two concurrent calls and fingerprint_of(amount=5) on the reuse, and prints the exception; against this branch in a venv with uv pip install -e ".[redis]" "testcontainers[redis]":

--- two identical requests, 50 ms apart, same key ---
responses: ['ch_1', 'ch_1']
charges made by the provider: 1 -> ['ch_1']

--- the same key again, with a different amount ---
requested amount 5, got: IdempotencyKeyReuseError: Idempotency key 'order-42' for operation 'payment.charge' was used for a different request: stored fingerprint '52e7a9b6e8c63f1c5e73408229341583352cbe73a1c082bc9df2162cb3bd6dce', got '7e84cbf0f7a7c92c037058665d66152f8eb8580ab2534e52c877bccceb9cc7bf'
charges made by the provider: 1 -> ['ch_1']

The unmodified script against the same branch still prints requested amount 5, got back: charge_id='ch_1' amount=1999 for the second block — no fingerprint, key-only, as documented.

Closes #27

…another

A key reused with a different payload replayed the first result, with no
signal that anything was off. IdempotencyRecord gained fingerprint, stored
beside the result and compared on a hit and on a pending reservation; a
mismatch raises the new IdempotencyKeyReuseError, which carries both
fingerprints and propagates out of coordinate() and the decorator.

coordinate() takes idempotency_fingerprint as its one keyword; the decorator
takes fingerprint_params, the names of the parameters whose bound values,
defaults applied, are hashed with the new fingerprint_of(). Either side
without a fingerprint means no comparison, so records already stored and
callers who do not opt in keep the key-only behaviour.

Closes #27
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@AlexeyShalaev
AlexeyShalaev merged commit 78a9ca2 into master Sep 7, 2026
7 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the feat/fingerprint branch September 7, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A reused key with a different payload replays the first result: store a fingerprint

1 participant