Introducing numerus-closus
Last week, I introduced on Hackage numerus-closus, a set of rate-limiting primitives.
Usually we try to rate-limit the inbound traffic, but here the context is a bit different; we are trying to, to some extent, rate-limit the outbound traffic, respecting the quota.
It's true that in some cases, for example, HTTP, there are some attempts to normalize rate-limit in a pull fashion, for example, emitting a response header to specify the current quota, but here we are attempting to limit SNTP (email sending).
I attempted to see if there was any library that could handle it, but they were having some of the following trade-offs, which were unacceptable for my use case:
- Bound to only one algorithm * Embedding the primitive regarding the concurrency
- Coupled with a technology (servant, wai, persistent) or
IO - Bound to only a time frame
Let's start with a simple type representing the result of consuming a token, as follows:
newtype RateLimiter = RateLimiter
{ debit :: UTCTime -> Either NextDebitable RateLimiter
}
data NextDebitable
= DebitableFrom UTCTime
| Never
deriving stock (Eq, Ord, Show)
The main idea is to turn the problem into a discrete expression, asking: "At this time, can I get a token, or when will the next one be available?"
Note: in a sense, it follows antikythera design.
We can express a few trivial rate limiters as the following ones:
alwaysAllow :: RateLimiter
alwaysAllow =
RateLimiter
{ debit = const $ Right alwaysAllow
}
alwaysDeny :: RateLimiter
alwaysDeny =
RateLimiter
{ debit = const $ Left Never
}
We can also define basic algebra functions as and, and or as follows:
(.&&) :: 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 $ getLast $ Last x' <> Last y'
(Left x', _) -> Left x'
(_, Left y') -> Left y'
}
infixr 3 .&&
(.||) :: 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 $ getFirst $ First x' <> First y'
(Right x', _) -> Right $ x' .|| y
(_, Right y') -> Right $ x .|| y'
}
infixr 3 .||
We have leveraged First/Last Semigroup instances derived from Ord.
We also can derive foldable-based versions, as follows:
allOf :: NE.NonEmpty RateLimiter -> RateLimiter
allOf = foldl1 (.&&)
anyOf :: NE.NonEmpty RateLimiter -> RateLimiter
anyOf = foldl1 (.||)
An easy algorithm to implement is a bucket with a finite number of tokens, as follows:
newtype BucketSize a
= BucketSize a
deriving stock (Eq, Ord, Show)
deriving newtype (Num)
finiteBucket :: BucketSize Integer -> RateLimiter
finiteBucket (BucketSize n) =
RateLimiter
{ debit =
const $
if n > 0
then Right $ finiteBucket $ BucketSize (n - 1)
else Left Never
}
The idea is simple: given a bucket size, until it is empty, re-create the rate limiter with a smaller bucket size.
The next one is fixed window, implemented as follows:
newtype WindowSize = WindowSize NominalDiffTime
deriving stock (Eq, Ord, Show)
deriving newtype (Num, Fractional, Real, RealFrac, FormatTime, ParseTime)
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
}
nextTimeUnit :: UTCTime -> UTCTime
nextTimeUnit = addUTCTime 0.000001
The idea is to have a finite bucket that is reset when the current time is beyond the current window.
It is a bit more involved than the previous, as we had to use a helper function to hold the internal state.
The next one is sliding window, which registers each token, cleaning those outside of the current window, which ends with the current time, implemented as follows:
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
}
Note: We rely on Set, with its internal tree representation; cleaning with split is only O(ln n).
Sadly, it has a space complexity of O(bucket size).
This is where sliding window bucketed is useful; it splits a window into sub-windows, only counting in each of them, as follows:
newtype WindowsCount a
= WindowsCount a
deriving stock (Eq, Ord, Show)
deriving newtype (Num)
slidingWindowBucketed :: WindowSize -> WindowsCount Int -> BucketSize Integer -> RateLimiter
slidingWindowBucketed (WindowSize window) (WindowsCount windowsCount) (BucketSize maxBucket) = go Map.empty
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
}
We store each sub-window in a Map; given that keys are stored in a Set, we can reuse split to clean up old entries.
The last one of this log is the most famous one, called sliding window count; it is implemented as follows:
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
}
It uses two windows, the current and the previous one, estimating the request rate with prev * (1 - elapsed\/window) + current.
That's it for this introduction.
The library goes beyond this:
- Support of token bucket, leaky bucket, GCRA algorithm
- Scheduling and loop functions
- A typed version, which is useful for persistence