Skip to content

An invalid idempotency key runs the action unprotected instead of raising #51

Closed

Description

@AlexeyShalaev

Versions: idempotency-kit 0.4.1, redis 8.1.0, Redis 7, Python 3.13.

coordinate() runs the action without any protection when the idempotency key fails validation. A key containing :, a key longer than 255 characters or a whitespace-only key makes _try_reserve catch IdempotencyValidationError (core/services/aio/coordinator.py:273), log it and return None, which _claim treats like a storage failure: the action runs unreserved and nothing is stored. Every retry with that key repeats the effect. An empty string goes through if not self._enabled or not idempotency_key (coordinator.py:134) and runs without even a log record.

Reproduction

import asyncio
import logging

from redis.asyncio import Redis
from idempotency_kit import AsyncIdempotencyCoordinator, IdempotencyDomainService, JsonResultAdapter
from idempotency_kit.infra.storage.redis.aio import RedisAsyncIdempotencyRepository


async def main():
    redis = Redis.from_url("redis://127.0.0.1:6379/0")
    coordinator = AsyncIdempotencyCoordinator(
        RedisAsyncIdempotencyRepository(redis), IdempotencyDomainService(), in_flight="raise",
    )
    effects = []

    async def charge(attempt):
        effects.append(attempt)
        return {"charge_id": f"ch_{len(effects)}"}

    for key in ("", "order:42", "x" * 256):
        effects.clear()
        for attempt in (1, 2):
            await coordinator.coordinate("payment.charge", key, 3600, JsonResultAdapter(), charge, attempt)
        print(f"key {key[:12]!r:16} -> effects={len(effects)}")
    await redis.aclose()


logging.basicConfig(level=logging.CRITICAL)
asyncio.run(main())

Actual

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

Expected

The key usually comes straight from a client (Idempotency-Key). A storage outage is the library's problem, and degrading to running the action is a documented choice. An invalid key is the caller's problem: silently dropping protection hides it, and the client believes its retries are safe. The IETF Idempotency-Key draft answers a malformed key with 400.

Proposal

  • For a non-None key that fails validation, including "", raise IdempotencyValidationError from coordinate() before running the action, so the boundary can map it to 400. Keep None meaning "no key, no protection".
  • If changing the default is too abrupt, make it an option, and document the current behaviour next to in_flight.

Today the workaround is to validate with IdempotencyIdentifiers before calling coordinate().

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Reproduced on master with your script (plus a whitespace-only key): effects=2 for '', 'order:42', 'x' * 256 and ' '.

    Plan: coordinate() validates the operation and the key with IdempotencyIdentifiers right after the enabled check and raises IdempotencyValidationError before anything is read, reserved or run. None stays the one way to say "no key". That covers both flows, the reserved one and in_flight="run", which today degrades the same way through the failed read and the rejected SET NX. The decorator's if not idempotency_key becomes is None, so an empty string reaches the coordinator and is refused there instead of skipping it. enabled=False stays a pure pass-through with no validation, as documented.

    The non-empty invalid keys are a plain bug: rule 12 on the agents page already says an over-long key raises. The empty string is different, because rule 4 documents it as "no idempotency", and code that does headers.get("Idempotency-Key", "") relies on that. I am still going with your proposal rather than an option: a whitespace-only key raising while an empty one silently disables protection would be an odd line to draw, and an empty Idempotency-Key header is a malformed one. So the PR carries a ! and the migration note is one line: pass None, not "", when there is no key. No org library depends on idempotency-kit, so there is no cap to widen.

    Rejected: an opt-in flag (a second way to call the same API, and the default would keep the silent hole), and validating inside the domain service (the coordinator's own check keeps the decision in one place and does not depend on how the service was built). No new metric or log line for the refusal: the exception goes back to the caller, who maps it to 400.

  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    #54 is up, as planned. Your script against that branch (plus a whitespace-only key):

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

    coordinate() checks the operation and any non-None key before it reads, reserves or runs anything, in all three in_flight modes, and the decorator now hands an empty key to the coordinator instead of skipping it. Map IdempotencyValidationError to 400 at the boundary and you have the IETF behaviour; the IdempotencyIdentifiers pre-check is no longer needed.

    On your side the one thing to change is "" meaning "no key": pass None. That was documented (rule 4 on the agents page), so the PR carries a ! and the release will be 0.5.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions