packages feed

calamity-0.1.28.1: Calamity/HTTP/Internal/Ratelimit.hs

-- | Module containing ratelimit stuff
module Calamity.HTTP.Internal.Ratelimit (
  newRateLimitState,
  doRequest,
) where

import Calamity.Client.Types (BotC)
import Calamity.HTTP.Internal.Route
import Calamity.HTTP.Internal.Types
import Calamity.Internal.Utils

import Control.Applicative
import Control.Concurrent
import Control.Concurrent.Event (Event)
import qualified Control.Concurrent.Event as E
import Control.Concurrent.STM
import qualified Control.Concurrent.STM.Lock as L
import Control.Lens
import Control.Monad

import Data.Aeson
import Data.Aeson.Lens
import qualified Data.ByteString.Lazy as LB
import qualified Data.ByteString          as B
import Data.Maybe
import qualified Data.Text.Lazy as LT
import Data.Time
import Data.Time.Clock.POSIX

import Fmt

import Network.HTTP.Client (responseStatus)
import Network.HTTP.Req
import Network.HTTP.Types

import Polysemy (Sem)
import qualified Polysemy as P
import qualified Polysemy.Async as P

import Prelude hiding (error)
import qualified Prelude

import qualified Control.Exception.Safe as Ex
import qualified StmContainers.Map as SC

newRateLimitState :: IO RateLimitState
newRateLimitState = RateLimitState <$> SC.newIO <*> SC.newIO <*> E.newSet

data Ratelimit
  = KnownRatelimit Bucket
  | UnknownRatelimit Route

