shibuya-core-0.8.0.0: src/Shibuya/Core/Retry.hs
-- | Exponential backoff policy and pure evaluator.
--
-- Handlers compute a retry delay from a backoff policy and the current
-- delivery attempt. The pure evaluator takes a jitter sample in @[0,1)@
-- and is suitable for property tests.
module Shibuya.Core.Retry
( -- * Policy
BackoffPolicy (..),
Jitter (..),
defaultBackoffPolicy,
-- * Pure evaluator
exponentialBackoffPure,
-- * Effectful evaluator
exponentialBackoff,
-- * Handler convenience
retryWithBackoff,
)
where
import Data.Maybe (fromMaybe)
import Data.Time (NominalDiffTime, nominalDiffTimeToSeconds, secondsToNominalDiffTime)
import Effectful (Eff, IOE, liftIO, (:>))
import GHC.Generics (Generic)
import Shibuya.Core.Ack (AckDecision (..), RetryDelay (..))
import Shibuya.Core.Types (Attempt (..), Envelope (..))
import System.Random qualified as Random
-- | Strategy for adding randomness to backoff delays.
-- Jitter prevents thundering herds when many messages fail simultaneously.
--
-- See <https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/>.
data Jitter
= -- | No randomness; delay is deterministic.
NoJitter
| -- | Delay is uniform in @[0, baseExp]@ where @baseExp@ is
-- @min maxDelay (base * factor^attempt)@.
FullJitter
| -- | Delay is @baseExp / 2 + uniform(0, baseExp / 2)@.
EqualJitter
deriving stock (Eq, Show, Generic)
-- | Configuration for exponential backoff with optional jitter.
data BackoffPolicy = BackoffPolicy
{ -- | Base delay applied at attempt 0 (before jitter).
base :: !NominalDiffTime,
-- | Multiplicative growth factor between attempts. Typically 2.0.
factor :: !Double,
-- | Upper bound on the computed delay before jitter.
-- Prevents arbitrarily long retries far in the future.
maxDelay :: !NominalDiffTime,
-- | Jitter strategy.
jitter :: !Jitter
}
deriving stock (Eq, Show, Generic)
-- | Sensible defaults: 1 s base, factor 2, max 5 min, full jitter.
--
-- This matches AWS's published recommendation. With these defaults:
--
-- - Attempt 0: random delay in [0, 1) s
-- - Attempt 1: random delay in [0, 2) s
-- - Attempt 2: random delay in [0, 4) s
-- - ...
-- - Attempt 8 onwards: random delay in [0, 256) s, capped at [0, 300) s
defaultBackoffPolicy :: BackoffPolicy
defaultBackoffPolicy =
BackoffPolicy
{ base = 1,
factor = 2.0,
maxDelay = 300,
jitter = FullJitter
}
-- | Compute a retry delay from a policy, an attempt count, and a jitter
-- sample in @[0, 1)@.
--
-- The sample is consumed only when the policy's 'jitter' is not 'NoJitter';
-- callers passing @0.0@ to a 'NoJitter' policy will see deterministic output.
exponentialBackoffPure ::
BackoffPolicy ->
Attempt ->
-- | Jitter sample in @[0, 1)@. Ignored when 'jitter' = 'NoJitter'.
Double ->
RetryDelay
exponentialBackoffPure policy (Attempt n) sample =
RetryDelay (secondsToNominalDiffTime (realToFrac jittered))
where
baseSec :: Double
baseSec = realToFrac (nominalDiffTimeToSeconds policy.base)
maxSec :: Double
maxSec = realToFrac (nominalDiffTimeToSeconds policy.maxDelay)
baseExp :: Double
baseExp = min maxSec (baseSec * policy.factor ** fromIntegral n)
clampedSample :: Double
clampedSample = max 0 (min 0.999999 sample)
jittered :: Double
jittered = case policy.jitter of
NoJitter -> baseExp
FullJitter -> baseExp * clampedSample
EqualJitter -> baseExp / 2 + (baseExp / 2) * clampedSample
-- | Compute a retry delay by sampling jitter from 'IO'.
--
-- Equivalent to 'exponentialBackoffPure' with a fresh sample drawn via
-- 'System.Random.randomRIO' on each call.
exponentialBackoff ::
(IOE :> es) =>
BackoffPolicy ->
Attempt ->
Eff es RetryDelay
exponentialBackoff policy attempt = do
sample <- liftIO (Random.randomRIO (0.0 :: Double, 1.0))
pure (exponentialBackoffPure policy attempt sample)
-- | One-line helper for the common case: read 'envelope.attempt', compute a
-- backoff delay, and return 'AckRetry'.
--
-- Treats @Nothing@ attempt as @Attempt 0@ (first delivery), so handlers
-- consuming envelopes from adapters that do not track redeliveries still
-- get a sensible base-delay retry.
retryWithBackoff ::
(IOE :> es) =>
BackoffPolicy ->
Envelope msg ->
Eff es AckDecision
retryWithBackoff policy envelope = do
let attempt = fromMaybe (Attempt 0) envelope.attempt
delay <- exponentialBackoff policy attempt
pure (AckRetry delay)