Repository navigation
fix!: raise on an invalid idempotency key instead of running the action unprotected - #54
Merged
Merged
Conversation
…on unprotected coordinate() let a key the store cannot hold through to the reservation, where the repository rejected it, and the coordinator treated that like a storage outage: the action ran unreserved and nothing was stored, so every retry with the key repeated the effect. An empty key skipped the coordinator altogether. coordinate() now validates the operation and any non-None key with IdempotencyIdentifiers before anything is read, reserved or run, and raises IdempotencyValidationError, in every in_flight mode. The decorator hands an empty key to the coordinator instead of skipping it. None still means no key; enabled=False is still a pass-through without the check. BREAKING CHANGE: coordinate() and @async_idempotent raise IdempotencyValidationError for an empty key, which used to mean "no idempotency", and for a key or operation that is whitespace-only, contains ":" or is too long, which used to run the action unprotected. Pass None, not "", when a request carries no key, and map IdempotencyValidationError to HTTP 400. Closes #51
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This was referenced Oct 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #51
Problem
A key the store cannot hold (a colon, longer than 255 characters, whitespace-only) went through
coordinate()to the reservation, where the repository rejected it withIdempotencyValidationError._try_reservehandled that like a storage outage: logged it and returnedNone, so the action ran unreserved and nothing was stored. Every retry with that key repeated the effect. Inin_flight="run"mode the same happened through the failed read and the rejectedSET NX. An empty key never reached the coordinator's logic at all (if not idempotency_key).Fix
coordinate()validates the operation and any non-Nonekey withIdempotencyIdentifiersright after theenabledcheck, before anything is read, reserved or run, and raisesIdempotencyValidationError(with the Pydanticerrors). One check covers all threein_flightmodes. The decorator'sif not idempotency_keybecameis None, so an empty key reaches the coordinator and is refused there.Noneis the one way to say "no key";enabled=Falsestays a pure pass-through.No log line or metric for the refusal: the exception goes back to the caller, which maps it to 400, as the quickstart's FastAPI example already does.
Who is affected
""to mean "no key". Rule 4 on the agents page documented that, so this is a breaking change and the commit carries!(0.4.x -> 0.5.0 underbump-minor-pre-major). The typical source isheaders.get("Idempotency-Key", ""); the fix on their side is to passNone. FastAPI'sHeader(None)already givesNonefor a missing header.:, are whitespace-only or longer than 255 characters (or whose operation is invalid). Those calls ran unprotected before, with anERRORlog; now they raise. That part is the bug fix itself.pyproject.tomlunder bedrock-python), so there is no upper cap to widen.Rejected
""as "no key" and raising only for the rest: whitespace-only would raise while empty silently disables protection, and an emptyIdempotency-Keyheader is a malformed one.IdempotencyDomainService: the coordinator's own check does not depend on how the service was built (tests and some users pass a mock).Verification
The reporter's script against this branch (plus a whitespace-only key), Redis 7 in Docker:
New tests: the four invalid keys in each
in_flightmode, two calls each, the action never awaited and nothing written; an invalid operation;enabled=Falsewith an empty key still runs; the decorator passes""on to the coordinator. With the fix reverted (coordinator.pyandidempotent.pyfrom master) 14 of them fail; theenabled=Falseone is a guard and passes on both.make check: ruff, format and mypy clean.uv run pytest --cov=idempotency_kit: 199 passed (unit and integration againstredis:7-alpine), 96% coverage.Docs: agents page rules 4 and 12, the
coordinate()row, the errors table and the paragraph under it; API reference; user guide (key format section and the error list).