packages feed

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

{-# LANGUAGE ScopedTypeVariables #-}

module Glue.RetrySpec (spec) where

import           Control.Concurrent       (throwTo)
import           Control.Concurrent.Async (async, asyncThreadId, waitCatch)
import           Control.Exception.Base   (AsyncException (UserInterrupt))
import           Control.Monad.IO.Class (liftIO)
import           Data.IORef
import           Glue.Clock
import           Glue.Retry
import           Test.Hspec
import           Test.QuickCheck
import           UnliftIO.Exception     (Exception, fromException, throwIO, try)
import           UnliftIO.STM           (atomically, newEmptyTMVarIO, putTMVar,
                                         readTMVar)

newtype SmallInt = SmallInt Int
  deriving (Eq, Show)

instance Arbitrary SmallInt where
  arbitrary = sized $ \s -> do
    n <- choose (0, s `min` 10)
    pure $ SmallInt n

data RetryTestException = RetryTestException
  deriving (Eq, Show)

instance Exception RetryTestException

newtype NotRetryableException = NotRetryableException Int
  deriving (Eq, Show)

instance Exception NotRetryableException

-- | A clock that records each sleep (in milliseconds) instead of sleeping.
recordingClock :: IORef [Int] -> Clock IO
recordingClock ref =
  Clock
    { clockNow     = pure 0
    , clockSleep   = \us -> atomicModifyIORef' ref (\s -> (s ++ [us `div` 1000], ()))
    , clockTimeout = \_ action -> Just <$> action
    }

-- | A service failing the given number of times before succeeding, counting calls.
failingTimes :: IORef Int -> Int -> Int -> IO Int
failingTimes ref failures req = do
  counter <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))
  if counter > failures then pure req else throwIO RetryTestException

withSleeps :: (IORef [Int] -> IO ()) -> IO [Int]
withSleeps body = do
  sleeps <- newIORef []
  body sleeps
  readIORef sleeps

alwaysFailing :: IO (Int -> IO Int)
alwaysFailing = do
  ref <- newIORef 0
  pure (failingTimes ref maxBound)

