packages feed

nova-nix-0.9.0.0: src/Nix/Http.hs

-- | The HTTP transfer layer every download nova-nix makes goes through:
-- the User-Agent each request carries, and upstream's retry policy with
-- the classification that decides what a retry can outlive.
--
-- Upstream serves every download, a fetcher's and a substituter's alike,
-- through one @FileTransfer@ (@libstore/filetransfer.cc@ at 2.24.9), with
-- one @download-attempts@ setting, one backoff and one table of the
-- failures that are never retried.  @builtin:fetchurl@ ("Nix.Builder")
-- and the substituter ("Nix.Substituter") share this module for the same
-- reason: a source tarball, a narinfo and a NAR fail and retry alike.
module Nix.Http
  ( -- * Identity
    userAgent,
    withUserAgent,

    -- * Attempt failures
    AttemptFailure (..),
    attemptFailureMessage,
    catchSync,

    -- * Retry policy
    FetchRetryPolicy (..),
    defaultFetchRetryPolicy,
    RetryEffects (..),
    ioRetryEffects,
    retryTransient,
    retryDelayMs,

    -- * Classification
    TransferError (..),
    statusError,
    withTransfer,
    transferBodyReader,
    transferFailureHandlers,
    fetchStatusFailure,
    fetchExceptionFailure,
    fetchWriteFailure,
  )
where

import Control.Concurrent (threadDelay)
import Control.Exception (Handler (..), IOException, SomeAsyncException (..), SomeException, catch, displayException, fromException, throwIO)
import qualified Data.ByteString.Char8 as BS8
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Version (showVersion)
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types.Header (hUserAgent)
import qualified Network.HTTP.Types.Status as HTTP
import Paths_nova_nix (version)
import System.IO (stderr)
import System.Random (randomRIO)

-- ---------------------------------------------------------------------------
-- Identity
-- ---------------------------------------------------------------------------

-- | @nova-nix\/VERSION (+repository)@: the product token upstream's shape
-- calls for, and a comment naming where the client comes from.
--
-- Upstream identifies itself on every request as @curl\/VERSION
-- Nix\/VERSION@.  A request with no User-Agent is what bot mitigation in
-- front of a server scores lowest: ftp.gnu.org answers one with 403
-- outright, and Cloudflare in front of cache.novavero.ai challenged every
-- request a GitHub runner made without one while passing the same requests
-- carrying this header.  The fetcher sent one; the substituter and push
-- sent none, so the public cache could be read by a person and not by the
-- tool built to read it.  The version comes from Cabal, so the header
-- names the build that made the request rather than a spelling that can
-- drift at release time.
userAgent :: BS8.ByteString
userAgent = BS8.pack ("nova-nix/" <> showVersion version <> " (+https://github.com/Novavero-AI/nova-nix)")

-- | The request with 'userAgent' as its only User-Agent, whatever it
-- carried before.
withUserAgent :: HTTP.Request -> HTTP.Request
withUserAgent request =
  request
    { HTTP.requestHeaders =
        (hUserAgent, userAgent) : filter ((/= hUserAgent) . fst) (HTTP.requestHeaders request)
    }

-- ---------------------------------------------------------------------------
-- Attempt failures
-- ---------------------------------------------------------------------------

-- | How one download attempt failed, deciding whether the retry budget
-- applies.  'TransientFailure' is a failure a fresh attempt could
-- plausibly complete; 'FatalFailure' is deterministic - the same served
-- object fails the same way every time.
data AttemptFailure
  = TransientFailure !Text
  | FatalFailure !Text
  deriving (Eq, Show)

-- | The failure's message, independent of its retry class.
attemptFailureMessage :: AttemptFailure -> Text
attemptFailureMessage failure = case failure of
  TransientFailure msg -> msg
  FatalFailure msg -> msg

-- | Run an action, passing only synchronous exceptions to the handler.
-- Asynchronous exceptions (a Ctrl-C, a timeout) re-throw untouched: an
-- interrupt converted into a recoverable failure would be spent as
-- retry budget, as fallthrough to the next cache, as a local build, or
-- as a failed push instead of aborting.  Every catch-all on the
-- download, substitution, build and push paths goes through this one
-- split.
catchSync :: IO a -> (SomeException -> IO a) -> IO a
catchSync action handler = action `catch` classify
  where
    classify someErr
      | Just (SomeAsyncException _) <- fromException someErr = throwIO someErr
      | otherwise = handler someErr

-- ---------------------------------------------------------------------------
-- Retry policy
-- ---------------------------------------------------------------------------

-- | How many times one download is tried before it fails: upstream's
-- @download-attempts@ default (@libstore/filetransfer.hh@ at 2.24.9,
-- @Setting<unsigned int> tries{this, 5, "download-attempts", ...}@).
downloadAttempts :: Int
downloadAttempts = 5

-- | The delay before the first retry, in milliseconds; every later retry
-- doubles it (@FileTransferRequest::baseRetryTimeMs = 250@ in the same
-- header).
retryBaseDelayMs :: Int
retryBaseDelayMs = 250

