packages feed

glue-core-0.8.0.0: test/Glue/CircuitBreakerSpec.hs

{-# LANGUAGE ScopedTypeVariables #-}

module Glue.CircuitBreakerSpec (spec) where

import           Control.Concurrent.Async (async, cancel, wait)
import           Control.Monad            (forM, replicateM_)
import           Data.IORef
import           Glue.CircuitBreaker
import           Glue.Clock
import           Glue.Clock.Virtual
import           Test.Hspec
import           Test.QuickCheck
import           UnliftIO.Exception       (Exception, fromException, throwIO,
                                           try)
import           UnliftIO.STM             (atomically, newEmptyTMVarIO,
                                           putTMVar, readTMVar)

data CircuitBreakerTestException = CircuitBreakerTestException
  deriving (Eq, Show)

instance Exception CircuitBreakerTestException

newtype BadRequestException = BadRequestException Int
  deriving (Eq, Show)

instance Exception BadRequestException

type Outcome = Either CircuitBreakerException (Either CircuitBreakerTestException Int)

failing :: IORef Int -> Int -> IO Int
failing ref _ = atomicModifyIORef' ref (\c -> (c + 1, ())) >> throwIO CircuitBreakerTestException

callAll :: (Int -> IO Int) -> [Int] -> IO [Outcome]
callAll service = traverse (try . try . service)

options :: VirtualClock -> CircuitBreakerOptions IO failure
options vc = defaultCircuitBreakerOptions { breakerClock = virtualClock vc }

openFailure :: Outcome
openFailure = Left (CircuitBreakerException "Circuit breaker open.")

-- | A scripted outcome for comparing the exception and value shapes.
data Step = Succeed Int | Fail Bool | Wait Int
  deriving (Show)

stepValue :: Step -> Int
stepValue (Succeed n) = n
stepValue (Wait n)    = n
stepValue (Fail _)    = 0

-- | A failure carrying whether it should count against the breaker.
newtype StepFailure = StepFailure Bool
  deriving (Eq, Show)

instance Exception StepFailure

instance Arbitrary Step where
  arbitrary = oneof [Succeed <$> arbitrary, Fail <$> arbitrary, Wait <$> choose (0, 30)]

spec :: Spec
spec = do
  describe "circuitBreaker" $ do
    it "Opens on the failure number given by the threshold" $
      property $ \(threshold' :: Int) -> do
        let threshold = 1 + abs threshold' `mod` 5
        vc  <- newVirtualClock
        ref <- newIORef 0
        (s, service) <- circuitBreaker ((options vc) { breakerFailureThreshold = threshold }) (failing ref)
        results <- callAll service [1 .. 10]
        results `shouldBe` replicate threshold (Right (Left CircuitBreakerTestException)) ++ replicate (10 - threshold) openFailure
        isCircuitBreakerOpen s `shouldReturn` True
        isCircuitBreakerClosed s `shouldReturn` False
        readIORef ref `shouldReturn` threshold

    it "Successful calls pass straight through" $
      property $ \(requestIDs :: [Int]) -> do
        (s, service) <- circuitBreaker defaultCircuitBreakerOptions (\req -> pure (req + 1) :: IO Int)
        traverse service requestIDs `shouldReturn` fmap (+ 1) requestIDs
        isCircuitBreakerClosed s `shouldReturn` True

    it "Counts consecutive failures, a success resetting the count" $ do
      vc <- newVirtualClock
      (s, service) <- circuitBreaker (options vc) $ \(req :: Int) ->
        if req > 0 then pure req else throwIO CircuitBreakerTestException
      _ <- callAll service [0, 0, 1, 0, 0, 1, 0, 0]
      isCircuitBreakerClosed s `shouldReturn` True
      _ <- callAll service [0]
      isCircuitBreakerOpen s `shouldReturn` True

    it "With a failure window, only failures within the window count" $ do
      vc  <- newVirtualClock
      ref <- newIORef 0
      (s, service) <- circuitBreaker ((options vc) { breakerFailureWindowMs = Just 1000 }) (failing ref)
      replicateM_ 5 $ do
        _ <- callAll service [1, 1]
        advanceClock vc 1000000
      isCircuitBreakerClosed s `shouldReturn` True
      _ <- callAll service [1, 1]
      advanceClock vc 999000
      _ <- callAll service [1]
      isCircuitBreakerOpen s `shouldReturn` True

    it "A call cancelled by an asynchronous exception does not count as a failure" $ do
      vc      <- newVirtualClock
      started <- newEmptyTMVarIO
      (s, service) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1 }) $ \(req :: Int) -> do
        atomically $ putTMVar started ()
        clockSleep (virtualClock vc) 1000000
        pure req
      call <- async (service 1)
      atomically $ readTMVar started
      cancel call
      isCircuitBreakerClosed s `shouldReturn` True

    it "Stays open for the open duration, then admits a single trial" $ do
      vc   <- newVirtualClock
      gate <- newEmptyTMVarIO
      healthy <- newIORef False
      let service (req :: Int) = do
            ok <- readIORef healthy
            if ok then atomically (readTMVar gate) >> pure req else throwIO CircuitBreakerTestException
      (s, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1, breakerOpenDurationMs = 500 }) service
      _ <- callAll breaker [1]
      advanceClock vc 499000
      callAll breaker [1] `shouldReturn` [openFailure]
      advanceClock vc 1000
      writeIORef healthy True
      trial <- async (breaker 7)
      let waitForTrial = circuitBreakerPhases s >>= \p -> if p == [BreakerHalfOpen] then pure () else waitForTrial
      waitForTrial
      callAll breaker [1] `shouldReturn` [openFailure]
      atomically $ putTMVar gate ()
      wait trial `shouldReturn` 7
      isCircuitBreakerClosed s `shouldReturn` True

    it "A failed trial opens the circuit again for the open duration" $ do
      vc  <- newVirtualClock
      ref <- newIORef 0
      (s, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1, breakerOpenDurationMs = 500 }) (failing ref)
      _ <- callAll breaker [1]
      advanceClock vc 500000
      callAll breaker [1, 1] `shouldReturn` [Right (Left CircuitBreakerTestException), openFailure]
      circuitBreakerPhases s `shouldReturn` [BreakerOpen]
      readIORef ref `shouldReturn` 2

    it "Failures rejected by countsAsFailure do not count" $ do
      vc <- newVirtualClock
      let counts e = maybe True (const False) (fromException e :: Maybe BadRequestException)
      (s, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 1, countsAsFailure = counts }) $ \(req :: Int) ->
        throwIO (BadRequestException req) :: IO Int
      results <- forM [1 .. 5] (try . breaker)
      results `shouldBe` fmap (Left . BadRequestException) [1 .. 5]
      isCircuitBreakerClosed s `shouldReturn` True

    it "Reports each transition to the observer" $ do
      vc  <- newVirtualClock
      seen <- newIORef []
      ref <- newIORef 0
      healthy <- newIORef False
      let service (req :: Int) = readIORef healthy >>= \ok -> if ok then pure req else failing ref req
          observe t = atomicModifyIORef' seen (\ts -> (ts ++ [t], ()))
      (_, breaker) <- circuitBreaker ((options vc) { breakerFailureThreshold = 2, breakerOpenDurationMs = 10, onBreakerTransition = observe }) service
      _ <- callAll breaker [1, 1]
      advanceClock vc 10000
      _ <- callAll breaker [1]
      advanceClock vc 10000
      writeIORef healthy True
      _ <- callAll breaker [1]
      readIORef seen `shouldReturn`
        [ BreakerTransition BreakerClosed BreakerOpen
        , BreakerTransition BreakerOpen BreakerHalfOpen
        , BreakerTransition BreakerHalfOpen BreakerOpen
        , BreakerTransition BreakerOpen BreakerHalfOpen
        , BreakerTransition BreakerHalfOpen BreakerClosed
        ]

    it "combineCircuitBreakerStates reports open if any breaker is open" $ do
      let opts = (defaultCircuitBreakerOptions :: CircuitBreakerOptions IO e) { breakerFailureThreshold = 1 }
      (s1, svc1) <- circuitBreaker opts (\(_ :: Int) -> throwIO CircuitBreakerTestException :: IO Int)
      (s2, _)    <- circuitBreaker opts (\(x :: Int) -> pure (x + 1) :: IO Int)
      let combined = combineCircuitBreakerStates [s1, s2]
      isCircuitBreakerOpen combined `shouldReturn` False
      isCircuitBreakerClosed combined `shouldReturn` True
      _ <- try (svc1 1) :: IO (Either CircuitBreakerTestException Int)
      isCircuitBreakerOpen combined `shouldReturn` True
      isCircuitBreakerClosed combined `shouldReturn` False

  describe "circuitBreakerWithStore" $
    it "Never calls the service when the store refuses" $ do
      ref <- newIORef 0
      let store = BreakerStore (pure (Refused, Nothing)) (\_ _ -> pure Nothing) (pure BreakerOpen)
          breaker = circuitBreakerWithStore defaultCircuitBreakerOptions store (failing ref)
      callAll breaker [1, 2] `shouldReturn` [openFailure, openFailure]
      readIORef ref `shouldReturn` 0

  describe "circuitBreakerEither" $ do
    it "Counts Left failures and refuses with the given value" $ do
      vc <- newVirtualClock
      (s, breaker) <- circuitBreakerEither ((options vc) { breakerFailureThreshold = 2 }) "open" $ \(_ :: Int) ->
        pure (Left "down" :: Either String Int)
      traverse breaker [1, 2, 3] `shouldReturn` [Left "down", Left "down", Left "open"]
      isCircuitBreakerOpen s `shouldReturn` True

    it "Behaves like the exception shape for the same outcomes" $
      property $ \(steps :: [Step]) -> do
        vc1 <- newVirtualClock
        vc2 <- newVirtualClock
        let opts vc = (options vc) { breakerFailureThreshold = 2, breakerOpenDurationMs = 20 }
            countsException e = maybe True (\(StepFailure c) -> c) (fromException e)
            asException (Fail c) = throwIO (StepFailure c)
            asException step     = pure (stepValue step)
            asValue (Fail c) = pure (Left (Just c))
            asValue step     = pure (Right (stepValue step))
        (_, exceptional) <- circuitBreaker ((opts vc1) { countsAsFailure = countsException }) asException
        (_, valued)      <- circuitBreakerEither ((opts vc2) { countsAsFailure = or }) Nothing asValue
        let run step = do
              e <- try (try (exceptional step))
              v <- valued step
              case step of
                Wait n -> advanceClock vc1 (n * 1000) >> advanceClock vc2 (n * 1000)
                _      -> pure ()
              let normalised = case e of
                    Left (CircuitBreakerException _) -> Left Nothing
                    Right (Left (StepFailure c))     -> Left (Just c)
                    Right (Right n)                  -> Right n
              pure (normalised, v)
        pairs <- traverse run steps
        fmap fst pairs `shouldBe` fmap snd pairs