feat(circuit-breaker): add CircuitBreaker Tower middleware - #855
feat(circuit-breaker): add CircuitBreaker Tower middleware#855Mattbusel wants to merge 3 commits into
Conversation
Three-state machine (Closed → Open → HalfOpen) with configurable failure threshold, success-rate recovery, and probe timeout. - CircuitBreakerLayer for ServiceBuilder ergonomics - CircuitBreaker<S> implements Service<Request> - ResponseFuture: non-blocking gate check via try_read() - Automatic HalfOpen transition after timeout elapses - Clears result window on HalfOpen so recovery rate reflects only post-recovery probes, not stale failure history - Full test coverage for open/close/recovery paths Designed and implemented by Matthew Busel.
…Debug - Replace tokio::sync::RwLock with std::sync::Mutex — state updates now happen synchronously in poll() and poll_ready(), eliminating the tokio::spawn-inside-poll anti-pattern - Circuit gate check moved to poll_ready() where Tower expects it; call() only wraps the inner future in ResponseFuture - ResponseFuture::poll updates state inline on Ready — no allocation or task spawn, correct under Tower's single-threaded test executor - Suppress missing_debug_implementations for CircuitBreaker<S> since S is an unconstrained generic (same pattern as tower::Timeout<S>) - cargo fmt applied Designed and implemented by Matthew Busel.
|
Thanks for restarting this! Just a couple thoughts as I look through it:
|
|
Good points, thanks. On Budget, I see them as complementary layers: Budget governs retry-worthiness, circuit breaker gates all traffic (including first attempts) when the backend is down. They compose rather than overlap. Happy to add a note in the docs making that distinction explicit. On the broader abstraction, agreed. I'll refactor to a Will push an updated draft. |
…ionship - Extract CircuitPolicy trait (on_success, on_failure, should_probe, on_half_open) - Move ConsecutiveFailures into policy.rs as the built-in implementation - CircuitBreaker<S, P> generic over CircuitPolicy; SharedState<P> replaces State - CircuitBreakerLayer<P> with ::new() and ::with_policy() constructors - ResponseFuture<F, T, E, P> delegates outcome reporting to the policy - Document Send/Sync expectations on CircuitPolicy and CircuitBreaker structs - Document budget vs circuit breaker relationship in mod.rs and policy.rs - Add custom_policy_is_accepted test
|
Pushed ddb88ba, CircuitPolicy trait extracted, budget relationship documented. Ready for another look. |
…d controller (closes #384) (#412) CircuitBreakerHandle was read-only: force_open/force_closed/reset existed only on CircuitBreaker/CircuitBreakerWithFallback, which may already be moved, boxed, or dropped by the time external control is needed. Add the same operations to the handle, backed by the same shared circuit state used by build_with_handle(), so a retained handle controls every service the layer produces regardless of what happened to the original service. Because the underlying state is updated with Release ordering before the call returns, a subsequent state()/is_open() call (Acquire ordering) is guaranteed to observe the new state -- no sleep or poll loop needed. Add a manual_mode() builder option for a manual/external-only circuit that never trips or recovers automatically from inner-service results or from the wait_duration_in_open recovery timer. State changes only via an explicit force_open/force_closed/reset call, giving a simple external on/off switch matching the scope discussed in tower-rs/tower#855. CircuitBreakerHandle now also implements HealthTriggerable (under the health-integration feature), and the module docs point to the handle's force_open()/force_closed() as the deterministic, awaitable alternative to the trait's fire-and-forget tokio::spawn-based transitions.
… retry-budget composition (closes #375) Adds contract-level compatibility tests for the trip-condition paths not yet covered by the #381/#382 regression suite: failure-rate window fill/trip, the FailureModel::ConsecutiveFailures model (independent of sliding-window gating), slow-call-rate trip independent of hard failures, and a permanently-Pending inner service under a Closed circuit (no busy-poll, waker registered, drop-safe). Adds docs/circuitbreaker-tower-comparison.md as the durable comparison checklist the issue asks for: API/state-machine comparison against the upstream CircuitPolicy proposal (fetched from PR #855 commit ddb88ba), admission semantics with test citations, an explicit partial finding on externally-triggered circuits, the classifier/state-transition/admission separation of concerns, an operator-control capability table, an RMQTT and GovCraft/Acton integration review (no genuine API friction found, so no issue filed), ideas worth adopting, intentional differences, and a composition-with-retry-budgets section covering layer ordering and budget token accounting. Updates the contract matrix's circuitbreaker row and links the new comparison doc from README.md next to the reconnect migration guide.
…#855 (closes #375) (#418) * chore: start work on #375 * docs(circuitbreaker): compare against tower-rs/tower#855 and document retry-budget composition (closes #375) Adds contract-level compatibility tests for the trip-condition paths not yet covered by the #381/#382 regression suite: failure-rate window fill/trip, the FailureModel::ConsecutiveFailures model (independent of sliding-window gating), slow-call-rate trip independent of hard failures, and a permanently-Pending inner service under a Closed circuit (no busy-poll, waker registered, drop-safe). Adds docs/circuitbreaker-tower-comparison.md as the durable comparison checklist the issue asks for: API/state-machine comparison against the upstream CircuitPolicy proposal (fetched from PR #855 commit ddb88ba), admission semantics with test citations, an explicit partial finding on externally-triggered circuits, the classifier/state-transition/admission separation of concerns, an operator-control capability table, an RMQTT and GovCraft/Acton integration review (no genuine API friction found, so no issue filed), ideas worth adopting, intentional differences, and a composition-with-retry-budgets section covering layer ordering and budget token accounting. Updates the contract matrix's circuitbreaker row and links the new comparison doc from README.md next to the reconnect migration guide.
Problem
Tower has no built-in circuit breaker. PR #102 was closed during migration in 2019 with a note to pick it back up — it never was. Users building on
reqwest,hyper, ortonicare forced to either write their own or pull in a separate crate just for this pattern.The missing primitive means retry storms: when a backend goes down, requests pile up, timeouts accumulate, and memory/goroutine equivalents grow unbounded. A circuit breaker cuts this off at the source.
Solution
This PR adds
tower::circuit_breaker— a three-state machine implemented as a standard TowerService<Request>+Layer.States
Usage
Key design decisions
try_read()inpoll()— circuit gate check is non-blocking; wakes and yields rather than blocking the executor if the write lock is held during a state transition.CircuitError<E>— wraps the inner error type;CircuitError::Opensignals a rejected-without-calling case so callers can distinguish "backend failed" from "circuit open".reset()method — allows operator-driven forced close (e.g. after confirming backend is healthy).circuit-breaker = ["tokio/sync", "tokio/time", "pin-project-lite"]— zero cost if unused.Files changed
tower/src/circuit_breaker/mod.rstower/src/circuit_breaker/layer.rsCircuitBreakerLayertower/src/circuit_breaker/service.rsCircuitBreaker<S>+ state machine + teststower/src/circuit_breaker/future.rsResponseFuturewith non-blocking gatetower/src/lib.rs#[cfg(feature = "circuit-breaker")] pub mod circuit_breakertower/Cargo.tomlfullTests
Two inline tests in
service.rs:closed_passes_requests_through— baseline happy pathopens_after_failure_threshold— verifies Open state rejects withCircuitError::OpenDesigned and implemented by Matthew Busel.