packages feed

cachix-1.11.1: src/Cachix/Client/Retry.hs

module Cachix.Client.Retry
  ( -- * Whole-operation retry
    retryAll,

    -- * HTTP retry
    retryHttp,
    retryClientM,

    -- * Policies
    endlessConstantRetryPolicy,
  )
where

import Cachix.Client.Exception (CachixException)
import Control.Concurrent.Async qualified as Async
import Control.Exception.Safe (Handler (..))
import Control.Monad.Catch (MonadCatch, MonadMask, handleJust, throwM)
import Control.Retry
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as LBS
import Data.List (lookup)
import Data.Text qualified as T
import Data.Time
  ( UTCTime,
    defaultTimeLocale,
    diffUTCTime,
    getCurrentTime,
    nominalDiffTimeToSeconds,
    readPTime,
    rfc822DateFormat,
  )
import GHC.Read (Read (readPrec))
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Media qualified as Media
import Network.HTTP.Types.Header (Header, hContentType, hRetryAfter)
import Network.HTTP.Types.Status qualified as HTTP
import Protolude hiding (Handler (..), handleJust)
import Servant.Client (ClientEnv, ClientError (..))
import Servant.Client qualified as Servant
import Servant.Client.Streaming (ClientM, runClientM)
import Text.ParserCombinators.ReadPrec qualified as ReadPrec (lift)

-- Policies

-- | Per-HTTP-request policy. Wall-time bounded so the user-visible delay is
-- predictable regardless of how many attempts fit. Jittered exponential
-- backoff to avoid thundering-herd on shared origins (CDN incidents).
httpRetryPolicy :: (MonadIO m) => RetryPolicyM m
httpRetryPolicy =
  limitRetriesByCumulativeDelay (secondsToMicros 90) $
    capDelay (secondsToMicros 15) $
      fullJitterBackoff (secondsToMicros 1)

-- | Whole-operation policy used by 'retryAll'. Small attempt count with a
-- short delay, since each attempt re-runs the entire operation (e.g. a full
-- NAR re-upload). Compounds with 'httpRetryPolicy' on HTTP errors, so
-- keep this tight to bound worst-case wall time per store path.
retryAllPolicy :: RetryPolicy
retryAllPolicy =
  constantDelay (secondsToMicros 5) <> limitRetries 3

-- | Constant 1s delay, no attempt cap. For callers that own their own
-- termination condition (e.g. websocket reconnect loops).
endlessConstantRetryPolicy :: RetryPolicy
endlessConstantRetryPolicy =
  constantDelay (secondsToMicros 1)

-- Whole-operation retry

-- | Retry an entire operation on any synchronous exception, except for
-- 'ExitCode' and 'CachixException' (which are fatal). Use for operations
-- that re-run the full unit of work, like a NAR upload.
retryAll :: (MonadIO m, MonadMask m) => (RetryStatus -> m a) -> m a
retryAll f =
  recovering retryAllPolicy handlers $
    rethrowLinkedThreadExceptions . f
  where
    handlers = skipAsyncExceptions ++ [exitCodeHandler, cachixExceptionsHandler, allHandler]

    -- Skip over exitSuccess/exitFailure
    exitCodeHandler _ = Handler $ \(_ :: ExitCode) -> return False

    -- Skip over fatal Cachix exceptions
    cachixExceptionsHandler _ = Handler $ \(_ :: CachixException) -> return False

    -- Retry everything else
    allHandler _ = Handler $ \(_ :: SomeException) -> return True

-- HTTP retry

-- | Retry an HTTP operation that signals failure by throwing
-- 'HTTP.HttpException' or Servant 'ClientError'. Honors @Retry-After@ headers
-- and Cloudflare problem-details @retry_after@ bodies.
--
-- For Servant calls that return @Either ClientError a@ (e.g. 'runClientM'),
-- use 'retryClientM' instead — 'retryHttp' only triggers on thrown errors.
retryHttp :: (MonadIO m, MonadMask m) => m a -> m a
retryHttp = recoveringDynamic httpRetryPolicy handlers . const
  where
    handlers =
      map (fmap toRetryAction .) skipAsyncExceptions
        ++ [ \_ -> Handler httpExceptionToRetryAction,
             \_ -> Handler clientErrorToRetryAction
           ]