spec :: Spec
spec = do
  describe "retryingService" $ do
    it "Attempts a service call multiple times" $
      property $ \(request, SmallInt failures, SmallInt retries) -> do
        ref <- liftIO $ newIORef 0
        let options      = defaultRetryOptions { maximumRetries = retries }
            retryService = retryingService options (failingTimes ref failures)
            successCase  = retryService request `shouldReturn` (request :: Int)
            failureCase  = retryService request `shouldThrow` (== RetryTestException)
        if retries >= failures then successCase else failureCase

    it "Asynchronous exceptions are rethrown" $
      property $ \(request, retries) -> do
        ref     <- liftIO $ newIORef (0 :: Int)
        started <- newEmptyTMVarIO
        let service req = do
              atomicModifyIORef' ref (\c -> (c + 1, ()))
              atomically $ putTMVar started ()
              never <- newEmptyTMVarIO
              atomically $ readTMVar never :: IO ()
              pure (req * 2 :: Int)
            options      = defaultRetryOptions { maximumRetries = retries }
            retryService = retryingService options service
        call <- async (retryService request)
        atomically $ readTMVar started
        throwTo (asyncThreadId call) UserInterrupt
        result <- waitCatch call
        either fromException (const Nothing) result `shouldBe` Just UserInterrupt
        readIORef ref `shouldReturn` 1

    it "Does not retry a failure rejected by retryOn" $ do
      ref <- newIORef (0 :: Int)
      let service req = atomicModifyIORef' ref (\c -> (c + 1, ())) >> throwIO (NotRetryableException req) :: IO Int
          retryable e = maybe True (const False) (fromException e :: Maybe NotRetryableException)
          retryService = retryingService defaultRetryOptions { retryOn = retryable } service
      retryService 4 `shouldThrow` (== NotRetryableException 4)
      readIORef ref `shouldReturn` 1

    it "Does not retry a request rejected by retryAllowed" $ do
      ref <- newIORef 0
      let retryService = retryingService defaultRetryOptions { retryAllowed = (> 0) } (failingTimes ref 1)
      retryService 0 `shouldThrow` (== RetryTestException)
      readIORef ref `shouldReturn` 1

    it "Waits according to the backoff, capped by the maximum delay" $ do
      service <- alwaysFailing
      sleeps <- withSleeps $ \ref -> do
        let options = defaultRetryOptions
              { maximumRetries  = 5
              , retryBackoff    = ExponentialBackoff 100 2
              , retryMaxDelayMs = Just 300
              , retryClock      = recordingClock ref
              }
        retryingService options service 1 `shouldThrow` (== RetryTestException)
      sleeps `shouldBe` [100, 200, 300, 300, 300]

    it "Computes constant, linear and exponential backoffs" $ do
      fmap (backoffDelayMs (ConstantBackoff 50)) [0 .. 3] `shouldBe` [50, 50, 50, 50]
      fmap (backoffDelayMs (LinearBackoff 50 25)) [0 .. 3] `shouldBe` [50, 75, 100, 125]
      fmap (backoffDelayMs (ExponentialBackoff 50 3)) [0 .. 3] `shouldBe` [50, 150, 450, 1350]

    it "Keeps jittered waits within their bounds" $
      property $ \(randoms :: [NonNegative Int], equal :: Bool) -> do
        source <- newIORef (cycle (0 : fmap getNonNegative randoms))
        service <- alwaysFailing
        let random n = atomicModifyIORef' source (\rs -> (drop 1 rs, head' rs `mod` (n + 1)))
            head' = foldr const 0
        sleeps <- withSleeps $ \ref -> do
          let options = defaultRetryOptions
                { maximumRetries  = 6
                , retryBackoff    = ExponentialBackoff 10 2
                , retryMaxDelayMs = Just 200
                , retryJitter     = if equal then EqualJitter else FullJitter
                , retryRandom     = random
                , retryClock      = recordingClock ref
                }
          retryingService options service 1 `shouldThrow` (== RetryTestException)
        let caps = [10, 20, 40, 80, 160, 200]
            lower cap = if equal then cap `div` 2 else 0
        sleeps `shouldSatisfy` (\s -> and (zipWith (\d cap -> d >= lower cap && d <= cap) s caps) && length s == 6)

    it "Skips a retry whose wait would pass the deadline, returning the last failure" $ do
      ref     <- newIORef (0 :: Int)
      retries <- newIORef []
      let service req = do
            n <- atomicModifyIORef' ref (\c -> (c + 1, c + 1))
            throwIO (NotRetryableException (req + n)) :: IO Int
      sleeps <- withSleeps $ \sleepRef -> do
        let options = defaultRetryOptions
              { maximumRetries   = 5
              , retryBackoff     = LinearBackoff 100 100
              , retryRemainingMs = pure (Just 150)
              , onRetry          = \attempt _ delay -> atomicModifyIORef' retries (\r -> (r ++ [(attempt, delay)], ()))
              , retryClock       = recordingClock sleepRef
              }
        retryingService options service 10 `shouldThrow` (== NotRetryableException 12)
      sleeps `shouldBe` [100]
      readIORef retries `shouldReturn` [(1, 100)]

    it "Does not retry once the deadline has passed" $ do
      ref <- newIORef 0
      let options = defaultRetryOptions { retryRemainingMs = pure (Just 0) }
      retryingService options (failingTimes ref 1) 1 `shouldThrow` (== RetryTestException)
      readIORef ref `shouldReturn` 1

  describe "retryingServiceEither" $ do
    it "Retries Left failures accepted by retryOn and returns the last one" $ do
      ref <- newIORef (0 :: Int)
      let service (_ :: Int) = atomicModifyIORef' ref (\c -> (c + 1, Left (c + 1) :: Either Int Int))
          options = defaultRetryOptions { maximumRetries = 5, retryOn = (< 3) }
      retryingServiceEither options service 1 `shouldReturn` Left 3
      readIORef ref `shouldReturn` 3

    it "Behaves like the exception shape for the same outcomes" $
      property $ \(outcomes :: [Bool], SmallInt retries) -> do
        exceptionRef <- newIORef outcomes
        valueRef     <- newIORef outcomes
        let next ref = atomicModifyIORef' ref (\o -> case o of { (x : xs) -> (xs, x); [] -> ([], True) })
            exceptional (req :: Int) = next exceptionRef >>= \ok -> if ok then pure req else throwIO RetryTestException
            valued (req :: Int) = next valueRef >>= \ok -> pure (if ok then Right req else Left RetryTestException)
            options :: RetryOptions IO Int f
            options = defaultRetryOptions { maximumRetries = retries }
        viaException <- try (retryingService options exceptional 7)
        viaValue     <- retryingServiceEither options valued 7
        viaException `shouldBe` viaValue
        remaining <- readIORef valueRef
        readIORef exceptionRef `shouldReturn` remaining