-- | The spread upstream adds to the backoff exponent so that clients
-- which failed together do not retry together: a uniform draw from
-- @[0, 0.5)@ (@filetransfer.cc@ at 2.24.9, line 500).
retryJitterCeiling :: Double
retryJitterCeiling = 0.5

microsPerMilli :: Int
microsPerMilli = 1000

-- | How often one download is tried and how long to wait between tries.
-- 'defaultFetchRetryPolicy' is upstream's; tests shrink the delay.
data FetchRetryPolicy = FetchRetryPolicy
  { frpAttempts :: !Int,
    frpBaseDelayMs :: !Int
  }
  deriving (Eq, Show)

-- | Upstream's policy: five attempts, 250 ms before the first retry.
defaultFetchRetryPolicy :: FetchRetryPolicy
defaultFetchRetryPolicy = FetchRetryPolicy {frpAttempts = downloadAttempts, frpBaseDelayMs = retryBaseDelayMs}

-- | The delay before retry number @retry@ (counting from one), given the
-- jitter drawn for it: upstream's @baseRetryTimeMs * 2 ^ (attempt - 1 +
-- jitter)@ (@filetransfer.cc@ at 2.24.9, line 500), truncated to whole
-- milliseconds as its assignment to an @int@ truncates.
retryDelayMs :: FetchRetryPolicy -> Int -> Double -> Int
retryDelayMs policy retry jitter =
  truncate (fromIntegral (frpBaseDelayMs policy) * 2 ** (fromIntegral (retry - 1) + jitter) :: Double)

-- | What the retry loop needs from the outside world, injected so the
-- policy is testable with no clock, no entropy and no network.
data RetryEffects m = RetryEffects
  { reSleepMs :: !(Int -> m ()),
    reJitter :: !(m Double),
    reWarn :: !(Text -> m ())
  }

-- | The real effects: a sleep, a uniform jitter, and a warning on stderr
-- in the shape of upstream's @warn("%s; retrying in %d ms", ...)@.
ioRetryEffects :: RetryEffects IO
ioRetryEffects =
  RetryEffects
    { reSleepMs = threadDelay . (* microsPerMilli),
      reJitter = randomRIO (0, retryJitterCeiling),
      reWarn = TIO.hPutStrLn stderr . ("warning: " <>)
    }

-- | Run one download's attempt under the retry policy: a
-- 'TransientFailure' is tried again after the backoff until the attempts
-- are spent, while a 'FatalFailure' or a success ends the loop at once.
-- Exceptions propagate, so a cancellation is never spent as retry budget.
retryTransient :: (Monad m) => FetchRetryPolicy -> RetryEffects m -> m (Either AttemptFailure a) -> m (Either AttemptFailure a)
retryTransient policy effects action = go 1
  where
    go !attempt = do
      result <- action
      case result of
        Left (TransientFailure err)
          | attempt < frpAttempts policy -> do
              jitter <- reJitter effects
              let delay = retryDelayMs policy attempt jitter
              reWarn effects (err <> "; retrying in " <> T.pack (show delay) <> " ms")
              reSleepMs effects delay
              go (attempt + 1)
        _ -> pure result

-- ---------------------------------------------------------------------------
-- Classification
-- ---------------------------------------------------------------------------

-- | 'HTTP.withResponse', with the body read through
-- 'transferBodyReader', so every failure of the connection is an
-- 'HTTP.HttpException', the body's included.
withTransfer :: HTTP.Request -> HTTP.Manager -> (HTTP.Response HTTP.BodyReader -> IO a) -> IO a
withTransfer request manager consume =
  HTTP.withResponse request manager (consume . fmap (transferBodyReader request))

-- | A response body reader whose failures are all
-- 'HTTP.HttpException's.  The client's manager wraps what opening a
-- response throws (a reset, a TLS failure) into
-- 'HTTP.InternalException', but hands the body reader over unwrapped
-- (@responseOpen@ in http-client 0.7), so a connection reset or a torn
-- TLS record mid-body arrives as a bare 'IOException' or TLS exception,
-- the shape of a failure writing the output.  Wrapping the read the way
-- the manager wraps the open keeps the two apart: a transport failure is
-- transient wherever it happens, as curl's receive errors are upstream,
-- and a write failure is not.  The reader only ever reads the
-- connection, so every synchronous exception it raises is the
-- transport's; an asynchronous one passes through untouched.
transferBodyReader :: HTTP.Request -> HTTP.BodyReader -> HTTP.BodyReader
transferBodyReader request reader = reader `catchSync` asTransportFailure
  where
    asTransportFailure err = case fromException err :: Maybe HTTP.HttpException of
      Just _ -> throwIO err
      Nothing -> throwIO (HTTP.HttpExceptionRequest request (HTTP.InternalException err))

