packages feed

notion-client-0.8.0.0: src/Notion/V1/Retry.hs

-- | Retry policy for Notion API requests, ported from the official JS SDK.
--
-- Everything here is pure; 'Notion.V1.Client.withRetries' runs the loop.
module Notion.V1.Retry
  ( -- * Options
    RetryOptions (..),
    defaultRetryOptions,
    noRetries,

    -- * Policy
    canRetry,
    parseRetryAfter,
    retryDelay,
    validateRequestPath,
  )
where

import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.Char (isDigit, isSpace)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
import Data.Time.Format (defaultTimeLocale, parseTimeM)
import Network.HTTP.Types (Method, methodDelete, methodGet, urlDecode)
import Notion.Prelude hiding (ByteString)
import Notion.V1.Error (APIErrorCode (..), InvalidPathParameterError (..))

-- | How failed requests are retried.
data RetryOptions = RetryOptions
  { -- | Maximum number of retries after the first attempt; 0 disables retries.
    maxRetries :: Natural,
    -- | Base of the exponential back-off.
    initialRetryDelay :: NominalDiffTime,
    -- | Upper bound for both @retry-after@ and back-off delays.
    maxRetryDelay :: NominalDiffTime
  }
  deriving stock (Eq, Show)

-- | Two retries, starting at one second and capped at one minute (the JS SDK defaults).
defaultRetryOptions :: RetryOptions
defaultRetryOptions = RetryOptions {maxRetries = 2, initialRetryDelay = 1, maxRetryDelay = 60}

-- | Never retry.
noRetries :: RetryOptions
noRetries = RetryOptions {maxRetries = 0, initialRetryDelay = 0, maxRetryDelay = 0}

-- | Whether a failed request may be retried. @rate_limited@ and
-- @service_overload@ are retried for any method; @internal_server_error@ and
-- @service_unavailable@ only for @GET@ and @DELETE@.
canRetry :: Method -> APIErrorCode -> Bool
canRetry method = \case
  RateLimited -> True
  ServiceOverload -> True
  InternalServerError -> idempotent
  ServiceUnavailable -> idempotent
  _ -> False
  where
    idempotent = method == methodGet || method == methodDelete

-- | Parse a @retry-after@ header value at time @now@.
--
-- Leading ASCII digits (after skipping spaces) are delta-seconds, like
-- JavaScript's @parseInt@ (@"1.5"@ is one second). Otherwise the value may be
-- an HTTP date such as @Wed, 21 Oct 2015 07:28:00 GMT@; a date in the past
-- gives 0. Anything else gives 'Nothing'.
parseRetryAfter :: UTCTime -> BS.ByteString -> Maybe NominalDiffTime
parseRetryAfter now raw
  | not (BS.null digits) = fromInteger <$> readInteger digits
  | otherwise = do
      date <- parseTimeM False defaultTimeLocale "%a, %d %b %Y %H:%M:%S GMT" (BS8.unpack trimmed)
      pure (max 0 (diffUTCTime date now))
  where
    trimmed = BS8.dropWhileEnd isSpace (BS8.dropWhile isSpace raw)
    digits = BS8.takeWhile isDigit trimmed
    readInteger bs = case BS8.readInteger bs of
      Just (n, rest) | BS.null rest -> Just n
      _ -> Nothing

-- | Delay before retry number @attempt + 1@ (@attempt@ counts from 0).
-- A @retry-after@ value wins, capped at 'maxRetryDelay'. Otherwise exponential
-- back-off with @jitter@ in [0, 1): @base * jitter + base / 2@, where
-- @base = initialRetryDelay * 2 ^ attempt@.
retryDelay :: RetryOptions -> Natural -> Double -> Maybe NominalDiffTime -> NominalDiffTime
retryDelay RetryOptions {initialRetryDelay, maxRetryDelay} attempt jitter = \case
  Just retryAfter -> min retryAfter maxRetryDelay
  Nothing ->
    let base = initialRetryDelay * 2 ^ attempt
     in min (base * realToFrac jitter + base / 2) maxRetryDelay

-- | Reject request paths containing a path traversal sequence (@..@), plain or
-- percent-encoded.
validateRequestPath :: Text -> Either InvalidPathParameterError ()
validateRequestPath path
  | ".." `Text.isInfixOf` path = Left (InvalidPathParameterError path)
  | "%2e" `Text.isInfixOf` Text.toLower path,
    ".." `BS.isInfixOf` urlDecode False (Text.encodeUtf8 path) =
      Left (InvalidPathParameterError path)
  | otherwise = Right ()