Skip to content
sluice

Failure modes

Every row below is asserted by a chaos scenario (chaos harness) and a golden fixture, not just documented. SluiceError always carries { code, retryable, indeterminate, context } — never a stack, never a driver string.

| # | Fault | Contract | |---|---|---| | F1 | Duplicate delivery (2–5×, concurrent) | Exactly one claim wins; losers poll the winner's record to a terminal state (bounded by deadlineMs) and return replayed. Ledger count is 1. | | F2 | Timeout, effect landed anyway | Classified indeterminate. Default onIndeterminate: 'fail' throws E_INDETERMINATE, parked indeterminate — never reported as success, never auto-retried. 'gate' opens a "did this land?" human gate; 'reclaim' re-executes and is only legal when the caller has declared the downstream idempotent. | | F3 | Crash after the side effect, before the result was persisted | Lease expires; the next claim finds in_flight + an expired lease and transitions it to indeterminate — never a silent re-claim. Same three policies as F2. | | F4 | Crash before the side effect | Same indeterminate transition — sluice cannot distinguish F3 from F4, and says so. This is why 'fail' is the default. | | F5 | Crash mid-gate | The gate row is durable; resumeContext carries what a new process needs. A new process resumes via claimDecided (leased), and post-decision work runs through run() — resumption is itself exactly-once. | | F6 | Double decision / approve-reject race | Conditional update; first writer wins; the second call returns the recorded decision (idempotent, not an error). Both attempts are recorded in the audit trail. | | F7 | Retry storm | Retry budget (10% of calls) + full-jitter backoff + circuit breaker. Exhaustion throws E_RETRY_BUDGET immediately. The published amplification factor on the homepage is the receipt. | | F8 | Clock skew between workers | Within leaseMs / 2 skew, no double execution. Beyond that sluice fails closed to indeterminate (a stale owner's terminal write is refused) rather than executing twice. Stated ceiling — see limitations. | | F9 | Key reuse with different arguments | fingerprint mismatch throws E_KEY_CONFLICT plus an audit event. Never silently returns the other call's result. | | F10 | Result too large (> maxResultBytes, default 64 KiB) | Stored as resultOmitted; replay returns { status: 'replayed', value: undefined, resultOmitted: true }. The type forces every caller to handle it. | | F11 | Store unavailable | E_STORE; marked indeterminate: true if it fails after the claim (the effect may be in flight), retryable: true if before. sluice never executes the effect function without a granted claim. | | F12 | Gate timeout | sweepTimeouts — and any read of an expired gate — resolves it to timed_out and applies onTimeout (default reject, fail closed). sluice.gate() sugar throws E_GATE_TIMEOUT. |

Error codes

E_KEY_CONFLICT E_INDETERMINATE E_EFFECT_FAILED E_CIRCUIT_OPEN E_RETRY_BUDGET E_DEADLINE E_LEASE_LOST E_GATE_REJECTED E_GATE_TIMEOUT E_WAIT_TIMEOUT E_BAD_TOKEN E_STORE E_RESULT_TOO_LARGE E_CONFIG

Every one extends SluiceError; retryable and indeterminate are the two bits your code should branch on, not the message string.