packages feed

glue-core-0.8.0.0: src/Glue/Retry.hs

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

-- | Module containing retry functionality, allowing the construction of services that attempt multiple times in case of transient failure.
--
-- Whether to retry is decided by the failure ('retryOn') as well as the request ('retryAllowed').
-- Waits between attempts follow a 'Backoff', optionally capped and jittered, and a retry whose wait
-- would pass the caller's deadline ('retryRemainingMs') is skipped, returning the last failure.
-- Asynchronous exceptions are never retried.
module Glue.Retry
  ( RetryOptions (..)
  , Backoff (..)
  , Jitter (..)
  , defaultRetryOptions
  , backoffDelayMs
  , retryingService
  , retryingServiceEither
  ) where

import           Glue.Clock
import           Glue.Failure
import           Glue.Types
import           System.Random      (randomRIO)
import           UnliftIO           (MonadUnliftIO, SomeException, liftIO)

-- | How the wait before each retry grows.
data Backoff
  = ConstantBackoff Int
  -- ^ The same wait (in milliseconds) before every retry.
  | LinearBackoff Int Int
  -- ^ An initial wait, growing by the second value (both in milliseconds) for each retry.
  | ExponentialBackoff Int Double
  -- ^ An initial wait in milliseconds, multiplied by the second value for each retry.
  deriving (Eq, Show)

-- | Randomisation of the wait, so that many clients don't retry in lockstep.
-- See <https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/>.
data Jitter
  = NoJitter
  -- ^ Wait exactly the backoff.
  | FullJitter
  -- ^ Wait a uniformly random time between zero and the backoff.
  | EqualJitter
  -- ^ Wait half the backoff plus a uniformly random time up to the other half.
  deriving (Eq, Show)

-- | Options for determining behaviour of retrying services.
data RetryOptions m a failure = RetryOptions
  { retryAllowed     :: a -> Bool
  -- ^ Predicate for determining if we can retry a call, can be used to prevent retries on non-idempotent operations.
  , retryOn          :: failure -> Bool
  -- ^ Predicate on the failure: retry an unreachable backend or a timeout, say, but not a rejected request.
  , maximumRetries   :: Int
  -- ^ The upper bound on how many retry attempts to make when invoking the service.
  , retryBackoff     :: Backoff
  -- ^ How long to wait before each retry.
  , retryMaxDelayMs  :: Maybe Int
  -- ^ Ceiling on the wait before any retry, applied before jitter.
  , retryJitter      :: Jitter
  -- ^ Randomisation applied to the capped wait.
  , retryRandom      :: Int -> m Int
  -- ^ Source of randomness for jitter: a uniformly random value between zero and the argument inclusive.
  , retryRemainingMs :: m (Maybe Int)
  -- ^ Time left (in milliseconds) before the caller's deadline, if there is one. A retry whose wait
  -- would pass it is skipped. 'remainingUntil' builds this from a deadline on a t'Clock'.
  , onRetry          :: Int -> failure -> Int -> m ()
  -- ^ Called before each retry with the retry number (from 1), the failure and the chosen wait in milliseconds.
  , retryClock       :: Clock m
  -- ^ Clock used to wait between attempts.
  }

-- | Defaulted options for retrying any failure 3 times with no wait time and no deadline.
defaultRetryOptions :: MonadUnliftIO m => RetryOptions m a failure
defaultRetryOptions =
  RetryOptions
    { retryAllowed     = const True
    , retryOn          = const True
    , maximumRetries   = 3
    , retryBackoff     = ConstantBackoff 0
    , retryMaxDelayMs  = Nothing
    , retryJitter      = NoJitter
    , retryRandom      = \n -> liftIO $ randomRIO (0, max 0 n)
    , retryRemainingMs = pure Nothing
    , onRetry          = \_ _ _ -> pure ()
    , retryClock       = systemClock
    }

-- | The un-jittered, uncapped wait in milliseconds before the retry with the given index (from 0).
backoffDelayMs :: Backoff -> Int -> Int
backoffDelayMs (ConstantBackoff delay) _ = max 0 delay
backoffDelayMs (LinearBackoff initial step) n = max 0 (initial + step * n)
backoffDelayMs (ExponentialBackoff initial multiplier) n =
  let factor  = if multiplier <= 0 then 1 else multiplier
      delay   = fromIntegral (max 0 initial) * factor ^ n :: Double
  in  if delay >= fromIntegral (maxBound :: Int) then maxBound else round delay

chooseDelay :: Monad m => RetryOptions m a failure -> Int -> m Int
chooseDelay options n = do
  let capped = maybe id (min . max 0) (retryMaxDelayMs options) (backoffDelayMs (retryBackoff options) n)
  case retryJitter options of
    NoJitter    -> pure capped
    FullJitter  -> clamp capped <$> retryRandom options capped
    EqualJitter -> do
      let half = capped `div` 2
      (+ half) . clamp (capped - half) <$> retryRandom options (capped - half)
  where
    clamp upper = max 0 . min upper

retryVia :: Monad m => FailureMode m r failure -> RetryOptions m a failure -> BasicService m a r -> BasicService m a r
retryVia mode options service request = attempt 0
  where
    attempt retryCount = do
      outcome <- observeCall mode (service request)
      case outcome of
        Right result -> pure result
        Left failure
          | retryCount < maximumRetries options && retryAllowed options request && retryOn options failure -> do
              delay     <- chooseDelay options retryCount
              remaining <- retryRemainingMs options
              if maybe False (\left -> left <= 0 || delay > left) remaining
                then reraise mode failure
                else do
                  onRetry options (retryCount + 1) failure delay
                  clockSleep (retryClock options) (delay * 1000)
                  attempt (retryCount + 1)
          | otherwise -> reraise mode failure

-- | Retries a call to a service multiple times, potentially backing off wait times between subsequent calls.
-- Asynchronous exceptions don't result in a retry; they are immediately rethrown.
retryingService
  :: MonadUnliftIO m
  => RetryOptions m a SomeException
  -- ^ Instance of t'RetryOptions' to configure the retry functionality.
  -> BasicService m a b
  -- ^ The service to perform retries of.
  -> BasicService m a b
retryingService = retryVia exceptionFailures

-- | Variant of 'retryingService' for services returning failures as values: @Left@ results are
-- judged by 'retryOn', and the last @Left@ is returned once retrying stops. Exceptions are not caught.
retryingServiceEither
  :: Monad m
  => RetryOptions m a e
  -- ^ Instance of t'RetryOptions' to configure the retry functionality.
  -> EitherService m a e b
  -- ^ The service to perform retries of.
  -> EitherService m a e b
retryingServiceEither = retryVia valueFailures