packages feed

ratelimiter-0.1.0: src/Control/RateLimiter.hs

{-# LANGUAGE OverloadedStrings #-}
module Control.RateLimiter
    ( WindowSize(..), RateLimitMode(..), RateLimitConfig(..)
    , RateLimiter, newRateLimiter
    , isRateLimited, withRetryRateLimiter
    )
where

import Control.Monad
import Control.Monad.Extra
import Control.Monad.Trans
import Data.IORef
import Data.Time
import Data.Time.TimeSpan
import qualified Data.Sequence as Seq
import qualified Data.Vector as V

data WindowSize
    = WsMinute
    | WsHour
    | WsSecond
    | WsDay
    deriving (Show, Eq, Enum, Bounded)

data RateLimitMode
    = RollingWindow !DiffTime
    | FixedWindow !WindowSize
    deriving (Show, Eq)

data RateLimitConfig
    = RateLimitConfig
    { rlc_mode :: !RateLimitMode
    , rlc_maximum :: !Int
    }

data Entry k
  = Entry
  { eTime :: !UTCTime
  , eKey :: !k
  }

data RateLimiterImpl k
    = RateLimiterImpl
    { rl_config :: !RateLimitConfig
    , rl_state :: !(IORef (Seq.Seq (Entry k)))
    }

newtype RateLimiter k
    = RateLimiter { _unRateLimiter :: V.Vector (RateLimiterImpl k) }

-- | Create a new rate limiter with a list of configurations
newRateLimiter :: V.Vector RateLimitConfig -> IO (RateLimiter k)
newRateLimiter cfgs =
    liftM RateLimiter $ forM cfgs $ \cfg ->
    do ref <- newIORef mempty
       pure $ RateLimiterImpl { rl_config = cfg, rl_state = ref }

-- | Check if a given key is rate limited. Use `()` if you don't need multiple keys
isRateLimited :: (Eq k, MonadIO m) => k -> RateLimiter k -> m Bool
isRateLimited key (RateLimiter rls) =
    anyM (isRateLimitedImpl key) $ V.toList rls

isRateLimitedImpl :: Eq k => MonadIO m => k -> RateLimiterImpl k -> m Bool
isRateLimitedImpl key rl =
    do now <- liftIO getCurrentTime
       (_, isLimited) <-
           liftIO $
           atomicModifyIORef' (rl_state rl) $ \xs ->
           do let (xs', bucketSize) =
                      currentBucketSize now key (rlc_mode $ rl_config rl) xs
              if bucketSize < rlc_maximum (rl_config rl)
                 then (xs' Seq.|> Entry now key, (bucketSize, False))
                 else (xs', (bucketSize, True))
       pure isLimited

-- | Retry action if rate limited after 1 second
withRetryRateLimiter :: Eq k => MonadIO m => k -> RateLimiter k -> m a -> m a
withRetryRateLimiter key rl action =
    do limited <- isRateLimited key rl
       if not limited
          then action
          else do liftIO $ sleepTS (seconds 1)
                  withRetryRateLimiter key rl action

currentBucketSize ::
    Eq k => UTCTime -> k -> RateLimitMode -> Seq.Seq (Entry k) -> (Seq.Seq (Entry k), Int)
currentBucketSize now key rlm times =
    let timeOfDay =
          timeToTimeOfDay (utctDayTime now)
        takeUntil =
            case rlm of
              RollingWindow dt ->
                  addUTCTime (fromRational . toRational $ (-1) * dt) now
              FixedWindow ws ->
                  case ws of
                    WsMinute ->
                        now {utctDayTime = timeOfDayToTime $ timeOfDay { todSec = 0}}
                    WsHour ->
                        now {utctDayTime = timeOfDayToTime $ timeOfDay { todMin = 0, todSec = 0}}
                    WsSecond ->
                        now { utctDayTime = fromInteger $ truncate (utctDayTime now) }
                    WsDay ->
                        now { utctDayTime = 0 }
        newSeq = Seq.takeWhileR (\el -> eTime el >= takeUntil) times
    in ( newSeq
       , Seq.length $ Seq.filter (\el -> eKey el == key) newSeq
       )