v1.8.0
What's changed in v1.8.0
-
feat: give RepositoryError and LockError a retryability signal (#83) (by @patrickleet)
- refactor!: give RepositoryError and LockError a retryability signal
repository_storage_error mapped every sqlx failure — connection refused,
pool timeout, SQLITE_BUSY, and constraint violations alike — to
RepositoryError::Model(String). RepositoryError had no storage shape and no
source(), and From/From collapsed to
strings. Downstream, HandlerError::transport_error_kind classified every
Repository(_) as Retryable, so a deterministic model error redelivered
forever while a transient DB outage was indistinguishable from it.Add a Storage { operation, retryable, source } variant to RepositoryError
plus a kind() -> RetryClass method, mirroring TransportError. RetryClass
lives in the lock module (the lowest layer both errors reach) so repository
does not depend on the bus. repository_storage_error now classifies sqlx
errors via is_sqlx_transient (connection/pool/timeout and SQLITE_BUSY ->
retryable; constraint/decode -> permanent) and preserves the source. The
From impls map to Storage, keeping the source and the transient/permanent
distinction. ConcurrentWrite and NotFound stay retryable (race resolved by
a later attempt); Model/Replay/invalid-identity/InvalidState are permanent.HandlerError::transport_error_kind now defers to RepositoryError::kind()
instead of bucketing all Repository(_) as retryable.LockError gains a Busy variant and the same kind()/is_retryable() signal:
Poisoned is permanent (broken invariant); contention, lease loss, and busy
are retryable. The existing AcquireFailed/ReleaseFailed lease mappings were
already retryable under this classification.Storage carries a boxed source, so RepositoryError drops Clone/PartialEq/Eq
(they were test-only). The handful of assert_eq! comparisons became
matches! on the variant. Both enums are now #[non_exhaustive] so future
variants are non-breaking.BREAKING CHANGE: RepositoryError no longer derives Clone/PartialEq/Eq, both
RepositoryError and LockError are #[non_exhaustive], and From
/From now produce RepositoryError::Storage instead of
::Model.Implements [[tasks/repository-error-taxonomy]]
- test(knative): use a genuinely retryable error for the 503 redeliver case
The
retryable_failure_returns_503ingress test handler returned
RepositoryError::Model("transient"). After the error-taxonomy rework,
Modelis — correctly — a deterministic fault classified Permanent, so
the ingress mapped it to 422 (do-not-retry), failing the assertion.This was a stale expectation asserting the old redeliver-forever bug, not
a misclassification: aModelerror is deterministic and re-running the
identical message cannot change its outcome, so it MUST be permanent (the
deterministic_repository_errors_are_permanentunit test pins this).The fix is in the test, not the classification logic. The handler now
returnsRepositoryError::retryable_storage("load stream", ...)— a
transient storage outage (connection refused) that genuinely may succeed
on redelivery — which is what a "temporarily failed" handler should
signal. The 503 redeliver semantics the test names are now honestly
exercised, and the deterministic-permanent path is covered by
order.rejected-> 422.Refines [[tasks/repository-error-taxonomy]]
- test: expect permanent Storage error for read-model constraint violation
A read-model CHECK-constraint violation is a deterministic, non-retryable
fault. With the reworked error taxonomy it now surfaces as the new
RepositoryError::Storage { retryable: false, .. }variant rather than the
oldRepositoryError::Model(String), so re-running the identical commit is
never retried forever.PR #85 added
read_model_failure_mid_plan_rolls_back_events_and_outboxto
both the sqlite and postgres repository suites asserting the oldModel
error. Update both assertions to expect the permanentStoragevariant
(and!err.is_retryable()), matching the corrected classification and the
idiom already used by this PR's own backend-storage tests. The rollback
invariant the test actually guards — events, outbox row, and the first
read-model mutation all absent after the failed commit — is unchanged.Refines [[tasks/repository-error-taxonomy]]
- fix(repo): classify Postgres deadlock/serialization SQLSTATEs as retryable
40001 (serialization_failure) and 40P01 (deadlock_detected) are transient
write-race outcomes that should be retried, not handed to the failure policy.
is_sqlx_transient previously only matched pool/timeout/IO and SQLite busy, so
these fell through as permanent. Mirrors the existing 23505 unique-violation
detection (no feature gate: SQLite never carries these SQLSTATEs).Addresses CodeRabbit review on PR #83.
Refines [[tasks/repository-error-taxonomy]]
See full diff: v1.7.6...v1.8.0