packages feed

acolyte-client-0.1.0.0: src/Acolyte/Client/Retry.hs

-- | Retry policies for HTTP client calls.
--
-- Wraps IO actions with configurable retry logic including
-- exponential backoff and status-code-based retry decisions.
module Acolyte.Client.Retry
  ( RetryPolicy (..)
  , defaultRetryPolicy
  , noRetry
  , exponentialBackoff
  , withRetry
  ) where

import Control.Concurrent (threadDelay)
import Data.ByteString (ByteString)
import qualified Network.HTTP.Client as HC
import Network.HTTP.Types (Status, statusCode)


-- | Configuration for retrying failed HTTP requests.
data RetryPolicy = RetryPolicy
  { rpMaxRetries  :: !Int            -- ^ Maximum number of retries (default: 3)
  , rpBaseDelay   :: !Int            -- ^ Base delay in microseconds (default: 100000 = 100ms)
  , rpMaxDelay    :: !Int            -- ^ Maximum delay cap in microseconds (default: 5000000 = 5s)
  , rpShouldRetry :: Status -> Bool  -- ^ Which status codes to retry
  }


-- | Sensible defaults: 3 retries, 100ms base, 5s cap, retry on 502/503/504.
defaultRetryPolicy :: RetryPolicy
defaultRetryPolicy = RetryPolicy
  { rpMaxRetries  = 3
  , rpBaseDelay   = 100000
  , rpMaxDelay    = 5000000
  , rpShouldRetry = \s -> statusCode s `elem` [502, 503, 504]
  }


-- | Never retry.
noRetry :: RetryPolicy
noRetry = RetryPolicy
  { rpMaxRetries  = 0
  , rpBaseDelay   = 0
  , rpMaxDelay    = 0
  , rpShouldRetry = const False
  }


-- | Exponential backoff with the given max retries and base delay (microseconds).
-- Retries on 502, 503, 504 by default.
exponentialBackoff :: Int -> Int -> RetryPolicy
exponentialBackoff maxRetries baseDelayUs = RetryPolicy
  { rpMaxRetries  = maxRetries
  , rpBaseDelay   = baseDelayUs
  , rpMaxDelay    = baseDelayUs * (2 ^ maxRetries)
  , rpShouldRetry = \s -> statusCode s `elem` [502, 503, 504]
  }


-- | Wrap an IO action with retry logic.
--
-- On each attempt, if the response status matches the retry predicate
-- and we haven't exhausted retries, wait with exponential backoff then retry.
-- The final attempt (or a non-retryable response) is returned as-is.
withRetry :: RetryPolicy -> IO (HC.Response ByteString) -> IO (HC.Response ByteString)
withRetry policy action = go 0
  where
    go attempt
      | attempt >= rpMaxRetries policy = action
      | otherwise = do
          resp <- action
          if rpShouldRetry policy (HC.responseStatus resp) && attempt < rpMaxRetries policy
            then do
              let delay = min (rpMaxDelay policy) (rpBaseDelay policy * (2 ^ attempt))
              threadDelay delay
              go (attempt + 1)
            else pure resp