Skip to content

feat: let the coordinator fail closed when the idempotency store is unavailable - #55

Merged
AlexeyShalaev merged 1 commit into
masterfrom
feat/53-fail-closed-on-storage-error
Oct 16, 2026
Merged

AlexeyShalaev merged 1 commit into
masterfrom
feat/53-fail-closed-on-storage-error

Conversation

@AlexeyShalaev

@AlexeyShalaev AlexeyShalaev commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

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 on BaseIdempotencySettings, IdempotencySettingsProtocol and the Dishka provider (read with a "run" fallback, like in_flight). New constants StorageErrorMode and DEFAULT_ON_STORAGE_ERROR sit next to InFlightMode.

With "raise":

Where the store fails "run" (default, unchanged) "raise"
reserving the key, or reading it (any in_flight mode, the waiter's retries included) logged, counted, action runs unprotected logged, counted, IdempotencyStorageError; action does not run
saving the result, releasing the reservation logged, counted, result returned the same: the effect has happened, raising would make the client retry it
  • The exception is the existing, already exported IdempotencyStorageError. The Redis repository raises exactly that, and it is re-raised as is, with operation and original_error. A custom repository that lets a raw driver exception through gets it wrapped in one (__cause__ and original_error set), since the point of the mode is not to run when in doubt. Out of coordinate() it always means the action did not run, so it maps straight to 503.
  • A record the store returned but nobody can read (a non-storage IdempotencyError from 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.
  • The storage_reserve_error / storage_get_error metric 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

  • A new IdempotencyStorageUnavailableError, as proposed in the issue: IdempotencyStorageError already 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.
  • Failing closed on an unreadable record too: it would wedge the key for up to the record's TTL, every retry a 503, with the store perfectly healthy.

Verification

The reporter's script against this branch, Redis at 127.0.0.1:1:

master:                         effects 2
this branch, default:           effects 2
this branch, on_storage_error="raise":
raised IdempotencyStorageError - Redis storage failure during save
raised IdempotencyStorageError - Redis storage failure during save
effects 0

New tests in tests/unit/core/test_on_storage_error.py: the reporter's scenario on a real redis.asyncio.Redis pointed at a closed port (retries off, so it takes milliseconds) in both modes; a failed read after a contested reservation in wait and raise; the failed first read in in_flight="run"; the metric still recorded; a raw ConnectionError wrapped; 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.py and provider, 11 tests fail (the option does not exist). As a sharper control, keeping the option but dropping the two _raise_if_failing_closed calls 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 against redis: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 check clean, uv run pytest --cov=idempotency_kit 222 passed, 97% coverage, and the reporter's script still prints effects 0 with "raise".

@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!

…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
AlexeyShalaev force-pushed the feat/53-fail-closed-on-storage-error branch from d9b3c1c to 25d9b2a Compare October 16, 2026 15:00
@AlexeyShalaev
AlexeyShalaev merged commit c23aad6 into master Oct 16, 2026
8 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the feat/53-fail-closed-on-storage-error branch October 16, 2026 15:01
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.

No way to fail closed when the idempotency store is unavailable

1 participant