packages feed

numerus-closus 0.1.0.0 → 0.2.0.0

raw patch · 3 files changed

+416/−185 lines, 3 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

- Control.NumerusClosus: WindowsCount :: a -> WindowsCount a
- Control.NumerusClosus: [debit] :: RateLimiter -> UTCTime -> Either NextDebitable RateLimiter
- Control.NumerusClosus: instance Data.Time.Format.Format.Class.FormatTime Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance Data.Time.Format.Parse.Class.ParseTime Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Base.Semigroup Control.NumerusClosus.NextDebitable
- Control.NumerusClosus: instance GHC.Classes.Eq Control.NumerusClosus.NextDebitable
- Control.NumerusClosus: instance GHC.Classes.Eq Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Classes.Eq a => GHC.Classes.Eq (Control.NumerusClosus.BucketSize a)
- Control.NumerusClosus: instance GHC.Classes.Eq a => GHC.Classes.Eq (Control.NumerusClosus.WindowsCount a)
- Control.NumerusClosus: instance GHC.Classes.Ord Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Classes.Ord a => GHC.Classes.Ord (Control.NumerusClosus.BucketSize a)
- Control.NumerusClosus: instance GHC.Classes.Ord a => GHC.Classes.Ord (Control.NumerusClosus.WindowsCount a)
- Control.NumerusClosus: instance GHC.Num.Num Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Num.Num a => GHC.Num.Num (Control.NumerusClosus.BucketSize a)
- Control.NumerusClosus: instance GHC.Num.Num a => GHC.Num.Num (Control.NumerusClosus.WindowsCount a)
- Control.NumerusClosus: instance GHC.Real.Fractional Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Real.Real Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Real.RealFrac Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Show.Show Control.NumerusClosus.NextDebitable
- Control.NumerusClosus: instance GHC.Show.Show Control.NumerusClosus.WindowSize
- Control.NumerusClosus: instance GHC.Show.Show a => GHC.Show.Show (Control.NumerusClosus.BucketSize a)
- Control.NumerusClosus: instance GHC.Show.Show a => GHC.Show.Show (Control.NumerusClosus.WindowsCount a)
- Control.NumerusClosus: newtype RateLimiter
- Control.NumerusClosus: newtype WindowsCount a
+ Control.NumerusClosus: BucketsCount :: a -> BucketsCount a
+ Control.NumerusClosus: WindowSize :: NominalDiffTime -> WindowSize
+ Control.NumerusClosus: [unBucketSize] :: BucketSize a -> a
+ Control.NumerusClosus: [unBucketsCount] :: BucketsCount a -> a
+ Control.NumerusClosus: [unWindowSize] :: WindowSize -> NominalDiffTime
+ Control.NumerusClosus: [wrapped] :: RateLimiter -> a
+ Control.NumerusClosus: data RateLimiter
+ Control.NumerusClosus: debit :: RateLimiter -> UTCTime -> Either NextDebitable RateLimiter
+ Control.NumerusClosus: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.RateLimiter
+ Control.NumerusClosus: newtype BucketsCount a
+ Control.NumerusClosus: newtype WindowSize
+ Control.NumerusClosus.Typed: (:&&) :: a -> b -> (:&&) a b
+ Control.NumerusClosus.Typed: (:||) :: a -> b -> (:||) a b
+ Control.NumerusClosus.Typed: AlwaysAllow :: AlwaysAllow
+ Control.NumerusClosus.Typed: AlwaysDeny :: AlwaysDeny
+ Control.NumerusClosus.Typed: BucketSize :: a -> BucketSize a
+ Control.NumerusClosus.Typed: BucketsCount :: a -> BucketsCount a
+ Control.NumerusClosus.Typed: DebitableFrom :: UTCTime -> NextDebitable
+ Control.NumerusClosus.Typed: FiniteBucket :: BucketSize Integer -> FiniteBucket
+ Control.NumerusClosus.Typed: FixedWindow :: WindowSize -> BucketSize Integer -> BucketSize Integer -> UTCTime -> UTCTime -> FixedWindow
+ Control.NumerusClosus.Typed: Never :: NextDebitable
+ Control.NumerusClosus.Typed: PositionInTime :: m UTCTime -> (UTCTime -> m ()) -> PositionInTime (m :: Type -> Type)
+ Control.NumerusClosus.Typed: SlidingWindow :: WindowSize -> BucketSize Int -> UTCTime -> Set UTCTime -> SlidingWindow
+ Control.NumerusClosus.Typed: SlidingWindowBucketed :: WindowSize -> BucketsCount Int -> BucketSize Integer -> Map UTCTime Integer -> SlidingWindowBucketed
+ Control.NumerusClosus.Typed: SlidingWindowCount :: WindowSize -> BucketSize Int -> Integer -> Integer -> UTCTime -> UTCTime -> SlidingWindowCount
+ Control.NumerusClosus.Typed: WindowSize :: NominalDiffTime -> WindowSize
+ Control.NumerusClosus.Typed: [bucket] :: SlidingWindow -> Set UTCTime
+ Control.NumerusClosus.Typed: [bucketsCount] :: SlidingWindowBucketed -> BucketsCount Int
+ Control.NumerusClosus.Typed: [buckets] :: SlidingWindowBucketed -> Map UTCTime Integer
+ Control.NumerusClosus.Typed: [currentCount] :: SlidingWindowCount -> Integer
+ Control.NumerusClosus.Typed: [delayUntil] :: PositionInTime (m :: Type -> Type) -> UTCTime -> m ()
+ Control.NumerusClosus.Typed: [fullBucket] :: FiniteBucket -> BucketSize Integer
+ Control.NumerusClosus.Typed: [getTime] :: PositionInTime (m :: Type -> Type) -> m UTCTime
+ Control.NumerusClosus.Typed: [maxBucket] :: SlidingWindowBucketed -> BucketSize Integer
+ Control.NumerusClosus.Typed: [prevCount] :: SlidingWindowCount -> Integer
+ Control.NumerusClosus.Typed: [unBucketSize] :: BucketSize a -> a
+ Control.NumerusClosus.Typed: [unBucketsCount] :: BucketsCount a -> a
+ Control.NumerusClosus.Typed: [unWindowSize] :: WindowSize -> NominalDiffTime
+ Control.NumerusClosus.Typed: [windowEnd] :: SlidingWindowCount -> UTCTime
+ Control.NumerusClosus.Typed: [windowSize] :: SlidingWindowBucketed -> WindowSize
+ Control.NumerusClosus.Typed: [windowStart] :: SlidingWindowCount -> UTCTime
+ Control.NumerusClosus.Typed: alwaysAllow :: AlwaysAllow
+ Control.NumerusClosus.Typed: alwaysDeny :: AlwaysDeny
+ Control.NumerusClosus.Typed: class RateLimiter a
+ Control.NumerusClosus.Typed: data AlwaysAllow
+ Control.NumerusClosus.Typed: data AlwaysDeny
+ Control.NumerusClosus.Typed: data FixedWindow
+ Control.NumerusClosus.Typed: data NextDebitable
+ Control.NumerusClosus.Typed: data PositionInTime (m :: Type -> Type)
+ Control.NumerusClosus.Typed: data SlidingWindow
+ Control.NumerusClosus.Typed: data SlidingWindowBucketed
+ Control.NumerusClosus.Typed: data SlidingWindowCount
+ Control.NumerusClosus.Typed: data a :|| b
+ Control.NumerusClosus.Typed: debit :: RateLimiter a => a -> UTCTime -> Either NextDebitable a
+ Control.NumerusClosus.Typed: finiteBucket :: BucketSize Integer -> FiniteBucket
+ Control.NumerusClosus.Typed: fixedWindow :: WindowSize -> BucketSize Integer -> UTCTime -> FixedWindow
+ Control.NumerusClosus.Typed: infixr 3 :||
+ Control.NumerusClosus.Typed: instance (Control.NumerusClosus.Typed.RateLimiter a, Control.NumerusClosus.Typed.RateLimiter b) => Control.NumerusClosus.Typed.RateLimiter (a Control.NumerusClosus.Typed.:&& b)
+ Control.NumerusClosus.Typed: instance (Control.NumerusClosus.Typed.RateLimiter a, Control.NumerusClosus.Typed.RateLimiter b) => Control.NumerusClosus.Typed.RateLimiter (a Control.NumerusClosus.Typed.:|| b)
+ Control.NumerusClosus.Typed: instance (GHC.Classes.Eq a, GHC.Classes.Eq b) => GHC.Classes.Eq (a Control.NumerusClosus.Typed.:&& b)
+ Control.NumerusClosus.Typed: instance (GHC.Classes.Eq a, GHC.Classes.Eq b) => GHC.Classes.Eq (a Control.NumerusClosus.Typed.:|| b)
+ Control.NumerusClosus.Typed: instance (GHC.Show.Show a, GHC.Show.Show b) => GHC.Show.Show (a Control.NumerusClosus.Typed.:&& b)
+ Control.NumerusClosus.Typed: instance (GHC.Show.Show a, GHC.Show.Show b) => GHC.Show.Show (a Control.NumerusClosus.Typed.:|| b)
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.AlwaysAllow
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.AlwaysDeny
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.FiniteBucket
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.FixedWindow
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.SlidingWindow
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.SlidingWindowBucketed
+ Control.NumerusClosus.Typed: instance Control.NumerusClosus.Typed.RateLimiter Control.NumerusClosus.Typed.SlidingWindowCount
+ Control.NumerusClosus.Typed: instance Data.Time.Format.Format.Class.FormatTime Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance Data.Time.Format.Parse.Class.ParseTime Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Base.Semigroup Control.NumerusClosus.Typed.NextDebitable
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.AlwaysAllow
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.AlwaysDeny
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.FiniteBucket
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.FixedWindow
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.NextDebitable
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.SlidingWindow
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.SlidingWindowBucketed
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.SlidingWindowCount
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq a => GHC.Classes.Eq (Control.NumerusClosus.Typed.BucketSize a)
+ Control.NumerusClosus.Typed: instance GHC.Classes.Eq a => GHC.Classes.Eq (Control.NumerusClosus.Typed.BucketsCount a)
+ Control.NumerusClosus.Typed: instance GHC.Classes.Ord Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Classes.Ord a => GHC.Classes.Ord (Control.NumerusClosus.Typed.BucketSize a)
+ Control.NumerusClosus.Typed: instance GHC.Classes.Ord a => GHC.Classes.Ord (Control.NumerusClosus.Typed.BucketsCount a)
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic (a Control.NumerusClosus.Typed.:&& b)
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic (a Control.NumerusClosus.Typed.:|| b)
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.AlwaysAllow
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.AlwaysDeny
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.FiniteBucket
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.FixedWindow
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.SlidingWindow
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.SlidingWindowBucketed
+ Control.NumerusClosus.Typed: instance GHC.Generics.Generic Control.NumerusClosus.Typed.SlidingWindowCount
+ Control.NumerusClosus.Typed: instance GHC.Num.Num Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Num.Num a => GHC.Num.Num (Control.NumerusClosus.Typed.BucketSize a)
+ Control.NumerusClosus.Typed: instance GHC.Num.Num a => GHC.Num.Num (Control.NumerusClosus.Typed.BucketsCount a)
+ Control.NumerusClosus.Typed: instance GHC.Real.Fractional Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Real.Real Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Real.RealFrac Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.AlwaysAllow
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.AlwaysDeny
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.FiniteBucket
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.FixedWindow
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.NextDebitable
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.SlidingWindow
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.SlidingWindowBucketed
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.SlidingWindowCount
+ Control.NumerusClosus.Typed: instance GHC.Show.Show Control.NumerusClosus.Typed.WindowSize
+ Control.NumerusClosus.Typed: instance GHC.Show.Show a => GHC.Show.Show (Control.NumerusClosus.Typed.BucketSize a)
+ Control.NumerusClosus.Typed: instance GHC.Show.Show a => GHC.Show.Show (Control.NumerusClosus.Typed.BucketsCount a)
+ Control.NumerusClosus.Typed: ioPositionInTime :: PositionInTime IO
+ Control.NumerusClosus.Typed: loopSchedule :: RateLimiter a => a -> (a -> IO b) -> IO ()
+ Control.NumerusClosus.Typed: loopScheduleWith :: (Monad m, RateLimiter a) => PositionInTime m -> a -> (a -> m b) -> m ()
+ Control.NumerusClosus.Typed: newtype BucketSize a
+ Control.NumerusClosus.Typed: newtype BucketsCount a
+ Control.NumerusClosus.Typed: newtype FiniteBucket
+ Control.NumerusClosus.Typed: newtype WindowSize
+ Control.NumerusClosus.Typed: schedule :: RateLimiter a => a -> (a -> IO b) -> IO (Either NextDebitable (a, b))
+ Control.NumerusClosus.Typed: scheduleWith :: (Monad m, RateLimiter a) => PositionInTime m -> a -> (a -> m b) -> m (Either NextDebitable (a, b))
+ Control.NumerusClosus.Typed: slidingWindow :: WindowSize -> BucketSize Int -> UTCTime -> SlidingWindow
+ Control.NumerusClosus.Typed: slidingWindowBucketed :: WindowSize -> BucketsCount Int -> BucketSize Integer -> SlidingWindowBucketed
+ Control.NumerusClosus.Typed: slidingWindowCount :: WindowSize -> BucketSize Int -> UTCTime -> SlidingWindowCount
- Control.NumerusClosus: RateLimiter :: (UTCTime -> Either NextDebitable RateLimiter) -> RateLimiter
+ Control.NumerusClosus: RateLimiter :: a -> RateLimiter
- Control.NumerusClosus: slidingWindowBucketed :: WindowSize -> WindowsCount Int -> BucketSize Integer -> RateLimiter
+ Control.NumerusClosus: slidingWindowBucketed :: WindowSize -> BucketsCount Int -> BucketSize Integer -> RateLimiter