-- | What a transfer attempt throws, turned into its failure: an
-- 'HTTP.HttpException' by 'fetchExceptionFailure', an 'IOException' by
-- 'fetchWriteFailure'.  Nothing else is caught, so an asynchronous
-- exception (an interrupt, a cancelled build) leaves the attempt as it
-- arrived and is never spent as retry budget, and anything unforeseen
-- reaches the caller's own boundary.
transferFailureHandlers :: Text -> [Handler (Either AttemptFailure a)]
transferFailureHandlers url =
  [ Handler (pure . Left . fetchExceptionFailure url),
    Handler (pure . Left . fetchWriteFailure url)
  ]

-- | Upstream's verdict on a response that is not a success
-- (@FileTransfer::Error@, @filetransfer.hh@ at 2.24.9, whose
-- @Interrupted@ is an asynchronous exception here, never a value).  Only
-- 'Transient' is retried.  A binary cache store reads 'NotFound' and
-- 'Forbidden' alike as the file not being in that cache
-- (@http-binary-cache-store.cc@ at 2.24.9, lines 123, 160 and 182),
-- since an S3 bucket that cannot be listed answers a missing key with
-- 403.
data TransferError
  = -- | 404 or 410: the file is not there.
    NotFound
  | -- | 401, 403 or 407: the request was refused.
    Forbidden
  | -- | A failure no retry can change.
    Misc
  | -- | A failure a retry could outlive.
    Transient
  deriving (Eq, Show)

-- | Classify a response status the way upstream's transfer layer does,
-- in its order (@filetransfer.cc@ at 2.24.9, lines 423-441): 404 and 410
-- are 'NotFound'; 401, 403 and 407 are 'Forbidden'; every other 4xx
-- except 408 and 429 (the server timed out waiting for the request, or
-- asked for a slower pace) is 'Misc', as are 501, 505 and 511 (the
-- server cannot speak this protocol, or a captive portal is in the way).
-- Everything else is 'Transient', the remaining 5xx included.
statusError :: HTTP.Status -> TransferError
statusError status
  | status `elem` [HTTP.status404, HTTP.status410] = NotFound
  | status `elem` [HTTP.status401, HTTP.status403, HTTP.status407] = Forbidden
  | HTTP.statusIsClientError status && status `notElem` [HTTP.status408, HTTP.status429] = Misc
  | status `elem` [HTTP.status501, HTTP.status505, HTTP.status511] = Misc
  | otherwise = Transient

-- | A response status as an attempt's failure, by 'statusError': only a
-- 'Transient' one is retried.
fetchStatusFailure :: Text -> HTTP.Status -> AttemptFailure
fetchStatusFailure url status = case statusError status of
  Transient -> TransientFailure message
  NotFound -> FatalFailure message
  Forbidden -> FatalFailure message
  Misc -> FatalFailure message
  where
    message = "HTTP " <> T.pack (show (HTTP.statusCode status)) <> " fetching " <> url

-- | Classify a failure of the HTTP client, by upstream's list of curl
-- results that are not retried (@filetransfer.cc@ at 2.24.9, lines
-- 443-465).  A URL the client cannot parse (@CURLE_URL_MALFORMAT@) is an
-- 'HTTP.InvalidUrlException' from the parser, except for an empty host,
-- which the parser accepts and the connection lookup rejects as
-- 'HTTP.InvalidDestinationHost' on every attempt.  A scheme the client
-- does not speak (@CURLE_UNSUPPORTED_PROTOCOL@) is an
-- 'HTTP.InvalidUrlException' too, or 'HTTP.TlsNotSupported' when the
-- manager has no TLS.  A redirect loop (@CURLE_TOO_MANY_REDIRECTS@)
-- completes the deterministic set.  Every other transport failure is
-- transient, name resolution and TLS included, as upstream has it.  The
-- message names the URL and the failure only: the client's own rendering
-- prints the whole request record over a dozen lines, and a retry would
-- repeat it.
fetchExceptionFailure :: Text -> HTTP.HttpException -> AttemptFailure
fetchExceptionFailure url err = case err of
  HTTP.InvalidUrlException _ reason -> FatalFailure (describeFailure url reason)
  HTTP.HttpExceptionRequest _ content -> case content of
    HTTP.InvalidDestinationHost _ -> FatalFailure (describeFailure url "empty host")
    HTTP.TlsNotSupported -> FatalFailure (describeFailure url "TLS is not supported")
    HTTP.TooManyRedirects _ -> FatalFailure (describeFailure url "too many redirects")
    _ -> TransientFailure (describeFailure url (show content))

-- | A failure writing what the transfer delivered: upstream's
-- @CURLE_WRITE_ERROR@, never retried, since the disk that refused one
-- write refuses the next.  An 'IOException' reaching the attempt boundary
-- is one, because 'withTransfer' has already raised every failure of the
-- connection as an 'HTTP.HttpException'.
fetchWriteFailure :: Text -> IOException -> AttemptFailure
fetchWriteFailure url err = FatalFailure (describeFailure url (displayException err))

describeFailure :: Text -> String -> Text
describeFailure url detail = "download error fetching " <> url <> ": " <> T.pack detail