-- | Run a Servant client action with HTTP retries.
--
-- 'runClientM' returns 'Either ClientError a' instead of throwing, so wrapping
-- it with 'retryHttp' would never trigger the retry handlers. This inspects
-- the return value directly, sharing the same retry decision logic as
-- 'retryHttp'.
retryClientM :: (MonadIO m, NFData a) => ClientEnv -> ClientM a -> m (Either ClientError a)
retryClientM env req =
  retryingDynamic httpRetryPolicy checkResult $ const $ liftIO (runClientM req env)
  where
    checkResult _ (Right _) = pure DontRetry
    checkResult _ (Left err) = clientErrorToRetryAction err

-- Retry decisions
--
-- Decide whether a given failure is worth retrying, and at what delay. Shared
-- between 'retryHttp' (exception path) and 'retryClientM' (Either path).

-- | Whether an HTTP exception is worth retrying. Temporary connection or
-- network transfer errors are good candidates.
shouldRetryHttpException :: HTTP.HttpException -> Bool
shouldRetryHttpException (HTTP.InvalidUrlException _ _) = False
shouldRetryHttpException (HTTP.HttpExceptionRequest _ reason) =
  case reason of
    HTTP.ConnectionClosed -> True
    HTTP.ConnectionFailure _ -> True
    HTTP.ConnectionTimeout -> True
    HTTP.IncompleteHeaders -> True
    HTTP.InternalException _ -> True
    HTTP.InvalidChunkHeaders -> True
    HTTP.InvalidProxyEnvironmentVariable _ _ -> True
    HTTP.InvalidStatusLine _ -> True
    HTTP.NoResponseDataReceived -> True
    HTTP.ProxyConnectException _ _ status
      | HTTP.statusIsServerError status -> True
    HTTP.ResponseBodyTooShort _ _ -> True
    HTTP.ResponseTimeout -> True
    HTTP.StatusCodeException response _ ->
      shouldRetryHttpStatusCode (HTTP.responseStatus response)
    HTTP.HttpZlibException _ -> True
    _ -> False

-- | Whether an HTTP status code is worth retrying. 429 and any 5xx.
shouldRetryHttpStatusCode :: HTTP.Status -> Bool
shouldRetryHttpStatusCode code
  | code == HTTP.tooManyRequests429 = True
  | HTTP.statusIsServerError code = True
  | otherwise = False

-- | Convert an 'HTTP.HttpException' to a 'RetryAction', honoring any 'Retry-After' hints.
httpExceptionToRetryAction :: (MonadIO m) => HTTP.HttpException -> m RetryAction
httpExceptionToRetryAction ex@(HTTP.HttpExceptionRequest _ (HTTP.StatusCodeException response body))
  | shouldRetryHttpException ex =
      let headers = HTTP.responseHeaders response
       in overrideDelayWithRetryAfter $
            lookupRetryAfter headers
              <|> lookupRetryAfterBody headers (LBS.fromStrict body)
httpExceptionToRetryAction ex = pure $ toRetryAction $ shouldRetryHttpException ex

-- | Convert a 'ClientError' to a 'RetryAction', honoring any 'Retry-After' hints.
clientErrorToRetryAction :: (MonadIO m) => ClientError -> m RetryAction
clientErrorToRetryAction (FailureResponse _req res)
  | shouldRetryHttpStatusCode (Servant.responseStatusCode res) =
      let headers = toList (Servant.responseHeaders res)
       in overrideDelayWithRetryAfter $
            lookupRetryAfter headers
              <|> lookupRetryAfterBody headers (Servant.responseBody res)
clientErrorToRetryAction (ConnectionError ex) =
  case fromException ex of
    Just httpException -> httpExceptionToRetryAction httpException
    Nothing -> pure DontRetry
clientErrorToRetryAction _ = pure DontRetry

-- Retry-After
--
-- Parse and honor server hints about when to retry,
data RetryAfter
  = RetryAfterDate UTCTime
  | RetryAfterSeconds Int
  deriving (Eq, Show)

