packages feed

glue-core-0.7.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.
module Glue.Retry
  ( RetryOptions (..)
  , defaultRetryOptions
  , retryingService
  ) where

import           Control.Monad       (when)
import           Glue.Types
import           UnliftIO            (MonadUnliftIO, SomeAsyncException,
                                      SomeException, fromException)
import           UnliftIO.Concurrent (threadDelay)
import           UnliftIO.Exception  (catch, throwIO)

-- | Options for determining behaviour of retrying services.
data RetryOptions a = RetryOptions
  { retryAllowed            :: a -> Bool
  -- ^ Predicate for determining if we can retry a call, can be used to prevent retries on non-idempotent operations.
  , retryInitialWaitTimeMs  :: Int
  -- ^ Amount of time (in milliseconds) to wait after the first failure.
  , maximumRetries          :: Int
  -- ^ The upper bound on how many retry attempts to make when invoking the service.
  , retryWaitTimeMultiplier :: Double
  -- ^ How much to multiply 'retryInitialWaitTimeMs' by for each subsequent retry.
  }

-- | Defaulted options for retrying 3 times with no wait time.
defaultRetryOptions :: RetryOptions a
defaultRetryOptions =
  RetryOptions
    { retryAllowed            = const True
    , retryInitialWaitTimeMs  = 0
    , maximumRetries          = 3
    , retryWaitTimeMultiplier = 0
    }

possibleAsyncException :: SomeException -> Maybe SomeAsyncException
possibleAsyncException = fromException

-- | 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
  :: forall m a b.
     MonadUnliftIO m
  => RetryOptions a
  -- ^ Instance of t'RetryOptions' to configure the retry functionality.
  -> BasicService m a b
  -- ^ The service to perform retries of.
  -> BasicService m a b
retryingService options service =
  let catchHandler rc r e =
        case possibleAsyncException e of
          Just ae -> throwIO ae
          Nothing -> wait rc >> attempt (rc + 1) r
      attempt retryCount request =
        if retryAllowed options request && maxRetries > retryCount
          then catch (service request) (catchHandler retryCount request)
          else service request
      maxRetries = maximumRetries options
      initialUs  = retryInitialWaitTimeMs options * 1000
      multiplier =
        if retryWaitTimeMultiplier options <= 0
          then 1.0
          else retryWaitTimeMultiplier options
      wait :: Int -> m ()
      wait retryCount =
        when (initialUs > 0) $
          threadDelay $ round $ fromIntegral initialUs * (multiplier ^ retryCount)
  in  attempt 0