Skip to content

fix!: raise on an invalid idempotency key instead of running the action unprotected - #54

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/51-reject-invalid-key
Oct 16, 2026
Merged

AlexeyShalaev merged 1 commit into
masterfrom
fix/51-reject-invalid-key

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

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 with IdempotencyValidationError. _try_reserve handled that like a storage outage: logged it and returned None, so the action ran unreserved and nothing was stored. Every retry with that key repeated the effect. In in_flight="run" mode the same happened through the failed read and the rejected SET NX. An empty key never reached the coordinator's logic at all (if not idempotency_key).

Fix

coordinate() validates the operation and any non-None key with IdempotencyIdentifiers right after the enabled check, before anything is read, reserved or run, and raises IdempotencyValidationError (with the Pydantic errors). One check covers all three in_flight modes. The decorator's if not idempotency_key became is None, so an empty key reaches the coordinator and is refused there. None is the one way to say "no key"; enabled=False stays 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

  • Callers passing "" 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 under bump-minor-pre-major). The typical source is headers.get("Idempotency-Key", ""); the fix on their side is to pass None. FastAPI's Header(None) already gives None for a missing header.
  • Callers whose keys contain :, are whitespace-only or longer than 255 characters (or whose operation is invalid). Those calls ran unprotected before, with an ERROR log; now they raise. That part is the bug fix itself.
  • No org library depends on idempotency-kit (checked every pyproject.toml under bedrock-python), so there is no upper cap to widen.

Rejected

  • An opt-in flag: a second way to call the same API, with the silent hole as the default.
  • Keeping "" as "no key" and raising only for the rest: whitespace-only would raise while empty silently disables protection, and an empty Idempotency-Key header is a malformed one.
  • Validating in 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:

master:
key ''               -> effects=2
key 'order:42'       -> effects=2
key 'xxxxxxxxxxxx'   -> effects=2
key '   '            -> effects=2

this branch:
key ''               -> IdempotencyValidationError, effects=0
key 'order:42'       -> IdempotencyValidationError, effects=0
key 'xxxxxxxxxxxx'   -> IdempotencyValidationError, effects=0
key '   '            -> IdempotencyValidationError, effects=0

New tests: the four invalid keys in each in_flight mode, two calls each, the action never awaited and nothing written; an invalid operation; enabled=False with an empty key still runs; the decorator passes "" on to the coordinator. With the fix reverted (coordinator.py and idempotent.py from master) 14 of them fail; the enabled=False one 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 against redis: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).

…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

codecov Bot commented Oct 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

An invalid idempotency key runs the action unprotected instead of raising

1 participant