tower-hs-0.2.0.0: test/Tower/Middleware/CircuitBreakerSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NumericUnderscores #-}
module Tower.Middleware.CircuitBreakerSpec (spec) where
import Control.Concurrent (threadDelay)
import Data.IORef
import Test.Hspec
import Tower.Service
import Tower.Error
import Tower.Error.Testing ()
import Data.Function ((&))
import Tower.Middleware.CircuitBreaker
spec :: Spec
spec = describe "Circuit Breaker middleware" $ do
let config = CircuitBreakerConfig
{ cbFailureThreshold = 3
, cbCooldownPeriod = 1 -- 1 second
}
describe "Closed state" $ do
it "passes requests through when healthy" $ do
breaker <- newCircuitBreaker
let svc :: Service () String
svc = Service $ \_ -> pure (Right "ok")
wrapped = svc & withCircuitBreaker config breaker
result <- runService wrapped ()
result `shouldBe` Right "ok"
getCircuitBreakerState breaker >>= (`shouldBe` Closed)
it "stays closed on fewer failures than threshold" $ do
breaker <- newCircuitBreaker
callCount <- newIORef (0 :: Int)
let svc :: Service () String
svc = Service $ \_ -> do
modifyIORef' callCount (+ 1)
pure (Left (CustomError "fail"))
wrapped = svc & withCircuitBreaker config breaker
-- 2 failures, threshold is 3
_ <- runService wrapped ()
_ <- runService wrapped ()
getCircuitBreakerState breaker >>= (`shouldBe` Closed)
readIORef callCount >>= (`shouldBe` 2)
describe "Tripping open" $ do
it "trips open after reaching failure threshold" $ do
breaker <- newCircuitBreaker
let svc :: Service () String
svc = Service $ \_ -> pure (Left (CustomError "fail"))
wrapped = svc & withCircuitBreaker config breaker
-- 3 failures = threshold
_ <- runService wrapped ()
_ <- runService wrapped ()
_ <- runService wrapped ()
getCircuitBreakerState breaker >>= (`shouldBe` Open)
it "rejects requests immediately when open" $ do
breaker <- newCircuitBreaker
callCount <- newIORef (0 :: Int)
let failSvc :: Service () String
failSvc = Service $ \_ -> do
modifyIORef' callCount (+ 1)
pure (Left (CustomError "fail"))
wrapped = failSvc & withCircuitBreaker config breaker
-- Trip the breaker
_ <- runService wrapped ()
_ <- runService wrapped ()
_ <- runService wrapped ()
-- Next request should be rejected without calling the service
countBefore <- readIORef callCount
result <- runService wrapped ()
countAfter <- readIORef callCount
result `shouldBe` Left CircuitBreakerOpen
countAfter `shouldBe` countBefore -- service was NOT called
describe "Half-open state" $ do
it "transitions to half-open after cooldown" $ do
let fastConfig = config { cbCooldownPeriod = 0.1 } -- 100ms cooldown
breaker <- newCircuitBreaker
let svc :: Service () String
svc = Service $ \_ -> pure (Left (CustomError "fail"))
wrapped = svc & withCircuitBreaker fastConfig breaker
-- Trip the breaker
_ <- runService wrapped ()
_ <- runService wrapped ()
_ <- runService wrapped ()
getCircuitBreakerState breaker >>= (`shouldBe` Open)
-- Wait for cooldown
threadDelay 150_000 -- 150ms
-- Next request should go through (half-open allows one probe)
_ <- runService wrapped ()
-- It failed again, so back to Open
getCircuitBreakerState breaker >>= (`shouldBe` Open)
it "resets to closed on success in half-open" $ do
let fastConfig = config { cbCooldownPeriod = 0.1 }
breaker <- newCircuitBreaker
callCount <- newIORef (0 :: Int)
let svc :: Service () String
svc = Service $ \_ -> do
n <- readIORef callCount
modifyIORef' callCount (+ 1)
if n < 3
then pure (Left (CustomError "fail"))
else pure (Right "recovered")
wrapped = svc & withCircuitBreaker fastConfig breaker
-- Trip the breaker (3 failures)
_ <- runService wrapped ()
_ <- runService wrapped ()
_ <- runService wrapped ()
getCircuitBreakerState breaker >>= (`shouldBe` Open)
-- Wait for cooldown
threadDelay 150_000
-- Next request succeeds — should reset to Closed
result <- runService wrapped ()
result `shouldBe` Right "recovered"
getCircuitBreakerState breaker >>= (`shouldBe` Closed)
describe "Reset on success" $ do
it "resets failure count on any success" $ do
breaker <- newCircuitBreaker
callCount <- newIORef (0 :: Int)
let svc :: Service () String
svc = Service $ \_ -> do
n <- readIORef callCount
modifyIORef' callCount (+ 1)
if n == 1 -- second call succeeds
then pure (Right "ok")
else pure (Left (CustomError "fail"))
wrapped = svc & withCircuitBreaker config breaker
-- Fail once
_ <- runService wrapped ()
-- Succeed — resets counter
_ <- runService wrapped ()
getCircuitBreakerState breaker >>= (`shouldBe` Closed)
-- Now need 3 MORE failures to trip (not 2)
_ <- runService wrapped () -- fail
_ <- runService wrapped () -- fail
getCircuitBreakerState breaker >>= (`shouldBe` Closed)