Repository navigation
feat: let the coordinator fail closed when the idempotency store is unavailable - #55
Merged
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This was referenced Oct 16, 2026
…navailable With the store unreachable the coordinator always ran the action unprotected, which is the right default for most operations and the wrong one for an effect nothing downstream deduplicates. A new coordinator option, on_storage_error="run" | "raise" (default "run"), also on BaseIdempotencySettings and the Dishka provider, lets a service refuse instead: with "raise", a storage failure on the reservation or the read is still counted and logged, then raises IdempotencyStorageError before the action runs. A driver exception a custom repository lets through is wrapped in one. A failure after the action, and a record the store returned but cannot be read, behave as before in both modes. Closes #53
AlexeyShalaev
force-pushed
the
feat/53-fail-closed-on-storage-error
branch
from
October 16, 2026 15:00
d9b3c1c to
25d9b2a
Compare
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 #53
Problem
With the store unreachable the coordinator always ran the action unprotected; two calls with the same key ran it twice, and there was no way to choose otherwise. Fail-open is right for most operations and wrong for an effect nothing downstream deduplicates (a charge at a provider without its own keys, an email, a ledger write elsewhere), where a 503 and a later retry are better. A health check in front of
coordinate()cannot close the race between the check and the write.Design
A coordinator option,
on_storage_error: Literal["run", "raise"] = "run", also onBaseIdempotencySettings,IdempotencySettingsProtocoland the Dishka provider (read with a"run"fallback, likein_flight). New constantsStorageErrorModeandDEFAULT_ON_STORAGE_ERRORsit next toInFlightMode.With
"raise":"run"(default, unchanged)"raise"in_flightmode, the waiter's retries included)IdempotencyStorageError; action does not runIdempotencyStorageError. The Redis repository raises exactly that, and it is re-raised as is, withoperationandoriginal_error. A custom repository that lets a raw driver exception through gets it wrapped in one (__cause__andoriginal_errorset), since the point of the mode is not to run when in doubt. Out ofcoordinate()it always means the action did not run, so it maps straight to 503.IdempotencyErrorfrom the repository, or an adapter decode failure) is not an outage, and keeps today's behaviour in both modes: the action runs and its result replaces the record, so the key heals instead of failing every retry until its TTL.storage_reserve_error/storage_get_errormetric and the log line are recorded in both modes, so dashboards do not change meaning.Default stays
"run": changing it would turn every Redis blip into errors for every service that did not ask for that. Purely additive; no breaking change.Rejected
IdempotencyStorageUnavailableError, as proposed in the issue:IdempotencyStorageErroralready means "the store is unavailable or fails", is exported, and is what the repository raises. A second name for the same condition is one more concept for every reader.Verification
The reporter's script against this branch, Redis at
127.0.0.1:1:New tests in
tests/unit/core/test_on_storage_error.py: the reporter's scenario on a realredis.asyncio.Redispointed at a closed port (retries off, so it takes milliseconds) in both modes; a failed read after a contested reservation inwaitandraise; the failed first read inin_flight="run"; the metric still recorded; a rawConnectionErrorwrapped; a corrupted record still runs and replaces; a failed save after the action still returns the result; a healthy store replays as usual; an unknown mode is refused. Plus the Dishka provider passing the setting through.Fails before: with master's
coordinator.pyand provider, 11 tests fail (the option does not exist). As a sharper control, keeping the option but dropping the two_raise_if_failing_closedcalls fails the 6 tests that need the refusal; the rest are guards for what must not change.make check: ruff, format and mypy clean.uv run pytest --cov=idempotency_kit: 196 passed (unit and integration againstredis:7-alpine), 97% coverage.Docs: user guide gets a "Failing closed when the store is down" section with the table above and a FastAPI 503 example; agents page rule 6, the mental model, the constructor and
coordinate()rows, constants, settings, the "expecting the coordinator to tell you Redis is down" mistake and the errors table; API reference; architecture's guarantees paragraph.Rebased onto master after #54 and #56. The option sits alongside both: an invalid key is still refused before the store is touched, and a failed renewal of a running action (#56) is logged in both modes, like a failed save, since the action has already started. After the rebase:
make checkclean,uv run pytest --cov=idempotency_kit222 passed, 97% coverage, and the reporter's script still printseffects 0with"raise".