packages feed

glue-core-0.7.0: src/Glue/CircuitBreaker.hs

{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module containing circuit breaker functionality, which is the ability to open a circuit
-- once a number of failures have occurred, thereby preventing later calls from attempting
-- to make unsuccessful calls.
-- Often this is useful if the underlying service were to repeatedly time out, so as to
-- reduce the number of calls inflight holding up upstream callers.
module Glue.CircuitBreaker
  ( CircuitBreakerOptions (..)
  , CircuitBreakerState
  , CircuitBreakerException (..)
  , combineCircuitBreakerStates
  , isCircuitBreakerOpen
  , isCircuitBreakerClosed
  , defaultCircuitBreakerOptions
  , circuitBreaker
  ) where

import           Data.Time.Clock.POSIX (getPOSIXTime)
import           Glue.Types
import           UnliftIO              (Exception, MonadIO, MonadUnliftIO,
                                        liftIO)
import           UnliftIO.Exception    (bracketOnError, throwIO)
import           UnliftIO.IORef        (IORef, atomicModifyIORef', newIORef,
                                        readIORef, writeIORef)

-- | Options for determining behaviour of circuit breaking services.
data CircuitBreakerOptions = CircuitBreakerOptions
  { maxBreakerFailures :: Int
  -- ^ How many times the underlying service must fail in the given window before the circuit opens.
  , resetTimeoutSecs   :: Int
  -- ^ The window of time in which the underlying service must fail for the circuit to open.
  , breakerDescription :: String
  -- ^ Description that is attached to the failure so as to identify the particular circuit.
  }
  deriving (Eq, Show)

-- | Defaulted options for the circuit breaker with 3 failures over 60 seconds.
defaultCircuitBreakerOptions :: CircuitBreakerOptions
defaultCircuitBreakerOptions =
  CircuitBreakerOptions
    { maxBreakerFailures = 3
    , resetTimeoutSecs   = 60
    , breakerDescription = "Circuit breaker open."
    }

-- | Status indicating if the circuit is open.
data BreakerStatus
  = BreakerClosed Int
  | BreakerOpen Int
  deriving (Eq, Show)

-- | Representation of the state the circuit breaker is currently in.
newtype CircuitBreakerState = CircuitBreakerState [IORef BreakerStatus]

-- | Exception thrown when the circuit is open.
newtype CircuitBreakerException = CircuitBreakerException String
  deriving (Eq, Show)

instance Exception CircuitBreakerException

-- | Combines multiple states together.
combineCircuitBreakerStates :: Foldable t => t CircuitBreakerState -> CircuitBreakerState
combineCircuitBreakerStates = CircuitBreakerState . foldMap (\(CircuitBreakerState refs) -> refs)

-- | Determines if a specific status is open.
isStatusOpen :: BreakerStatus -> Bool
isStatusOpen (BreakerOpen _)   = True
isStatusOpen (BreakerClosed _) = False

-- | Determines if a specific status is closed.
isStatusClosed :: BreakerStatus -> Bool
isStatusClosed (BreakerOpen _)   = False
isStatusClosed (BreakerClosed _) = True

-- | Determines if a circuit breaker is open.
isCircuitBreakerOpen :: MonadIO m => CircuitBreakerState -> m Bool
isCircuitBreakerOpen (CircuitBreakerState states) =
  or <$> traverse (fmap isStatusOpen . readIORef) states

-- | Determines if a circuit breaker is closed.
isCircuitBreakerClosed :: MonadIO m => CircuitBreakerState -> m Bool
isCircuitBreakerClosed (CircuitBreakerState states) =
  and <$> traverse (fmap isStatusClosed . readIORef) states

-- | Circuit breaking services can be constructed with this function.
circuitBreaker
  :: (MonadUnliftIO m, MonadIO n)
  => CircuitBreakerOptions
  -- ^ Options for specifying the circuit breaker behaviour.
  -> BasicService m a b
  -- ^ Service to protect with the circuit breaker.
  -> n (CircuitBreakerState, BasicService m a b)
circuitBreaker options service = do
  let retrieveTime = liftIO $ round <$> getPOSIXTime
      failureMax = maxBreakerFailures options
      callIfClosed request ref = bracketOnError (pure ()) (\_ -> incErrors ref) (\_ -> service request)
      canaryCall request ref = do
        result <- callIfClosed request ref
        writeIORef ref $ BreakerClosed 0
        pure result
      incErrors ref = do
        currentTime <- retrieveTime
        atomicModifyIORef' ref $ \status -> case status of
          BreakerClosed errorCount ->
            ( if errorCount >= failureMax
                then BreakerOpen (currentTime + resetTimeoutSecs options)
                else BreakerClosed (errorCount + 1)
            , ()
            )
          other -> (other, ())
      failingCall = throwIO $ CircuitBreakerException $ breakerDescription options
      callIfOpen request ref = do
        currentTime <- retrieveTime
        canaryRequest <- atomicModifyIORef' ref $ \status -> case status of
          BreakerClosed _ -> (status, False)
          BreakerOpen time ->
            if currentTime > time
              then (BreakerOpen (currentTime + resetTimeoutSecs options), True)
              else (status, False)
        if canaryRequest then canaryCall request ref else failingCall
      breakerService ref request = do
        status <- readIORef ref
        case status of
          BreakerClosed _ -> callIfClosed request ref
          BreakerOpen _   -> callIfOpen request ref
  ref <- newIORef $ BreakerClosed 0
  pure (CircuitBreakerState [ref], breakerService ref)