{- performing rate limits
  1. try to fetch the lock from the ratelimiter state
  1.1. if we know the bucket then we retrieve the lock (KnownRatelimit)
  1.2. if we don't know the bucket, we just store the route (UnknownRatelimit)
  2.1. wait fot the global event (the global event is set unless we're globally ratelimited)
  2.2. wait for the local lock (locks are unlocked unless the bucket is exhausted)
  2.3. try to wait for the ratelimit to expire if it's exhausted
  3. we then perform the request, after this we'll know the bucket,
     so we update the ratelimit state with the bucket. if while performing
     the current request another request completed that made the bucket known,
     we don't try to overwrite it
  4.1. if we exceeded the local ratelimit we take the lock and release it after a delay
  4.2. if we exceeded the global ratelimit, we unset the global rl event,
       wait for it to expire, and then try again
-}

getRateLimit :: RateLimitState -> Route -> STM Ratelimit
getRateLimit s h = do
  bucketKey <- SC.lookup h $ bucketKeys s
  bucket <- join <$> sequenceA (flip SC.lookup (buckets s) <$> bucketKey)
  case bucket of
    Just bucket' ->
      pure $ KnownRatelimit bucket'
    Nothing ->
      pure $ UnknownRatelimit h

-- | Knowing the bucket for a route, and the ratelimit info, map the route to
-- the bucket key and retrieve the bucket
updateBucket :: RateLimitState -> Route -> B.ByteString -> BucketState -> STM Bucket
updateBucket s h b bucketState = do
  bucketKey <- SC.lookup h $ bucketKeys s
  case bucketKey of
    Just bucketKey' -> do
      -- if we know the bucket key here, then the bucket has already been made
      bucket <- SC.lookup bucketKey' $ buckets s
      case bucket of
        Just bucket' -> do
          modifyTVar' (bucket' ^. #state) (`mergeBucket` bucketState)
          pure bucket'
        Nothing -> Prelude.error "Not possible"
    Nothing -> do
      -- the bucket key wasn't known, make a new bucket and insert it
      lock <- L.new
      bs <- newTVar bucketState
      let bucket = Bucket lock bs
      SC.insert bucket b $ buckets s
      SC.insert b h $ bucketKeys s
      pure bucket
  where
    mergeBucket :: BucketState -> BucketState -> BucketState
    mergeBucket old new = new { remaining = new ^. #remaining <|> old ^. #remaining }

resetBucket :: Bucket -> STM ()
resetBucket bucket = modifyTVar' (bucket ^. #state) (#remaining .~ Nothing)

-- | Maybe wait for a bucket, updating its state to say we used it
useBucketOnce :: Bucket -> IO ()
useBucketOnce bucket = do
  now <- getCurrentTime
  mWaitUntil <- atomically $ do
    -- first wait on the lock
    L.wait $ bucket ^. #lock

    -- now try to estimate an expiry
    s <- readTVar $ bucket ^. #state

    let remaining = if now > s ^. #resetTime
          then Nothing
          else s ^. #remaining

    case remaining of
      Just n | n > 0 -> do
        modifyTVar (bucket ^. #state) (#remaining ?~ n - 1)
        pure Nothing
      Just _ -> do
        -- expired, lock and then reset the remaining
        L.acquire $ bucket ^. #lock
        modifyTVar (bucket ^. #state) (#remaining .~ Nothing)
        pure . Just $ s ^. #resetTime
      Nothing ->
        -- unknown, just assume we're good
        pure Nothing

  case mWaitUntil of
    Just when -> do
      threadDelayUntil when
      atomically . L.release $ bucket ^. #lock
    Nothing ->
      pure ()

doDiscordRequest :: BotC r => IO LbsResponse -> Sem r DiscordResponseType
doDiscordRequest r = do
  r'' <- P.embed $ Ex.catchAny (Right <$> r) (pure . Left . Ex.displayException)
  case r'' of
    Right r' -> do
      let status = responseStatus . toVanillaResponse $ r'
      if
          | statusIsSuccessful status -> do
            let resp = responseBody r'
            debug $ "Got good response from discord: " +|| status ||+ ""
            now <- P.embed getCurrentTime
            if isExhausted r'
              then case (parseRateLimitHeader now r', buildBucketState now r') of
                (Just !when, Just (!bs, !key)) ->
                  pure $ ExhaustedBucket resp when bs key
                _ ->
                  pure $ ServerError (statusCode status)
              else case buildBucketState now r' of
                Just (!bs, !key) ->
                  pure $ Good resp bs key
                Nothing ->
                  pure $ ServerError (statusCode status)
          | status == status429 -> do
            debug "Got 429 from discord, retrying."
            now <- P.embed getCurrentTime
            let resp = responseBody r'
            case (resp ^? _Value, buildBucketState now r') of
              (Just !rv, bs) ->
                pure $ Ratelimited (parseRetryAfter now rv) (isGlobal rv) bs
              _ ->
                pure $ ServerError (statusCode status)
          | statusIsClientError status -> do
            let err = responseBody r'
            error $ "Something went wrong: " +|| err ||+ " response: " +|| r' ||+ ""
            pure $ ClientError (statusCode status) err
          | otherwise -> do
            debug $ "Got server error from discord: " +| statusCode status |+ ""
            pure $ ServerError (statusCode status)
    Left e -> do
      error $ "Something went wrong with the http client: " +| LT.pack e |+ ""
      pure . InternalResponseError $ LT.pack e

-- | Parse a ratelimit header returning when it unlocks
parseRateLimitHeader :: HttpResponse r => UTCTime -> r -> Maybe UTCTime
parseRateLimitHeader now r = computedEnd <|> end
  where
    computedEnd :: Maybe UTCTime
    computedEnd = flip addUTCTime now <$> resetAfter

    resetAfter :: Maybe NominalDiffTime
    resetAfter = realToFrac <$> responseHeader r "X-Ratelimit-Reset-After" ^? _Just . _Double

    end :: Maybe UTCTime
    end = posixSecondsToUTCTime . realToFrac <$>
      responseHeader r "X-Ratelimit-Reset" ^? _Just . _Double

buildBucketState :: HttpResponse r => UTCTime -> r -> Maybe (BucketState, B.ByteString)
buildBucketState now r = (bs,) <$> bucketKey
  where
    remaining = responseHeader r "X-RateLimit-Remaining" ^? _Just . _Integral
    bs = BucketState now remaining
    bucketKey = responseHeader r "X-RateLimit-Bucket"

isExhausted :: HttpResponse r => r -> Bool
isExhausted r = responseHeader r "X-RateLimit-Remaining" == Just "0"

-- | Parse the retry after field, returning when to retry
parseRetryAfter :: UTCTime -> Value -> UTCTime
parseRetryAfter now r = addUTCTime retryAfter now
  where
    retryAfter = realToFrac $ r ^?! key "retry_after" . _Double

isGlobal :: Value -> Bool
isGlobal r = r ^? key "global" . _Bool == Just True

-- Either (Either a a) b
data ShouldRetry a b
  = Retry a
  | RFail a
  | RGood b

retryRequest ::
  BotC r =>
  -- | number of retries
  Int ->
  -- | action to perform
  Sem r (ShouldRetry a b) ->
  Sem r (Either a b)
retryRequest maxRetries action = retryInner 0
 where
  retryInner numRetries = do
    res <- action
    case res of
      Retry r | numRetries > maxRetries -> do
        debug $ "Request failed after " +| maxRetries |+ " retries."
        pure $ Left r
      Retry _ ->
        retryInner (numRetries + 1)
      RFail r -> do
        debug "Request failed due to error response."
        pure $ Left r
      RGood r ->
        pure $ Right r

threadDelayMS :: Int -> IO ()
threadDelayMS ms = threadDelay (1000 * ms)

threadDelayUntil :: UTCTime -> IO ()
threadDelayUntil when = do
  now <- getCurrentTime
  let msUntil = round . (* 1000) . realToFrac @_ @Double $ diffUTCTime when now
  threadDelayMS msUntil

-- Run a single request
doSingleRequest ::
  BotC r =>
  RateLimitState ->
  Route ->
  -- | Global lock
  Event ->
  -- | Request action
  IO LbsResponse ->
  Sem r (ShouldRetry RestError LB.ByteString)
doSingleRequest rlstate route gl r = do
  P.embed $ E.wait (globalLock rlstate)

  rl <- P.embed . atomically $ getRateLimit rlstate route

  case rl of
    KnownRatelimit bucket ->
      P.embed $ useBucketOnce bucket

    _ -> pure ()

  r' <- doDiscordRequest r

  case r' of
    Good v bs bk -> do
      void . P.embed . atomically $ updateBucket rlstate route bk bs
      pure $ RGood v

    ExhaustedBucket v unlockWhen bs bk -> do
      debug $ "Exhausted bucket, unlocking at" +| unlockWhen |+ ""

      bucket <- P.embed . atomically $ do
        bucket <- updateBucket rlstate route bk bs
        L.acquire $ bucket ^. #lock
        pure bucket

      void . P.async $ do
        P.embed $ do
          threadDelayUntil unlockWhen
          atomically $ do
            L.release $ bucket ^. #lock
            resetBucket bucket
        debug "unlocking bucket"

      pure $ RGood v

    Ratelimited unlockWhen False (Just (bs, bk)) -> do
      debug $ "429 ratelimited on route, retrying at " +| unlockWhen |+ ""

      bucket <- P.embed . atomically $ do
        bucket <- updateBucket rlstate route bk bs
        L.acquire $ bucket ^. #lock
        pure bucket

      P.embed $ do
        threadDelayUntil unlockWhen
        atomically $ do
          L.release $ bucket ^. #lock
          resetBucket bucket

      pure $ Retry (HTTPError 429 Nothing)

    Ratelimited unlockWhen False _ -> do
      debug "Internal error (ratelimited but no headers), retrying"
      P.embed $ threadDelayUntil unlockWhen
      pure $ Retry (HTTPError 429 Nothing)

    Ratelimited unlockWhen True bs -> do
      debug "429 ratelimited globally"

      P.embed $ do
        case bs of
          Just (bs', bk) ->
            void . atomically $ updateBucket rlstate route bk bs'
          Nothing ->
            pure ()

        E.clear gl
        threadDelayUntil unlockWhen
        E.set gl
      pure $ Retry (HTTPError 429 Nothing)

    ServerError c -> do
      debug "Server failed, retrying"
      pure $ Retry (HTTPError c Nothing)

    InternalResponseError c -> do
      debug "Internal error, retrying"
      pure $ Retry (InternalClientError c)

    ClientError c v -> pure $ RFail (HTTPError c $ decode v)

doRequest :: BotC r => RateLimitState -> Route -> IO LbsResponse -> Sem r (Either RestError LB.ByteString)
doRequest rlstate route action =
  retryRequest
    5
    (doSingleRequest rlstate route (globalLock rlstate) action)