Files

numerus-closus.cabal view
@@ -1,6 +1,6 @@ cabal-version:       3.0 name:                numerus-closus-version:             0.1.0.0+version:             0.2.0.0 author:              Gautier DI FOLCO maintainer:          gautier.difolco@gmail.com category:            Scheduling@@ -22,6 +22,7 @@   hs-source-dirs: src   exposed-modules:     Control.NumerusClosus+    Control.NumerusClosus.Typed   other-modules:     Paths_numerus_closus   autogen-modules:
src/Control/NumerusClosus.hs view
@@ -1,4 +1,6 @@+{-# LANGUAGE StrictData #-} {-# LANGUAGE TupleSections #-}+{-# LANGUAGE NoFieldSelectors #-}  -- | -- Module        : Control.NumerusClosus@@ -13,6 +15,10 @@ -- fixed windows, sliding windows, and sliding window counters. Includes AND/OR -- combinators and scheduling helpers. --+-- This module wraps the typed rate-limiter representations from+-- "Control.NumerusClosus.Typed" behind an existential 'RateLimiter' data type,+-- which is itself an instance of the 'Typed.RateLimiter' class.+-- -- > import Control.NumerusClosus -- > import Data.Time (getCurrentTime) -- >@@ -24,19 +30,23 @@ -- >   print result module Control.NumerusClosus   ( RateLimiter (..),-    NextDebitable (..),+    debit, +    -- * Re-exported data types+    Typed.NextDebitable (..),+    Typed.BucketSize (..),+    Typed.WindowSize (..),+    Typed.BucketsCount (..),+     -- * Base helpers     alwaysAllow,     alwaysDeny,      -- * Strategies-    BucketSize (..),     finiteBucket,     fixedWindow,     slidingWindow,     slidingWindowCount,-    WindowsCount (..),     slidingWindowBucketed,      -- * Combinators@@ -46,8 +56,8 @@     anyOf,      -- * Scheduling-    PositionInTime (..),-    ioPositionInTime,+    Typed.PositionInTime (..),+    Typed.ioPositionInTime,     schedule,     scheduleWith,     loopSchedule,@@ -55,170 +65,62 @@   ) where -import Control.Concurrent.Thread.Delay (delay)-import Control.Monad (mfilter)+import qualified Control.NumerusClosus.Typed as Typed import qualified Data.List.NonEmpty as NE-import qualified Data.Map.Strict as Map-import Data.Maybe (fromMaybe)-import qualified Data.Set as Set-import Data.Time (FormatTime, NominalDiffTime, ParseTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)+import Data.Time (UTCTime (..))  -- * Main logic  -- | The core rate limiter type.--- Given the current time, returns either the next debitable time or a new rate limiter state.-newtype RateLimiter = RateLimiter-  { -- | Function to determine if a request is allowed at a given time.-    debit :: UTCTime -> Either NextDebitable RateLimiter-  }+-- An existential wrapper around any typed rate limiter, providing a uniform+-- interface while delegating to the typed implementations from+-- "Control.NumerusClosus.Typed".+data RateLimiter = forall a. (Typed.RateLimiter a) => RateLimiter {wrapped :: a} --- | Represents when the next request can be debited.-data NextDebitable-  = -- | The request will never be debitable again.-    Never-  | -- | The request can be debited from this time onwards.-    DebitableFrom UTCTime-  deriving stock (Eq, Show)+instance Typed.RateLimiter RateLimiter where+  debit (RateLimiter rl) t =+    case Typed.debit rl t of+      Right rl' -> Right $ RateLimiter {wrapped = rl'}+      Left nd -> Left nd -instance Semigroup NextDebitable where-  Never <> _ = Never-  _ <> Never = Never-  DebitableFrom x <> DebitableFrom y = DebitableFrom $ max x y+-- | Determine if a request is allowed at a given time.+-- Returns either the next debitable time or a new rate limiter state.+debit :: RateLimiter -> UTCTime -> Either Typed.NextDebitable RateLimiter+debit = Typed.debit  -- * Base helpers  -- | A rate limiter that always allows requests. alwaysAllow :: RateLimiter-alwaysAllow =-  RateLimiter-    { debit = const $ Right alwaysAllow-    }+alwaysAllow = RateLimiter {wrapped = Typed.alwaysAllow}  -- | A rate limiter that always denies requests. alwaysDeny :: RateLimiter-alwaysDeny =-  RateLimiter-    { debit = const $ Left Never-    }+alwaysDeny = RateLimiter {wrapped = Typed.alwaysDeny}  -- * Strategies --- | The maximum number of requests a bucket can hold.-newtype BucketSize a-  = BucketSize a-  deriving stock (Eq, Ord, Show)-  deriving newtype (Num)- -- | A simple finite bucket that allows exactly @n@ requests.-finiteBucket :: BucketSize Integer -> RateLimiter-finiteBucket (BucketSize n) =-  RateLimiter-    { debit =-        const $-          if n > 0-            then Right $ finiteBucket $ BucketSize (n - 1)-            else Left Never-    }---- | The size of a time window.-newtype WindowSize = WindowSize NominalDiffTime-  deriving stock (Eq, Ord, Show)-  deriving newtype (Num, Fractional, Real, RealFrac, FormatTime, ParseTime)+finiteBucket :: Typed.BucketSize Integer -> RateLimiter+finiteBucket = RateLimiter . Typed.finiteBucket  -- | A fixed window rate limiter that allows a given number of requests per window.-fixedWindow :: WindowSize -> BucketSize Integer -> UTCTime -> RateLimiter-fixedWindow (WindowSize window) (BucketSize maxBucket) startTime = go maxBucket $ addUTCTime window startTime-  where-    go bucket endTime =-      RateLimiter-        { debit =-            \now ->-              let (refreshedBucket, refreshedEndTime) =-                    if now > endTime-                      then (maxBucket, addUTCTime (fromIntegral (floor (diffUTCTime now endTime / window) + 1 :: Integer) * window) endTime)-                      else (bucket, endTime)-               in if refreshedBucket > 0-                    then Right $ go (refreshedBucket - 1) refreshedEndTime-                    else Left $ DebitableFrom $ nextTimeUnit refreshedEndTime-        }+fixedWindow :: Typed.WindowSize -> Typed.BucketSize Integer -> UTCTime -> RateLimiter+fixedWindow ws bs = RateLimiter . Typed.fixedWindow ws bs  -- | A sliding window rate limiter that allows a given number of requests within the sliding window.-slidingWindow :: WindowSize -> BucketSize Int -> RateLimiter-slidingWindow (WindowSize window) (BucketSize maxBucket) = go Set.empty-  where-    go bucket =-      RateLimiter-        { debit =-            \now ->-              let refreshedBucket = snd $ Set.split (addUTCTime ((-1) * window) now) bucket-               in if Set.size refreshedBucket < maxBucket-                    then Right $ go $ Set.insert now refreshedBucket-                    else Left $ DebitableFrom $ nextTimeUnit $ addUTCTime window $ fromMaybe now $ Set.lookupMin refreshedBucket-        }---- | The number of sub-windows for bucketed sliding windows.-newtype WindowsCount a-  = WindowsCount a-  deriving stock (Eq, Ord, Show)-  deriving newtype (Num)---- | Sliding window rate limiter using sub-bucket counters.--- Divides the window into sub-buckets keyed by request arrival times and sums--- their counters. This is a bucketed variant rather than the canonical--- two-window weighted interpolation.-slidingWindowBucketed :: WindowSize -> WindowsCount Int -> BucketSize Integer -> RateLimiter-slidingWindowBucketed (WindowSize window) (WindowsCount windowsCount) (BucketSize maxBucket) = go Map.empty+slidingWindow :: Typed.WindowSize -> Typed.BucketSize Int -> RateLimiter+slidingWindow ws bs = RateLimiter $ Typed.slidingWindow ws bs epoch   where-    bucketWindow = window / fromIntegral windowsCount-    go buckets =-      RateLimiter-        { debit =-            \now ->-              let refreshedBucket =-                    Map.restrictKeys buckets $-                      snd $-                        Set.split (addUTCTime ((-1) * window) now) $-                          Map.keysSet buckets-                  lastBucket =-                    fromMaybe now $-                      mfilter (> addUTCTime ((-1) * bucketWindow) now) $-                        fst <$> Map.lookupMax refreshedBucket-               in if sum refreshedBucket < maxBucket-                    then Right $ go $ Map.alter (Just . maybe 1 (+ 1)) lastBucket refreshedBucket-                    else Left $ DebitableFrom $ nextTimeUnit $ addUTCTime window $ maybe now fst $ Map.lookupMin refreshedBucket-        }+    epoch = UTCTime (toEnum 0) 0  -- | Sliding window counter using two-window weighted interpolation.--- Tracks counters for the current and previous fixed windows, then estimates--- the request rate as: @prev * (1 - elapsed\/window) + current@.--- This is the canonical sliding window counter algorithm with O(1) memory.-slidingWindowCount :: WindowSize -> BucketSize Integer -> UTCTime -> RateLimiter-slidingWindowCount (WindowSize window) (BucketSize maxBucket) startTime = go (0 :: Integer) 0 $ addUTCTime window startTime-  where-    go prevCount currentCount windowEnd =-      RateLimiter-        { debit =-            \now ->-              let (refreshedPrev, refreshedCurrent, refreshedEnd) =-                    if now > windowEnd-                      then-                        let windowsElapsed = floor (diffUTCTime now windowEnd / window) :: Integer-                            newEnd = addUTCTime (fromIntegral (windowsElapsed + 1) * window) windowEnd-                         in if windowsElapsed == 0-                              then (currentCount, 0, newEnd)-                              else (0, 0, newEnd)-                      else (prevCount, currentCount, windowEnd)-                  windowStart = addUTCTime ((-1) * window) refreshedEnd-                  fraction = realToFrac (diffUTCTime now windowStart) / realToFrac window :: Double-                  estimate = fromIntegral refreshedPrev * (1 - fraction) + fromIntegral refreshedCurrent :: Double-               in if estimate < fromIntegral maxBucket-                    then Right $ go refreshedPrev (refreshedCurrent + 1) refreshedEnd-                    else Left $ DebitableFrom $ nextTimeUnit refreshedEnd-        }+slidingWindowCount :: Typed.WindowSize -> Typed.BucketSize Integer -> UTCTime -> RateLimiter+slidingWindowCount ws (Typed.BucketSize bs) = RateLimiter . Typed.slidingWindowCount ws (Typed.BucketSize $ fromIntegral bs) --- | Helper to get the smallest next time unit for debiting.-nextTimeUnit :: UTCTime -> UTCTime-nextTimeUnit = addUTCTime 0.000001+-- | Sliding window rate limiter using sub-bucket counters.+slidingWindowBucketed :: Typed.WindowSize -> Typed.BucketsCount Int -> Typed.BucketSize Integer -> RateLimiter+slidingWindowBucketed ws bc bs = RateLimiter $ Typed.slidingWindowBucketed ws bc bs  -- * Combinators @@ -226,12 +128,7 @@ (.&&) :: RateLimiter -> RateLimiter -> RateLimiter x .&& y =   RateLimiter-    { debit = \at ->-        case (x.debit at, y.debit at) of-          (Right x', Right y') -> Right $ x' .&& y'-          (Left x', Left y') -> Left $ x' <> y'-          (Left x', _) -> Left x'-          (_, Left y') -> Left y'+    { wrapped = x Typed.:&& y     }  infixr 3 .&&@@ -240,18 +137,7 @@ (.||) :: RateLimiter -> RateLimiter -> RateLimiter x .|| y =   RateLimiter-    { debit = \at ->-        case (x.debit at, y.debit at) of-          (Right x', Right y') -> Right $ x' .|| y'-          (Left x', Left y') ->-            Left $-              case (x', y') of-                (Never, Never) -> Never-                (DebitableFrom x'', DebitableFrom y'') -> DebitableFrom $ min x'' y''-                (DebitableFrom x'', _) -> DebitableFrom x''-                (_, DebitableFrom y'') -> DebitableFrom y''-          (Right x', _) -> Right x'-          (_, Right y') -> Right y'+    { wrapped = x Typed.:|| y     }  infixr 3 .||@@ -266,45 +152,29 @@  -- * Scheduling --- | Fetch the time and compute the delay time.-data PositionInTime m = PositionInTime-  { -- | How to fetch the current time.-    getTime :: m UTCTime,-    -- | How to delay until a specific time.-    delayUntil :: UTCTime -> m ()-  }- -- | Run an IO action if the rate limiter allows it, using the current time.-schedule :: RateLimiter -> IO a -> IO (Either NextDebitable (RateLimiter, a))-schedule = scheduleWith ioPositionInTime---- | Default time strategy for IO.-ioPositionInTime :: PositionInTime IO-ioPositionInTime = PositionInTime getCurrentTime diffDelay-  where-    diffDelay to = do-      now <- getCurrentTime-      delay $ 0 `max` round (diffUTCTime to now * 1000000)+schedule :: RateLimiter -> IO a -> IO (Either Typed.NextDebitable (RateLimiter, a))+schedule = scheduleWith Typed.ioPositionInTime  -- | Run a monadic action if the rate limiter allows it, using a custom time strategy.-scheduleWith :: (Monad m) => PositionInTime m -> RateLimiter -> m a -> m (Either NextDebitable (RateLimiter, a))+scheduleWith :: (Monad m) => Typed.PositionInTime m -> RateLimiter -> m a -> m (Either Typed.NextDebitable (RateLimiter, a)) scheduleWith pit rl action = do   now <- pit.getTime-  case rl.debit now of+  case debit rl now of     Right rl' -> Right . (rl',) <$> action     Left nd -> return $ Left nd  -- | Repeatedly run an IO action, respecting the rate limiter. Will sleep until the next available slot if rate limited. loopSchedule :: RateLimiter -> IO a -> IO ()-loopSchedule = loopScheduleWith ioPositionInTime+loopSchedule = loopScheduleWith Typed.ioPositionInTime  -- | Repeatedly run a monadic action, respecting the rate limiter and using a custom time strategy. Will sleep until the next available slot if rate limited.-loopScheduleWith :: (Monad m) => PositionInTime m -> RateLimiter -> m a -> m ()+loopScheduleWith :: (Monad m) => Typed.PositionInTime m -> RateLimiter -> m a -> m () loopScheduleWith pit rl action = go rl   where     go rl' = do       result <- scheduleWith pit rl' action       case result of         Right (rl'', _) -> go rl''-        Left Never -> return ()-        Left (DebitableFrom at) -> pit.delayUntil at >> go rl'+        Left Typed.Never -> return ()+        Left (Typed.DebitableFrom at) -> pit.delayUntil at >> go rl'
+ src/Control/NumerusClosus/Typed.hs view
@@ -0,0 +1,360 @@+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE StrictData #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE NoFieldSelectors #-}++-- |+-- Module        : Control.NumerusClosus.Typed+-- Copyright     : Gautier DI FOLCO+-- License       : ISC+--+-- Maintainer    : Gautier DI FOLCO <gautier.difolco@gmail.com>+-- Stability     : Stable+-- Portability   : Portable+--+-- Simple, composable, pure rate-limiting primitives supporting finite buckets,+-- fixed windows, sliding windows, and sliding window counters. Includes AND/OR+-- combinators and scheduling helpers.+--+-- > import Control.NumerusClosus+-- > import Data.Time (getCurrentTime)+-- >+-- > main :: IO ()+-- > main = do+-- >   now <- getCurrentTime+-- >   let rl = fixedWindow 60 5 now  -- 5 requests per 60 seconds+-- >   result <- schedule rl (putStrLn "Request allowed")+-- >   print result+module Control.NumerusClosus.Typed+  ( RateLimiter (..),+    NextDebitable (..),++    -- * Base helpers+    AlwaysAllow (..),+    alwaysAllow,+    AlwaysDeny (..),+    alwaysDeny,++    -- * Strategies+    BucketSize (..),+    FiniteBucket (..),+    finiteBucket,+    WindowSize (..),+    FixedWindow (..),+    fixedWindow,+    SlidingWindow (..),+    slidingWindow,+    SlidingWindowCount (..),+    slidingWindowCount,+    BucketsCount (..),+    SlidingWindowBucketed (..),+    slidingWindowBucketed,++    -- * Combinators+    type (:&&) (..),+    type (:||) (..),++    -- * Scheduling+    PositionInTime (..),+    ioPositionInTime,+    schedule,+    scheduleWith,+    loopSchedule,+    loopScheduleWith,+  )+where++import Control.Concurrent.Thread.Delay (delay)+import Control.Monad (mfilter)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe)+import qualified Data.Set as Set+import Data.Time (FormatTime, NominalDiffTime, ParseTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)+import GHC.Generics (Generic)++-- * Main logic++-- | The core rate limiter type.+-- Given the current time, returns either the next debitable time or a new rate limiter state.+class RateLimiter a where+  -- | Function to determine if a request is allowed at a given time.+  debit :: a -> UTCTime -> Either NextDebitable a++-- | Represents when the next request can be debited.+data NextDebitable+  = -- | The request will never be debitable again.+    Never+  | -- | The request can be debited from this time onwards.+    DebitableFrom UTCTime+  deriving stock (Eq, Show)++instance Semigroup NextDebitable where+  Never <> _ = Never+  _ <> Never = Never+  DebitableFrom x <> DebitableFrom y = DebitableFrom $ max x y++-- * Base helpers++-- | A rate limiter that always allows requests.+data AlwaysAllow = AlwaysAllow+  deriving stock (Eq, Show, Generic)++instance RateLimiter AlwaysAllow where+  debit AlwaysAllow _ = Right AlwaysAllow++-- | Smart constructor for 'AlwaysAllow'.+alwaysAllow :: AlwaysAllow+alwaysAllow = AlwaysAllow++-- | A rate limiter that always denies requests.+data AlwaysDeny = AlwaysDeny+  deriving stock (Eq, Show, Generic)++instance RateLimiter AlwaysDeny where+  debit AlwaysDeny _ = Left Never++-- | Smart constructor for 'AlwaysDeny'.+alwaysDeny :: AlwaysDeny+alwaysDeny = AlwaysDeny++-- * Strategies++-- | The maximum number of requests a bucket can hold.+newtype BucketSize a = BucketSize+  { unBucketSize :: a+  }+  deriving stock (Eq, Ord, Show)+  deriving newtype (Num)++-- | A simple finite bucket that allows exactly @n@ requests.+newtype FiniteBucket+  = FiniteBucket {fullBucket :: BucketSize Integer}+  deriving stock (Eq, Show, Generic)++instance RateLimiter FiniteBucket where+  debit (FiniteBucket (BucketSize n)) _ =+    if n > 0+      then Right $ FiniteBucket $ BucketSize (n - 1)+      else Left Never++finiteBucket :: BucketSize Integer -> FiniteBucket+finiteBucket fullBucket = FiniteBucket {..}++-- | The size of a time window.+newtype WindowSize = WindowSize+  { unWindowSize :: NominalDiffTime+  }+  deriving stock (Eq, Ord, Show)+  deriving newtype (Num, Fractional, Real, RealFrac, FormatTime, ParseTime)++-- | A fixed window rate limiter that allows a given number of requests per window.+data FixedWindow = FixedWindow+  { windowSize :: WindowSize,+    maxBucket :: BucketSize Integer,+    -- \* Iteration fields+    bucket :: BucketSize Integer,+    windowStart :: UTCTime,+    windowEnd :: UTCTime+  }+  deriving stock (Eq, Show, Generic)++instance RateLimiter FixedWindow where+  debit (FixedWindow {..}) now =+    if refreshedBucket > 0+      then Right $ FixedWindow {bucket = refreshedBucket - 1, windowEnd = refreshedEndTime, ..}+      else Left $ DebitableFrom $ nextTimeUnit refreshedEndTime+    where+      (refreshedBucket, refreshedEndTime) =+        if now > windowEnd+          then (maxBucket, addUTCTime (fromIntegral (floor (diffUTCTime now windowEnd / windowSize.unWindowSize) + 1 :: Integer) * windowSize.unWindowSize) windowEnd)+          else (bucket, windowEnd)++fixedWindow :: WindowSize -> BucketSize Integer -> UTCTime -> FixedWindow+fixedWindow windowSize@(WindowSize window) maxBucket windowStart =+  FixedWindow {bucket = maxBucket, windowEnd = addUTCTime window windowStart, ..}++-- | A sliding window rate limiter that allows a given number of requests within the sliding window.+data SlidingWindow = SlidingWindow+  { windowSize :: WindowSize,+    maxBucket :: BucketSize Int,+    -- \* Iteration field+    windowStart :: UTCTime,+    bucket :: Set.Set UTCTime+  }+  deriving stock (Eq, Show, Generic)++instance RateLimiter SlidingWindow where+  debit SlidingWindow {..} now =+    if Set.size refreshedBucket < maxBucket.unBucketSize+      then Right $ SlidingWindow {bucket = Set.insert now refreshedBucket, ..}+      else Left $ DebitableFrom $ nextTimeUnit $ addUTCTime windowSize.unWindowSize $ fromMaybe now $ Set.lookupMin refreshedBucket+    where+      refreshedBucket = snd $ Set.split (addUTCTime ((-1) * windowSize.unWindowSize) now) bucket++slidingWindow :: WindowSize -> BucketSize Int -> UTCTime -> SlidingWindow+slidingWindow windowSize maxBucket windowStart =+  SlidingWindow {bucket = Set.empty, ..}++-- | The number of sub-windows for bucketed sliding windows.+newtype BucketsCount a = BucketsCount+  { unBucketsCount :: a+  }+  deriving stock (Eq, Ord, Show)+  deriving newtype (Num)++-- | Sliding window rate limiter using sub-bucket counters.+-- Divides the window into sub-buckets keyed by request arrival times and sums+-- their counters. This is a bucketed variant rather than the canonical+-- two-window weighted interpolation.+data SlidingWindowBucketed = SlidingWindowBucketed+  { windowSize :: WindowSize,+    bucketsCount :: BucketsCount Int,+    maxBucket :: BucketSize Integer,+    -- \* Iteration field+    buckets :: Map.Map UTCTime Integer+  }+  deriving stock (Eq, Show, Generic)++instance RateLimiter SlidingWindowBucketed where+  debit SlidingWindowBucketed {..} now =+    if sum refreshedBucket < maxBucket.unBucketSize+      then Right $ SlidingWindowBucketed {buckets = Map.alter (Just . maybe 1 (+ 1)) lastBucket refreshedBucket, ..}+      else Left $ DebitableFrom $ nextTimeUnit $ addUTCTime windowSize.unWindowSize $ maybe now fst $ Map.lookupMin refreshedBucket+    where+      refreshedBucket =+        Map.restrictKeys buckets $+          snd $+            Set.split (addUTCTime ((-1) * windowSize.unWindowSize) now) $+              Map.keysSet buckets+      lastBucket =+        fromMaybe now $+          mfilter (> addUTCTime ((-1) * bucketWindow) now) $+            fst <$> Map.lookupMax refreshedBucket+      bucketWindow = windowSize.unWindowSize / fromIntegral bucketsCount.unBucketsCount++slidingWindowBucketed :: WindowSize -> BucketsCount Int -> BucketSize Integer -> SlidingWindowBucketed+slidingWindowBucketed windowSize bucketsCount maxBucket =+  SlidingWindowBucketed {buckets = Map.empty, ..}++-- | Sliding window counter using two-window weighted interpolation.+-- Tracks counters for the current and previous fixed windows, then estimates+-- the request rate as: @prev * (1 - elapsed\/window) + current@.+-- This is the canonical sliding window counter algorithm with O(1) memory.+data SlidingWindowCount = SlidingWindowCount+  { windowSize :: WindowSize,+    maxBucket :: BucketSize Int,+    -- \* Iteration field+    prevCount :: Integer,+    currentCount :: Integer,+    windowStart :: UTCTime,+    windowEnd :: UTCTime+  }+  deriving stock (Eq, Show, Generic)++instance RateLimiter SlidingWindowCount where+  debit SlidingWindowCount {..} now =+    if estimate < fromIntegral maxBucket.unBucketSize+      then Right $ SlidingWindowCount {prevCount = refreshedPrev, currentCount = refreshedCurrent + 1, windowEnd = refreshedEnd, ..}+      else Left $ DebitableFrom $ nextTimeUnit refreshedEnd+    where+      (refreshedPrev, refreshedCurrent, refreshedEnd) =+        if now > windowEnd+          then+            let windowsElapsed = floor (diffUTCTime now windowEnd / windowSize.unWindowSize) :: Integer+                newEnd = addUTCTime (fromIntegral (windowsElapsed + 1) * windowSize.unWindowSize) windowEnd+             in if windowsElapsed == 0+                  then (currentCount, 0, newEnd)+                  else (0, 0, newEnd)+          else (prevCount, currentCount, windowEnd)+      newWindowStart = addUTCTime ((-1) * windowSize.unWindowSize) refreshedEnd+      fraction = realToFrac (diffUTCTime now newWindowStart) / realToFrac windowSize.unWindowSize :: Double+      estimate = fromIntegral refreshedPrev * (1 - fraction) + fromIntegral refreshedCurrent :: Double++slidingWindowCount :: WindowSize -> BucketSize Int -> UTCTime -> SlidingWindowCount+slidingWindowCount windowSize@(WindowSize window) maxBucket windowStart =+  SlidingWindowCount {prevCount = 0, currentCount = 0, windowEnd = addUTCTime window windowStart, ..}++-- | Helper to get the smallest next time unit for debiting.+nextTimeUnit :: UTCTime -> UTCTime+nextTimeUnit = addUTCTime 0.000001++-- * Combinators++-- | AND combinator type: allows a request if both rate limiters allow it.+data a :&& b = a :&& b+  deriving stock (Eq, Show, Generic)++infixr 3 :&&++instance (RateLimiter a, RateLimiter b) => RateLimiter (a :&& b) where+  debit (x :&& y) at =+    case (debit x at, debit y at) of+      (Right x', Right y') -> Right $ x' :&& y'+      (Left x', Left y') -> Left $ x' <> y'+      (Left x', _) -> Left x'+      (_, Left y') -> Left y'++-- | OR combinator type: allows a request if either rate limiter allows it.+data a :|| b = a :|| b+  deriving stock (Eq, Show, Generic)++infixr 3 :||++instance (RateLimiter a, RateLimiter b) => RateLimiter (a :|| b) where+  debit (x :|| y) at =+    case (debit x at, debit y at) of+      (Right x', Right y') -> Right $ x' :|| y'+      (Left x', Left y') ->+        Left $+          case (x', y') of+            (Never, Never) -> Never+            (DebitableFrom x'', DebitableFrom y'') -> DebitableFrom $ min x'' y''+            (DebitableFrom x'', _) -> DebitableFrom x''+            (_, DebitableFrom y'') -> DebitableFrom y''+      (Right x', _) -> Right $ x' :|| y+      (_, Right y') -> Right $ x :|| y'++-- * Scheduling++-- | Fetch the time and compute the delay time.+data PositionInTime m = PositionInTime+  { -- | How to fetch the current time.+    getTime :: m UTCTime,+    -- | How to delay until a specific time.+    delayUntil :: UTCTime -> m ()+  }++-- | Run an IO action if the rate limiter allows it, using the current time.+schedule :: (RateLimiter a) => a -> (a -> IO b) -> IO (Either NextDebitable (a, b))+schedule = scheduleWith ioPositionInTime++-- | Default time strategy for IO.+ioPositionInTime :: PositionInTime IO+ioPositionInTime = PositionInTime getCurrentTime diffDelay+  where+    diffDelay to = do+      now <- getCurrentTime+      delay $ 0 `max` round (diffUTCTime to now * 1000000)++-- | Run a monadic action if the rate limiter allows it, using a custom time strategy.+scheduleWith :: (Monad m, RateLimiter a) => PositionInTime m -> a -> (a -> m b) -> m (Either NextDebitable (a, b))+scheduleWith pit rl action = do+  now <- pit.getTime+  case debit rl now of+    Right rl' -> Right . (rl',) <$> action rl'+    Left nd -> return $ Left nd++-- | Repeatedly run an IO action, respecting the rate limiter. Will sleep until the next available slot if rate limited.+loopSchedule :: (RateLimiter a) => a -> (a -> IO b) -> IO ()+loopSchedule = loopScheduleWith ioPositionInTime++-- | Repeatedly run a monadic action, respecting the rate limiter and using a custom time strategy. Will sleep until the next available slot if rate limited.+loopScheduleWith :: (Monad m, RateLimiter a) => PositionInTime m -> a -> (a -> m b) -> m ()+loopScheduleWith pit rl action = go rl+  where+    go rl' = do+      result <- scheduleWith pit rl' action+      case result of+        Right (rl'', _) -> go rl''+        Left Never -> return ()+        Left (DebitableFrom at) -> pit.delayUntil at >> go rl'