http-tower-hs-0.1.0.0: src/Network/HTTP/Tower/Middleware/CircuitBreaker.hs
-- |
-- Module : Network.HTTP.Tower.Middleware.CircuitBreaker
-- Description : Three-state circuit breaker using STM
-- License : MIT
--
-- Prevents cascading failures by tracking consecutive errors and
-- short-circuiting requests when a service is known to be down.
--
-- @
-- breaker <- 'newCircuitBreaker'
-- let config = 'CircuitBreakerConfig' { 'cbFailureThreshold' = 5, 'cbCooldownPeriod' = 30 }
-- let client' = client '|>' 'withCircuitBreaker' config breaker
-- @
--
-- == State machine
--
-- * __Closed__ — normal operation. Failures are counted. Trips to Open
-- after reaching the threshold.
-- * __Open__ — all requests rejected with 'CircuitBreakerOpen'. After the
-- cooldown period, transitions to HalfOpen.
-- * __HalfOpen__ — one probe request is allowed through. Success resets to
-- Closed; failure trips back to Open.
module Network.HTTP.Tower.Middleware.CircuitBreaker
( CircuitBreakerConfig(..)
, CircuitBreakerState(..)
, CircuitBreaker
, newCircuitBreaker
, withCircuitBreaker
, getCircuitBreakerState
) where
import Control.Concurrent.STM
import Data.Time.Clock (UTCTime, NominalDiffTime, getCurrentTime, diffUTCTime)
import Network.HTTP.Tower.Core (Service(..), Middleware)
import Network.HTTP.Tower.Error (ServiceError(..))
-- | Configuration for the circuit breaker.
data CircuitBreakerConfig = CircuitBreakerConfig
{ cbFailureThreshold :: !Int
-- ^ Number of consecutive failures before the breaker trips open.
, cbCooldownPeriod :: !NominalDiffTime
-- ^ How long to stay open before transitioning to half-open (in seconds).
} deriving (Show, Eq)
-- | Observable state of the circuit breaker.
data CircuitBreakerState
= Closed -- ^ Normal operation, requests flow through.
| Open -- ^ Tripped — all requests rejected immediately.
| HalfOpen -- ^ Testing — one request allowed through to probe recovery.
deriving (Show, Eq)
data BreakerInternals = BreakerInternals
{ biState :: !CircuitBreakerState
, biFailureCount :: !Int
, biLastFailureAt :: !(Maybe UTCTime)
}
-- | Opaque handle to a circuit breaker instance.
-- Create with 'newCircuitBreaker', share across requests.
newtype CircuitBreaker = CircuitBreaker (TVar BreakerInternals)
-- | Create a new circuit breaker in the 'Closed' state.
newCircuitBreaker :: IO CircuitBreaker
newCircuitBreaker = CircuitBreaker <$> newTVarIO BreakerInternals
{ biState = Closed
, biFailureCount = 0
, biLastFailureAt = Nothing
}
-- | Read the current state of the circuit breaker.
getCircuitBreakerState :: CircuitBreaker -> IO CircuitBreakerState
getCircuitBreakerState (CircuitBreaker var) = biState <$> readTVarIO var
-- | Circuit breaker middleware.
--
-- The 'CircuitBreaker' handle is shared across all requests — create it
-- once and reuse it:
--
-- @
-- breaker <- 'newCircuitBreaker'
-- let client' = client '|>' 'withCircuitBreaker' config breaker
-- @
withCircuitBreaker :: CircuitBreakerConfig -> CircuitBreaker -> Middleware req res
withCircuitBreaker config (CircuitBreaker var) inner = Service $ \req -> do
now <- getCurrentTime
decision <- atomically $ do
internals <- readTVar var
case biState internals of
Open ->
case biLastFailureAt internals of
Just lastFail
| diffUTCTime now lastFail >= cbCooldownPeriod config -> do
writeTVar var internals { biState = HalfOpen }
pure AllowRequest
_ -> pure RejectRequest
HalfOpen -> pure AllowRequest
Closed -> pure AllowRequest
case decision of
RejectRequest -> pure (Left CircuitBreakerOpen)
AllowRequest -> do
result <- runService inner req
now' <- getCurrentTime
atomically $ do
internals <- readTVar var
case result of
Right _ -> writeTVar var BreakerInternals
{ biState = Closed
, biFailureCount = 0
, biLastFailureAt = Nothing
}
Left _ ->
case biState internals of
HalfOpen -> writeTVar var BreakerInternals
{ biState = Open
, biFailureCount = cbFailureThreshold config
, biLastFailureAt = Just now'
}
_ -> do
let newCount = biFailureCount internals + 1
if newCount >= cbFailureThreshold config
then writeTVar var BreakerInternals
{ biState = Open
, biFailureCount = newCount
, biLastFailureAt = Just now'
}
else writeTVar var internals
{ biFailureCount = newCount
, biLastFailureAt = Just now'
}
pure result
data Decision = AllowRequest | RejectRequest