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.Concurrent
import Control.Concurrent.Event ( Event )
import qualified Control.Concurrent.Event as E
import Control.Concurrent.STM
import Control.Concurrent.STM.Lock ( Lock )
import qualified Control.Concurrent.STM.Lock as L
import Control.Lens
import Control.Monad
import Data.Aeson
import Data.Aeson.Lens
import Data.ByteString ( ByteString )
import qualified Data.ByteString.Lazy as LB
import Data.Functor
import Data.Maybe
import Data.Time
import Data.Time.Clock.POSIX
import Fmt
import Focus
import Network.HTTP.Date
import Network.HTTP.Types hiding ( statusCode )
import Network.Wreq
import qualified Polysemy as P
import Polysemy ( Sem )
import qualified Polysemy.Async as P
import Prelude hiding ( error )
import qualified StmContainers.Map as SC
newRateLimitState :: IO RateLimitState
newRateLimitState = RateLimitState <$> SC.newIO <*> E.newSet
lookupOrInsertDefaultM :: Monad m => m a -> Focus a m a
lookupOrInsertDefaultM aM = casesM
(do a <- aM
pure (a, Set a))
(\a -> pure (a, Leave))
getRateLimit :: RateLimitState -> Route -> STM Lock
getRateLimit s h = SC.focus (lookupOrInsertDefaultM L.new) h (rateLimits s)
doDiscordRequest
:: BotC r
=> IO (Response LB.ByteString)
-> Sem r DiscordResponseType
doDiscordRequest r = do
r' <- P.embed r
let status = r' ^. responseStatus
if
| statusIsSuccessful status -> do
let resp = r' ^. responseBody
debug $ "Got good response from discord: " +|| r' ^. responseStatus ||+ ""
pure $ if isExhausted r'
then ExhaustedBucket resp $ parseRateLimitHeader r'
else Good resp
| statusIsServerError status -> do
debug $ "Got server error from discord: " +| status ^. statusCode |+ ""
pure $ ServerError (status ^. statusCode)
| status == status429 -> do
debug "Got 429 from discord, retrying."
case asValue r' of
Just rv -> pure $ Ratelimited (parseRetryAfter rv) (isGlobal rv)
Nothing -> pure $ ClientError (status ^. statusCode) "429 with invalid json???"
| statusIsClientError status -> do
let err = r' ^. responseBody
error $ "You fucked up: " +|| err ||+ " response: " +|| r' ||+ ""
pure $ ClientError (status ^. statusCode) err
| otherwise -> fail "Bogus response, discord fix your shit"
parseDiscordTime :: ByteString -> Maybe UTCTime
parseDiscordTime s = httpDateToUTC <$> parseHTTPDate s
computeDiscordTimeDiff :: Double -> UTCTime -> Int
computeDiscordTimeDiff end now = round . (* 1000.0) $ diffUTCTime end' now
where end' = end & toRational & fromRational & posixSecondsToUTCTime
parseRateLimitHeader :: Response a -> Int
parseRateLimitHeader r = computeDiscordTimeDiff end now
where
end = r ^?! responseHeader "X-Ratelimit-Reset" . _Double
now = r ^?! responseHeader "Date" & parseDiscordTime & fromJust
isExhausted :: Response a -> Bool
isExhausted r = r ^? responseHeader "X-RateLimit-Remaining" == Just "0"
parseRetryAfter :: Response Value -> Int
parseRetryAfter r =
r ^?! responseBody . key "retry_after" . _Integral
isGlobal :: Response Value -> Bool
isGlobal r = r ^? responseBody . key "global" . _Bool == Just True
data ShouldRetry a b
= Retry a
| RFail a
| RGood b
retryRequest
:: BotC r
=> Int
-> Sem r (ShouldRetry a b)
-> Sem r ()
-> Sem r (Either a b)
retryRequest max_retries action failAction = retryInner 0
where
retryInner num_retries = do
res <- action
case res of
Retry r | num_retries > max_retries -> do
debug $ "Request failed after " +| max_retries |+ " retries."
doFail $ Left r
Retry _ -> retryInner (succ num_retries)
RFail r -> do
debug "Request failed due to error response."
doFail $ Left r
RGood r -> pure $ Right r
where doFail v = failAction $> v
doSingleRequest
:: BotC r
=> Event
-> Lock
-> IO (Response LB.ByteString)
-> Sem r (ShouldRetry RestError LB.ByteString)
doSingleRequest gl l r = do
r' <- doDiscordRequest r
case r' of
Good v -> do
P.embed . atomically $ L.release l
pure $ RGood v
ExhaustedBucket v d -> do
debug $ "Exhausted bucket, unlocking after " +| d |+ "ms"
void . P.async $ do
P.embed $ do
threadDelay $ 1000 * d
atomically $ L.release l
debug "unlocking bucket"
pure $ RGood v
Ratelimited d False -> do
debug $ "429 ratelimited on route, sleeping for " +| d |+ " ms"
P.embed . threadDelay $ 1000 * d
pure $ Retry (HTTPError 429 Nothing)
Ratelimited d True -> do
debug "429 ratelimited globally"
P.embed $ do
E.clear gl
threadDelay $ 1000 * d
E.set gl
pure $ Retry (HTTPError 429 Nothing)
ServerError c -> do
debug "Server failed, retrying"
pure $ Retry (HTTPError c Nothing)
ClientError c v -> pure $ RFail (HTTPError c $ decode v)
doRequest
:: BotC r
=> RateLimitState
-> Route
-> IO (Response LB.ByteString)
-> Sem r (Either RestError LB.ByteString)
doRequest rlState route action = do
P.embed $ E.wait (globalLock rlState)
ratelimit <- P.embed . atomically $ do
lock <- getRateLimit rlState route
L.acquire lock
pure lock
retryRequest 5
(doSingleRequest (globalLock rlState) ratelimit action)
(P.embed . atomically $ L.release ratelimit)