instance Read RetryAfter where
  readPrec = parseSeconds <|> parseWebDate
    where
      parseSeconds = RetryAfterSeconds <$> readPrec
      parseWebDate = ReadPrec.lift $ RetryAfterDate <$> readPTime True defaultTimeLocale rfc822DateFormat

-- | Honor a parsed 'RetryAfter', clamped to 'maxRetryAfterMicros'. Negative
-- or past-dated values fall back to the policy's own delay.
overrideDelayWithRetryAfter :: (MonadIO m) => Maybe RetryAfter -> m RetryAction
overrideDelayWithRetryAfter Nothing = pure ConsultPolicy
overrideDelayWithRetryAfter (Just (RetryAfterSeconds s))
  | s <= 0 = pure ConsultPolicy
  | otherwise = pure $ ConsultPolicyOverrideDelay (clampMicros (secondsToMicros s))
overrideDelayWithRetryAfter (Just (RetryAfterDate date)) = do
  s <- secondsFromNow date
  pure $
    if s > 0
      then ConsultPolicyOverrideDelay (clampMicros (secondsToMicros s))
      else ConsultPolicy
  where
    secondsFromNow d = do
      now <- liftIO getCurrentTime
      pure $ ceiling $ nominalDiffTimeToSeconds (d `diffUTCTime` now)

-- | Bound a delay (in microseconds) by 'maxRetryAfterMicros'.
clampMicros :: Int -> Int
clampMicros micros
  | micros < 0 = maxRetryAfterMicros
  | otherwise = min micros maxRetryAfterMicros

lookupRetryAfter :: [Header] -> Maybe RetryAfter
lookupRetryAfter = readMaybe . T.unpack . decodeUtf8With lenientDecode <=< lookup hRetryAfter

-- | Parse @retry_after@ (in seconds) from an @application/problem+json@ body.
--
-- @retry_after@ is not part of RFC 9457 (which only standardizes @type@,
-- @status@, @title@, @detail@, @instance@), but Cloudflare returns it as an
-- extension member on origin 5xx error pages instead of the canonical
-- 'Retry-After' HTTP header. Honoring it lets us back off the right amount
-- during transient origin outages.
lookupRetryAfterBody :: [Header] -> LBS.ByteString -> Maybe RetryAfter
lookupRetryAfterBody headers body = do
  ct <- lookup hContentType headers
  -- Some origins serve problem-details bodies with the bare @application/json@
  -- content type, so accept that too.
  _ <- Media.matchContent acceptableContentTypes ct
  pd <- Aeson.decode' body
  RetryAfterSeconds <$> problemRetryAfter pd
  where
    acceptableContentTypes =
      ["application" Media.// "problem+json", "application" Media.// "json"]

newtype ProblemDetails = ProblemDetails {problemRetryAfter :: Maybe Int}

instance Aeson.FromJSON ProblemDetails where
  parseJSON = Aeson.withObject "ProblemDetails" $ \o ->
    ProblemDetails <$> o Aeson..:? "retry_after"

-- Time

-- | Convert seconds to microseconds (the unit 'threadDelay' and the @retry@
-- package use for delays).
secondsToMicros :: Int -> Int
secondsToMicros = (* 1_000_000)

-- | Cap on a server-supplied 'Retry-After' override. Bounds the per-attempt
-- sleep so a hostile or buggy origin can't park the client for a long time.
maxRetryAfterMicros :: Int
maxRetryAfterMicros = secondsToMicros 120

-- Linked-thread exception unwrapping

-- | Unwrap 'Async.ExceptionInLinkedThread' and rethrow the inner exception so
-- retry handlers can match on the actual cause.
rethrowLinkedThreadExceptions :: (MonadCatch m) => m a -> m a
rethrowLinkedThreadExceptions =
  handleJust unwrapLinkedThreadException throwM

unwrapLinkedThreadException :: SomeException -> Maybe SomeException
unwrapLinkedThreadException e
  | Just (Async.ExceptionInLinkedThread _ e') <- fromException e = Just e'
  | otherwise = Nothing