keter-rate-limiting-plugin (empty) → 0.1.0.0
raw patch · 28 files changed
+7565/−0 lines, 28 filesdep +HUnitdep +aesondep +async
Dependencies added: HUnit, aeson, async, base, bytestring, cache, case-insensitive, clock, containers, deepseq, directory, filepath, focus, hashable, http-types, iproute, keter-rate-limiting-plugin, list-t, network, random, stm, stm-containers, tasty, tasty-hunit, temporary, text, time, unordered-containers, wai, wai-extra
Files
- CHANGELOG.md +6/−0
- LICENSE +76/−0
- README.md +184/−0
- keter-rate-limiting-plugin.cabal +114/−0
- src/Data/TinyLRU.hs +458/−0
- src/Keter/RateLimiter/AutoPurge.hs +573/−0
- src/Keter/RateLimiter/Cache.hs +1240/−0
- src/Keter/RateLimiter/CacheWithZone.hs +164/−0
- src/Keter/RateLimiter/IPZones.hs +176/−0
- src/Keter/RateLimiter/LeakyBucket.hs +124/−0
- src/Keter/RateLimiter/Notifications.hs +514/−0
- src/Keter/RateLimiter/RequestUtils.hs +248/−0
- src/Keter/RateLimiter/SlidingWindow.hs +139/−0
- src/Keter/RateLimiter/TokenBucket.hs +168/−0
- src/Keter/RateLimiter/TokenBucketWorker.hs +194/−0
- src/Keter/RateLimiter/Types.hs +233/−0
- src/Keter/RateLimiter/WAI.hs +224/−0
- test/Keter/RateLimiter/Cache/PurgeTests.hs +55/−0
- test/Keter/RateLimiter/IPZonesTests.hs +95/−0
- test/Keter/RateLimiter/LeakyBucketStateTests.hs +72/−0
- test/Keter/RateLimiter/LeakyBucketTests.hs +316/−0
- test/Keter/RateLimiter/NotificationTests.hs +197/−0
- test/Keter/RateLimiter/SlidingWindowTests.hs +295/−0
- test/Keter/RateLimiter/TokenBucketStateTests.hs +68/−0
- test/Keter/RateLimiter/TokenBucketTests.hs +286/−0
- test/Keter/RateLimiter/WAITests.hs +361/−0
- test/Main.hs +569/−0
- test/TinyLRUTests.hs +416/−0
+ CHANGELOG.md view
@@ -0,0 +1,6 @@+# Revision history for keter-rate-limiting-plugin++## 0.1.0.0 -- 2025-08-08++* First version. Released on an unsuspecting world.+
+ LICENSE view
@@ -0,0 +1,76 @@+The original code of rack-attack is licensed under the MIT License:++The MIT License++Copyright (c) 2016 Kickstarter, PBC++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in+all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+THE SOFTWARE.++~~~++The original code of Ruby on Rails is licensed under the MIT License:++Copyright (c) David Heinemeier Hansson++Permission is hereby granted, free of charge, to any person obtaining+a copy of this software and associated documentation files (the+"Software"), to deal in the Software without restriction, including+without limitation the rights to use, copy, modify, merge, publish,+distribute, sublicense, and/or sell copies of the Software, and to+permit persons to whom the Software is furnished to do so, subject to+the following conditions:++The above copyright notice and this permission notice shall be+included in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.++~~~++Haskell code with modifications and new components created by Oleksandr Zhabenko +using AI code generation are licensed under the MIT licence:++MIT License++Copyright (c) 2025 Oleksandr Zhabenko++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.+
+ README.md view
@@ -0,0 +1,184 @@+# keter-rate-limiting-plugin++**keter-rate-limiting-plugin** is a modern, high-performance, and highly customizable rate-limiting plugin for [Keter](https://github.com/snoyberg/keter). It addresses [issue \#301](https://github.com/snoyberg/keter/issues/301) and brings robust, production-grade request throttling to Haskell web applications, featuring efficient in-memory caching and IP zone isolation.++This library is inspired by [rack-attack](https://github.com/rack/rack-attack) and and [Ruby on Rails](https://github.com/rails/rails) (for Keter.RateLimiter.Notifications) and provides a powerful middleware for Keter-managed applications, though it can be integrated with any WAI-compatible Haskell web stack.++## Features++ - **Five window algorithms**:+ - Fixed Window+ - Sliding Window+ - Token Bucket+ - Leaky Bucket+ - TinyLRU (Least Recently Used)+ - **IP Zone Support**: Isolate caches and throttling policies per IP zone, customer segment, or any other logical grouping.+ - **Flexible Throttle Configuration**: Set limits, periods, algorithms, and unique identifiers on a per-throttle basis.+ - **WAI Middleware**: Integrates seamlessly as a middleware into any WAI application.+ - **Convenient and Customizable API**:+ - Use simple wrappers for common scenarios with automatic key composition.+ - Or, for advanced use, fully control cache key structure and throttling logic.+ - **Memory-efficient**: Designed for large-scale, high-traffic deployments with automatic cleanup of expired entries.+ - **Easy Integration**: Minimal code changes are required to get started.++## Why Use This Plugin?++ - **Scalability**: Per-zone caches and flexible throttling allow you to scale from single-user apps to multi-tenant platforms.+ - **Performance**: The in-memory backend is built on efficient STM-based containers for high-concurrency workloads.+ - **Security**: Protects your application from abusive clients and denial-of-service attacks.+ - **Flexibility**: Choose between the convenience of wrappers and the full customizability of manual key management.+ - **Production-Ready**: Inspired by industry-standard tools, thoroughly documented, and designed for reliability.+ - **Open Source**: MIT licensed and community-friendly.++## Installation++Add the package to your `build-depends` in your project's `.cabal` file or `package.yaml`.++**For Cabal:**++```cabal+build-depends:+ , keter-rate-limiting-plugin+```++**For Stack (`package.yaml`):**++```yaml+dependencies:+- keter-rate-limiting-plugin+```++Then, rebuild your project. No external C libraries are required.++## Quick Start++The following example sets up a simple WAI application with a single rate-limiting rule: 10 requests per 60 seconds from a given IP address. It also demonstrates assigning requests to different IP zones.++```haskell+{-# LANGUAGE OverloadedStrings #-}++import Keter.RateLimiter.WAI+import Keter.RateLimiter.Cache (Algorithm(..))+import Keter.RateLimiter.IPZones (defaultIPZone)+import Network.Wai (Request, responseLBS, Application)+import Network.HTTP.Types (status200)+import Network.Wai.Handler.Warp (run)+import Data.Text.Encoding (encodeUtf8)++-- A simple application that runs behind the middleware.+myApp :: Application+myApp _ respond = respond $ responseLBS status200 [] "Hello, you are not rate limited!"++main :: IO ()+main = do+ -- 1. Initialize the rate limiter configuration.+ -- This function determines the IP Zone for each request.+ -- Here, we route traffic from "127.0.0.1" to "local_zone" and all other traffic to the default zone.+ env <- initConfig (\req -> if requestHeaderHost req == Just "127.0.0.1" then "local_zone" else defaultIPZone)++ -- 2. Define a throttle rule.+ let ipThrottle = ThrottleConfig+ { throttleLimit = 10+ , throttlePeriod = 60+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> fmap (encodeUtf8 . show) (remoteHost req) -- Identify requests by IP address+ , throttleTokenBucketTTL = Nothing -- Not used for FixedWindow+ }++ -- 3. Add the throttle rule to the environment.+ env' <- addThrottle env "req/ip" ipThrottle++ -- 4. Wrap your application with the middleware.+ let appWithMiddleware = attackMiddleware env' myApp++ putStrLn "Server starting on port 8080..."+ run 8080 appWithMiddleware+```++## Example Usage++### Using the Convenient API++The `CacheWithZone` module provides helpers that automatically compose cache keys from the algorithm, zone, and user key, simplifying common use cases.++```haskell+import Keter.RateLimiter.Cache+import Keter.RateLimiter.CacheWithZone++-- Create a store and cache for the Fixed Window algorithm+fixedWindowStore <- createInMemoryStore @'FixedWindow+let cache = newCache FixedWindow fixedWindowStore++-- Increment a counter for a user in a specific zone.+-- The key "rate_limiter:zoneX:userX" is created automatically.+-- The request is allowed if the count is within the limit.+isAllowed <- allowFixedWindowRequest cache "zoneX" "userX" 100 3600 -- 100 requests per hour+```++### Using the Customizable API++For more complex scenarios, you can manually construct cache keys and interact directly with the `Cache` module. This gives you full control over the key structure.++```haskell+import Keter.RateLimiter.Cache++-- Use the same cache from the previous example.+let customKey = "rate_limiter:fixed_window:logins:zoneY:userY"++-- Manually increment the counter for the custom key.+newCount <- incrementCache cache customKey 60 -- TTL of 60 seconds++-- Manually read the value.+mVal <- readCache cache customKey :: IO (Maybe Int)+```++### Token Bucket Example (with TTL)++The Token Bucket algorithm allows for bursts of traffic. You can also specify a TTL for how long an idle bucket remains in memory.++```haskell+import Keter.RateLimiter.WAI+import Keter.RateLimiter.Cache (Algorithm(..))++let tokenBucketThrottle = ThrottleConfig+ { throttleLimit = 100 -- Bucket capacity+ , throttlePeriod = 60 -- Refills 100 tokens over 60 seconds+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = \req -> getAuthToken req -- A function to get a user's API token+ , throttleTokenBucketTTL = Just 7200 -- Purge idle buckets after 2 hours+ }++-- env' <- addThrottle env "api/token" tokenBucketThrottle+```++## Testing++This package includes an extensive test suite covering all supported rate-limiting algorithms, IP zone isolation, and cache management.++To run the tests:++```bash+cabal test+```++or++```bash+stack test+```++## When to Use This Library++ - You need robust and efficient request throttling for your Haskell web application.+ - You want to protect your service from abuse and DoS attacks.+ - You require per-zone or per-user isolation of throttling policies.+ - You value both convenience and the ability to customize behavior as needed.++## License++MIT License © 2025 Oleksandr Zhabenko++## References++ - [rack-attack (Ruby)](https://github.com/rack/rack-attack)+ - [keter (Haskell)](https://github.com/snoyberg/keter)
+ keter-rate-limiting-plugin.cabal view
@@ -0,0 +1,114 @@+cabal-version: 3.0+name: keter-rate-limiting-plugin+version: 0.1.0.0+synopsis: Simple Keter rate limiting plugin.+description:+ A modern, high-performance, and highly customisable rate limiting plugin for keter.+ Provides four window algorithms (Fixed Window, Sliding Window, Token Bucket, Leaky Bucket),+ IP zone support, and convenient and customisable API.+ See README.md and homepage for usage and details.+homepage: https://github.com/Oleksandr-Zhabenko/keter-rate-limiting-plugin+license: MIT+license-file: LICENSE+author: Oleksandr Zhabenko+maintainer: oleksandr.zhabenko@yahoo.com+copyright: 2025 Oleksandr Zhabenko+category: Network+build-type: Simple+extra-doc-files: CHANGELOG.md, README.md+extra-source-files: README.md+tested-with: GHC ==9.2.8, GHC ==9.4.8, GHC ==9.6.5++common warnings+ ghc-options: -Wall++library+ import: warnings+ exposed-modules:+ Data.TinyLRU+ Keter.RateLimiter.WAI+ Keter.RateLimiter.Cache+ Keter.RateLimiter.CacheWithZone+ Keter.RateLimiter.IPZones+ Keter.RateLimiter.SlidingWindow+ Keter.RateLimiter.TokenBucketWorker+ Keter.RateLimiter.TokenBucket+ Keter.RateLimiter.LeakyBucket+ Keter.RateLimiter.Types+ Keter.RateLimiter.AutoPurge+ Keter.RateLimiter.Notifications+ Keter.RateLimiter.RequestUtils+ -- other-modules: (add if you have internal modules)+ default-extensions: DataKinds, DeriveGeneric, FlexibleContexts, FlexibleInstances, ForeignFunctionInterface, FunctionalDependencies, InstanceSigs, KindSignatures, MultiParamTypeClasses, OverloadedStrings, ScopedTypeVariables, TypeApplications, GADTs+ build-depends:+ base >= 4.7 && < 5,+ text >= 1.2,+ bytestring >= 0.10,+ wai >=3.2.3 && <3.3,+ iproute >=1.7.10,+ network >=3.1.2 && <3.2 || ^>=3.2.0,+ http-types >=0.12.3 && <0.13,+ directory >= 1.3.4.0 && <1.4,+ case-insensitive >=1.2.1.0,+ aeson >= 1.4,+ cache >= 0.1,+ containers >= 0.6,+ time >= 1.9,+ focus >=1.0.3,+ stm >=2.5.0 && <2.6,+ stm-containers >=1.2 && <2,+ deepseq,+ list-t >=1.0.5 && <2,+ network >=3.1.2 && <3.2 || ^>=3.2.0,+ unordered-containers >=0.2.17 && <0.3,+ hashable >= 1.4.2.0 && < 2,+ clock >= 0.8.3 && < 1+ extra-libraries: z+ hs-source-dirs: src+ default-language: Haskell2010++test-suite keter-rate-limiting-plugin-test+ import: warnings+ default-language: Haskell2010+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ other-modules: + Keter.RateLimiter.Cache.PurgeTests+ Keter.RateLimiter.LeakyBucketTests+ Keter.RateLimiter.IPZonesTests+ Keter.RateLimiter.LeakyBucketStateTests+ Keter.RateLimiter.NotificationTests+ Keter.RateLimiter.SlidingWindowTests+ Keter.RateLimiter.TokenBucketStateTests+ Keter.RateLimiter.TokenBucketTests+ Keter.RateLimiter.WAITests+ TinyLRUTests+ build-depends:+ base >=4 && <5,+ async >=2.2.4 && <2.3,+ wai >=3.2.3 && <3.3,+ wai-extra >= 3.0.3 && < 3.2,+ network >=3.1.2 && <3.2 || ^>=3.2.0,+ http-types >=0.12.3 && <0.13,+ directory >= 1.3.4.0 && <1.4,+ HUnit,+ aeson >= 1.4,+ bytestring >= 0.10,+ case-insensitive >=1.2.1.0,+ keter-rate-limiting-plugin,+ tasty >= 1.4 && < 1.6,+ filepath >=1.4.2 && <1.6,+ tasty-hunit >= 0.10 && < 0.11,+ containers ^>= 0.6.4 || ^>= 0.7,+ text >= 1.2.5 && < 3.0,+ random >=1.2.1 && <1.4,+ stm >=2.5.0 && <2.6,+ stm-containers >=1.2 && <2,+ cache >= 0.1,+ containers >= 0.6,+ time >= 1.9,+ hashable >= 1.4.2.0 && < 2,+ clock >= 0.8.3 && < 1,+ temporary >= 1.3 && < 1.4+ -- extra-libraries:
+ src/Data/TinyLRU.hs view
@@ -0,0 +1,458 @@+{-# LANGUAGE BangPatterns, FlexibleContexts, OverloadedStrings, ScopedTypeVariables, TypeApplications, DataKinds, KindSignatures #-}++-- |+-- Module : Data.TinyLRU+-- Description : A lightweight, thread-safe, in-memory LRU cache.+-- Copyright : (c) 2025 Your Name+-- License : MIT+-- Maintainer : your.email@example.com+-- Stability : experimental+-- Portability : POSIX+--+-- This module provides a simple, thread-safe, and efficient Least Recently Used (LRU)+-- cache. It is built using Software Transactional Memory (STM), making it suitable+-- for concurrent applications.+--+-- The cache supports:+--+-- * A fixed capacity, with automatic eviction of the least recently used item.+-- * Time-based expiration (TTL) for each entry.+-- * Generic key-value storage, with values being 'ToJSON'/'FromJSON' serializable.+-- * Atomic operations for safe concurrent access.+--+-- A typical use case involves creating a cache, and then using 'access' or+-- 'updateValue' within an 'atomically' block to interact with it.+--+-- == Example Usage+--+-- @+-- import Control.Concurrent.STM+-- import Data.TinyLRU+-- import Data.Text (Text)+-- import System.Clock+--+-- main :: IO ()+-- main = do+-- -- Initialize a cache with a capacity of 100 items.+-- lru <- atomically $ initTinyLRU 100+--+-- -- Add or retrieve a value.+-- let key1 = "my-key" :: Text+-- let value1 = "my-value" :: Text+-- let ttlSeconds = 3600 -- 1 hour+--+-- -- 'access' is a get-or-insert operation.+-- -- On first run, it inserts 'value1' and returns it.+-- -- On subsequent runs, it returns the existing value.+-- now <- getTime Monotonic+-- retrievedValue <- atomically $ access now key1 value1 ttlSeconds lru+-- print retrievedValue -- Should print: Just "my-value"+--+-- -- Explicitly update a value.+-- let newValue = "a-new-value" :: Text+-- updatedValue <- atomically $ updateValue now key1 newValue ttlSeconds lru+-- print updatedValue -- Should print: Just "a-new-value"+--+-- -- Use the cache for rate limiting.+-- let rateLimitKey = "user:123:login-attempts"+-- -- Allow 5 requests per 60 seconds.+-- isAllowed <- atomically $ allowRequestTinyLRU now lru rateLimitKey 5 60+-- if isAllowed+-- then putStrLn "Request allowed."+-- else putStrLn "Rate limit exceeded."+-- @+--++module Data.TinyLRU+ ( -- * Cache Type+ TinyLRUCache(..)+ -- * Core API+ , initTinyLRU+ , access+ , updateValue+ , deleteKey+ , resetTinyLRU+ -- * Rate Limiting+ , allowRequestTinyLRU+ -- * Internals+ -- | These are lower-level components and functions. Most users will not need them directly.+ , LRUList(..)+ , LRUNode(..)+ , isExpired+ , addToFront+ , removeNode+ , moveToFront+ , evictLRU+ , removeNodeFromCache+ , moveToFrontInCache+ ) where++import Control.Concurrent.STM+import StmContainers.Map (Map)+import qualified StmContainers.Map as Map+import Data.Text (Text)+import qualified Data.Text as T+import Data.ByteString (ByteString)+import qualified Data.ByteString.Lazy as BL+import Data.Aeson (FromJSON, ToJSON, encode, decodeStrict)+import System.Clock (TimeSpec(..))+import Data.Maybe (isNothing)+import Control.Monad (when, forM_, foldM)+import qualified ListT++-- | Represents a single node in the LRU cache's doubly-linked list.+-- Each node contains the key, value, expiry time, and pointers to the+-- previous and next nodes.+data LRUNode s = LRUNode+ { nodeKey :: !Text+ -- ^ The key associated with this cache entry.+ , nodeValue :: !ByteString+ -- ^ The value, stored as a 'ByteString' after JSON encoding.+ , nodeExpiry :: !(Maybe TimeSpec)+ -- ^ The absolute expiration time. 'Nothing' means the entry never expires.+ , nodePrev :: !(Maybe (TVar (LRUNode s)))+ -- ^ A transactional variable pointing to the previous node in the list. 'Nothing' if this is the head.+ , nodeNext :: !(Maybe (TVar (LRUNode s)))+ -- ^ A transactional variable pointing to the next node in the list. 'Nothing' if this is the tail.+ }++-- | Represents the doubly-linked list used to track the LRU order.+-- It only stores pointers to the head (most recently used) and+-- tail (least recently used) of the list.+data LRUList s = LRUList+ { lruHead :: !(Maybe (TVar (LRUNode s)))+ -- ^ A pointer to the most recently used node.+ , lruTail :: !(Maybe (TVar (LRUNode s)))+ -- ^ A pointer to the least recently used node.+ }++-- | The main data structure for the LRU cache.+-- This is the handle you will use for all cache operations.+data TinyLRUCache s = TinyLRUCache+ { lruCache :: !(Map Text (TVar (LRUNode s)))+ -- ^ A transactional hash map for O(1) average time complexity lookups.+ -- Maps keys to their corresponding 'LRUNode's in the list.+ , lruList :: !(TVar (LRUList s))+ -- ^ A transactional variable holding the 'LRUList', which manages the usage order.+ , lruCap :: !Int+ -- ^ The maximum number of items the cache can hold.+ }++-- | Initializes a new 'TinyLRUCache' with a specified capacity.+-- This function must be run within an 'STM' transaction.+--+-- @+-- lruCache <- atomically $ initTinyLRU 1000+-- @+--+-- @param cap The maximum number of items the cache can hold. Must be > 0.+-- @return An 'STM' action that yields a new, empty 'TinyLRUCache'.+initTinyLRU :: Int -> STM (TinyLRUCache s)+initTinyLRU cap = do+ cache <- Map.new+ list <- newTVar $ LRUList Nothing Nothing+ return $ TinyLRUCache cache list cap++-- | Helper function to calculate the expiry 'TimeSpec' from a TTL in seconds.+mkExpiry :: TimeSpec -> Int -> Maybe TimeSpec+mkExpiry now ttl | ttl <= 0 = Nothing+ | otherwise = Just $ addTTL now ttl++-- | Checks if a cache node is expired relative to the current time.+--+-- @param now The current 'TimeSpec'.+-- @param node The 'LRUNode' to check.+-- @return 'True' if the node is expired, 'False' otherwise.+isExpired :: TimeSpec -> LRUNode s -> Bool+isExpired now node =+ case nodeExpiry node of+ Nothing -> False+ Just expTime -> now >= expTime++-- | Atomically removes a node from the doubly-linked list.+-- It correctly updates the 'nodePrev' and 'nodeNext' pointers of the+-- neighboring nodes and the list's 'lruHead' and 'lruTail' pointers if necessary.+--+-- @param listTVar The 'TVar' of the 'LRUList' from which to remove the node.+-- @param nodeTVar The 'TVar' of the 'LRUNode' to remove.+removeNode :: TVar (LRUList s) -> TVar (LRUNode s) -> STM ()+removeNode listTVar nodeTVar = do+ node <- readTVar nodeTVar+ let mPrev = nodePrev node+ mNext = nodeNext node++ -- Link neighbour nodes to each other+ forM_ mPrev $ \pRef -> modifyTVar' pRef (\p -> p { nodeNext = mNext })+ forM_ mNext $ \nRef -> modifyTVar' nRef (\n -> n { nodePrev = mPrev })++ -- Atomically update the list's head and tail pointers+ modifyTVar' listTVar $ \list ->+ let newHead = if lruHead list == Just nodeTVar then mNext else lruHead list+ newTail = if lruTail list == Just nodeTVar then mPrev else lruTail list+ in list { lruHead = newHead, lruTail = newTail }++-- | Creates a new node and adds it to the front (most recently used position)+-- of the cache's linked list.+--+-- @param now The current 'TimeSpec', used for calculating expiry.+-- @param ttl The time-to-live in seconds. A value `<= 0` means it never expires.+-- @param cache The 'TinyLRUCache' instance.+-- @param key The key for the new entry.+-- @param value The 'ByteString' value for the new entry.+-- @return The 'TVar' of the newly created 'LRUNode'.+addToFront :: TimeSpec -> Int -> TinyLRUCache s -> Text -> ByteString -> STM (TVar (LRUNode s))+addToFront now ttl cache key value = do+ let expiry = mkExpiry now ttl+ list <- readTVar (lruList cache)+ nodeTVar <- newTVar $ LRUNode key value expiry Nothing (lruHead list)+ + forM_ (lruHead list) $ \oldHeadTVar -> do+ oldHead <- readTVar oldHeadTVar+ writeTVar oldHeadTVar oldHead { nodePrev = Just nodeTVar }++ let newTail = if isNothing (lruTail list) then Just nodeTVar else lruTail list+ writeTVar (lruList cache) (LRUList (Just nodeTVar) newTail)+ + return nodeTVar++-- | Moves an existing node to the front of the linked list, marking it as the+-- most recently used. This is a core operation for the LRU logic.+--+-- @param listTVar The 'TVar' of the 'LRUList'.+-- @param nodeTVar The 'TVar' of the 'LRUNode' to move.+moveToFront :: TVar (LRUList s) -> TVar (LRUNode s) -> STM ()+moveToFront listTVar nodeTVar = do+ list <- readTVar listTVar+ -- Only move if it's not already the head+ when (lruHead list /= Just nodeTVar) $ do+ removeNode listTVar nodeTVar+ node <- readTVar nodeTVar+ list' <- readTVar listTVar+ let mOldHead = lruHead list'+ writeTVar nodeTVar node { nodePrev = Nothing, nodeNext = mOldHead }+ + forM_ mOldHead $ \oldHeadTVar ->+ modifyTVar' oldHeadTVar (\h -> h { nodePrev = Just nodeTVar })+ + let newTail = if isNothing (lruTail list') then Just nodeTVar else lruTail list'+ writeTVar listTVar (LRUList (Just nodeTVar) newTail)++-- | Evicts the least recently used item from the cache. This involves removing+-- the tail of the linked list and deleting the corresponding entry from the hash map.+--+-- @param cache The 'TinyLRUCache' to perform eviction on.+evictLRU :: TinyLRUCache s -> STM ()+evictLRU cache = do+ list <- readTVar (lruList cache)+ forM_ (lruTail list) $ \tailTVar -> do+ node <- readTVar tailTVar+ Map.delete (nodeKey node) (lruCache cache)+ removeNode (lruList cache) tailTVar++-- | Helper to add a TTL in seconds to a 'TimeSpec'.+addTTL :: TimeSpec -> Int -> TimeSpec+addTTL (TimeSpec s ns) ttl = TimeSpec (s + fromIntegral (max 0 ttl)) ns++-- | Deletes an entry from the cache by its key.+-- This removes the item from both the internal map and the linked list.+--+-- @param key The key of the item to delete.+-- @param cache The 'TinyLRUCache' instance.+deleteKey :: Text -> TinyLRUCache s -> STM ()+deleteKey key cache = do+ mNodeTVar <- Map.lookup key (lruCache cache)+ forM_ mNodeTVar $ \nodeTVar -> do+ Map.delete key (lruCache cache)+ removeNode (lruList cache) nodeTVar++-- | Scans the cache and removes all expired items.+-- This is called automatically by 'access', 'updateValue', and 'allowRequestTinyLRU'.+cleanupExpired :: TimeSpec -> TinyLRUCache s -> STM ()+cleanupExpired now cache = do+ pairs <- ListT.toList $ Map.listT (lruCache cache)+ expired <- foldM (\acc (k, nodeRef) -> do+ node <- readTVar nodeRef+ if isExpired now node then return (k:acc) else return acc+ ) [] pairs+ forM_ expired $ \k -> deleteKey k cache++-- | Accesses a cache entry. This is the primary "get-or-insert" function.+--+-- The logic is as follows:+--+-- 1. It first cleans up any expired items in the cache.+-- 2. It looks for the key.+-- 3. If the key exists and is not expired, it moves the item to the front (as it's now+-- the most recently used) and returns its value wrapped in 'Just'.+-- 4. If the key does not exist, or if it exists but has expired, it inserts the+-- provided new value. If the cache is full, it evicts the least recently used+-- item before insertion. It then returns the new value wrapped in 'Just'.+-- 5. If the key is invalid (empty or too long), it returns 'Nothing'.+--+-- @param now The current 'TimeSpec', for expiry checks.+-- @param key The key to look up. Length must be between 1 and 256.+-- @param val The value to insert if the key is not found. It must have 'ToJSON' and 'FromJSON' instances.+-- @param ttl The time-to-live in seconds for the new entry if it's inserted.+-- @param cache The 'TinyLRUCache' instance.+-- @return An 'STM' action that yields 'Just' the value (either existing or newly inserted), or 'Nothing' if the key is invalid.+access :: forall a s. (FromJSON a, ToJSON a) => TimeSpec -> Text -> a -> Int -> TinyLRUCache s -> STM (Maybe a)+access now key val ttl cache+ | T.null key || T.length key > 256 = return Nothing+ | otherwise = do+ cleanupExpired now cache+ mNodeTVar <- Map.lookup key (lruCache cache)+ case mNodeTVar of+ Just nodeTVar -> do+ -- Key exists - check if expired+ node <- readTVar nodeTVar+ if isExpired now node then do+ -- Expired: delete and re-insert with new value+ deleteKey key cache+ insertNew+ else do+ -- Not expired: move to front and return existing value (cache hit)+ moveToFront (lruList cache) nodeTVar+ case decodeStrict (nodeValue node) :: Maybe a of+ Just existingVal -> return (Just existingVal)+ Nothing -> do+ -- Corrupt data, replace it+ deleteKey key cache+ insertNew+ Nothing -> insertNew+ where+ insertNew = do+ sz <- Map.size (lruCache cache)+ when (sz >= lruCap cache) $ evictLRU cache+ nodeTVar <- addToFront now ttl cache key (BL.toStrict (encode val))+ Map.insert nodeTVar key (lruCache cache)+ return (Just val)++-- | Updates or inserts a cache entry. This is the primary "write" or "upsert" function.+--+-- The logic is as follows:+--+-- 1. It first cleans up any expired items in the cache.+-- 2. It looks for the key.+-- 3. If the key exists, it updates the value and expiry time, and moves it to the front.+-- 4. If the key does not exist, it inserts the new value. If the cache is full, it+-- evicts the least recently used item first.+-- 5. If the key is invalid (empty or too long), it returns 'Nothing'.+--+-- Unlike 'access', this function /always/ writes the provided value.+--+-- @param now The current 'TimeSpec', for expiry calculations.+-- @param key The key to update or insert. Length must be between 1 and 256.+-- @param val The new value to write. It must have 'ToJSON' and 'FromJSON' instances.+-- @param ttl The new time-to-live in seconds for the entry.+-- @param cache The 'TinyLRUCache' instance.+-- @return An 'STM' action that yields 'Just' the value that was written, or 'Nothing' if the key is invalid.+updateValue :: forall a s. (FromJSON a, ToJSON a) => TimeSpec -> Text -> a -> Int -> TinyLRUCache s -> STM (Maybe a)+updateValue now key val ttl cache+ | T.null key || T.length key > 256 = return Nothing+ | otherwise = do+ cleanupExpired now cache+ mNodeTVar <- Map.lookup key (lruCache cache)+ case mNodeTVar of+ Just nodeTVar -> do+ -- Key exists - update the value regardless of expiration+ node <- readTVar nodeTVar+ let newExpiry = mkExpiry now ttl+ writeTVar nodeTVar node { + nodeValue = BL.toStrict (encode val),+ nodeExpiry = newExpiry+ }+ moveToFront (lruList cache) nodeTVar+ return (Just val)+ Nothing -> do+ -- Key doesn't exist - insert new+ sz <- Map.size (lruCache cache)+ when (sz >= lruCap cache) $ evictLRU cache+ nodeTVar <- addToFront now ttl cache key (BL.toStrict (encode val))+ Map.insert nodeTVar key (lruCache cache)+ return (Just val)++-- | Resets the cache, removing all entries.+--+-- @param cache The 'TinyLRUCache' to reset.+resetTinyLRU :: TinyLRUCache s -> STM ()+resetTinyLRU cache = do+ Map.reset (lruCache cache)+ writeTVar (lruList cache) $ LRUList Nothing Nothing++-- | A specialized function for rate limiting. It uses the cache to track the number+-- of requests for a given key within a specific time period.+--+-- The logic is as follows:+--+-- 1. It looks for the key. The value associated with the key is treated as a counter ('Int').+-- 2. If the entry for the key exists and is not expired:+-- * If the counter is less than the 'limit', it increments the counter,+-- refreshes the expiry time, and returns 'True' (request allowed).+-- * If the counter has reached the 'limit', it does nothing and returns 'False' (request denied).+-- 3. If the entry does not exist or has expired, it creates a new entry with the+-- counter set to 1 and returns 'True'.+--+-- @param now The current 'TimeSpec'.+-- @param cache The 'TinyLRUCache' instance.+-- @param key The key to identify the entity being rate-limited (e.g., a user ID or IP address).+-- @param limit The maximum number of requests allowed.+-- @param period The time period in seconds for the rate limit window.+-- @return An 'STM' action yielding 'True' if the request is allowed, 'False' otherwise.+allowRequestTinyLRU :: TimeSpec -> TinyLRUCache s -> Text -> Int -> Int -> STM Bool+allowRequestTinyLRU now cache key limit period+ | T.null key = return False+ | otherwise = do+ cleanupExpired now cache+ mNodeTVar <- Map.lookup key (lruCache cache)+ case mNodeTVar of+ Just nodeTVar -> do+ node <- readTVar nodeTVar+ if isExpired now node then do+ -- Expired: delete old entry and create new one+ deleteKey key cache+ insertNew+ else do+ -- Not expired: check and update count+ case decodeStrict (nodeValue node) :: Maybe Int of+ Just n+ | n < limit -> do+ -- Update the value and refresh expiry time+ let newExpiry = mkExpiry now period+ writeTVar nodeTVar node { + nodeValue = BL.toStrict (encode (n+1)),+ nodeExpiry = newExpiry+ }+ moveToFront (lruList cache) nodeTVar+ return True+ | otherwise -> do+ moveToFront (lruList cache) nodeTVar+ return False -- Over limit+ _ -> do+ -- Corrupt data, reset it+ deleteKey key cache+ insertNew+ Nothing -> insertNew+ where+ insertNew = do+ sz <- Map.size (lruCache cache)+ when (sz >= lruCap cache) $ evictLRU cache+ nodeTVar <- addToFront now period cache key (BL.toStrict (encode (1 :: Int)))+ Map.insert nodeTVar key (lruCache cache)+ return True++-- | Low-level function to remove a node from the cache's list.+-- Alias for 'removeNode'. Most users should use 'deleteKey' instead.+--+-- @param cache The 'TinyLRUCache' instance.+-- @param nodeTVar The 'TVar' of the 'LRUNode' to remove.+removeNodeFromCache :: TinyLRUCache s -> TVar (LRUNode s) -> STM ()+removeNodeFromCache cache = removeNode (lruList cache)++-- | Low-level function to move a node to the front of the cache's list.+-- Alias for 'moveToFront'. Most users should use 'access' or 'updateValue' which+-- call this internally.+--+-- @param cache The 'TinyLRUCache' instance.+-- @param nodeTVar The 'TVar' of the 'LRUNode' to move.+moveToFrontInCache :: TinyLRUCache s -> TVar (LRUNode s) -> STM ()+moveToFrontInCache cache = moveToFront (lruList cache)
+ src/Keter/RateLimiter/AutoPurge.hs view
@@ -0,0 +1,573 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE NumericUnderscores #-}+{-# LANGUAGE ScopedTypeVariables #-}++{-|+Module : Keter.RateLimiter.AutoPurge+Description : Automatic cleanup and garbage collection for rate limiter caches+Copyright : (c) Keter Project+License : MIT+Maintainer : maintainer@example.com+Stability : experimental+Portability : POSIX++This module provides automatic purging and cleanup functionality for rate limiter+caches and STM-based data structures. It implements background threads that+periodically remove expired entries to prevent memory leaks and maintain+optimal performance.++== Purging Strategies++The module supports two main purging approaches:++1. __Generic Cache Purging__: Works with any 'Data.Cache.Cache' instance+2. __Custom STM Map Purging__: Specialized for rate limiter bucket entries++== Architecture Overview++@+┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐+│ Cache/STM │ │ Purge Thread │ │ Timer Logic │+│ Map │◄───┤ │◄───┤ │+└─────────────────┘ │ • Check TTL │ │ • Interval calc │+ │ • Remove expired│ │ • Sleep mgmt │+ │ • Cleanup locks │ │ • Precise timing│+ └──────────────────┘ └─────────────────┘+@++== Performance Characteristics++* __Time Complexity__: O(n) where n is the number of entries (full scan)+* __Space Complexity__: O(1) additional memory usage+* __Timing Precision__: Microsecond-level accuracy using monotonic clock+* __Thread Safety__: All operations are atomic using STM++== Example Usage++=== Basic Cache Purging++@+import qualified Data.Cache as C+import Data.Text++-- Create a cache with 1-hour TTL+cache <- C.newCache (Just $ C.seconds 3600)++-- Start auto-purge every 10 minutes (600 seconds)+startAutoPurge cache 600++-- Cache will now automatically remove expired entries+@++=== Token Bucket Purging++@+import qualified StmContainers.Map as StmMap++-- Create STM map for token buckets+tokenBuckets <- StmMap.newIO++-- Start purging inactive buckets every 5 minutes, TTL 1 hour+threadId <- startCustomPurgeTokenBucket tokenBuckets 300 3600++-- Purge thread runs in background, cleaning up unused buckets+@++=== Leaky Bucket Purging++@+-- Create STM map for leaky buckets +leakyBuckets <- StmMap.newIO++-- Start purging inactive buckets every 2 minutes, TTL 30 minutes+threadId <- startCustomPurgeLeakyBucket leakyBuckets 120 1800++-- Thread automatically removes buckets not used for 30+ minutes+@++== Thread Management++All purge functions return 'ThreadId' values that can be used for thread+management (killing, monitoring, etc.). The threads run indefinitely until+explicitly terminated.++== Memory Management++The purge system is designed to prevent memory leaks in long-running+applications by:++* Removing expired cache entries+* Cleaning up unused worker threads and locks+* Releasing STM resources for inactive buckets+* Maintaining bounded memory usage regardless of request patterns+-}+module Keter.RateLimiter.AutoPurge+ ( -- * Data Types+ -- ** Token Bucket Entries+ TokenBucketEntry(..)+ -- ** Leaky Bucket Entries+ , LeakyBucketEntry(..)+ -- * Purging Functions+ -- ** Generic Cache Purging+ , startAutoPurge+ -- ** Custom STM Map Purging+ , startCustomPurge+ , startCustomPurgeTokenBucket+ , startCustomPurgeLeakyBucket+ ) where++import Control.Concurrent (forkIO, threadDelay, ThreadId)+import Control.Concurrent.MVar (MVar, newMVar, putMVar, takeMVar)+import Control.Monad (forever, filterM, void)+import Control.Concurrent.STM+import qualified Control.Concurrent.STM.TQueue as TQueue+import qualified StmContainers.Map as StmMap+import qualified ListT+import Data.Text (Text)+import Data.Time.Clock.POSIX (getPOSIXTime)+import qualified Data.Cache as C+import Keter.RateLimiter.Types (TokenBucketState(..), LeakyBucketState(..))+import System.Clock (TimeSpec(..), Clock(Monotonic), getTime, toNanoSecs)++-- | Container for token bucket state and associated worker resources.+--+-- This type encapsulates all the resources needed for a single token bucket+-- rate limiter, including the bucket state, request queue, and worker thread+-- synchronization. It's designed to work with STM-based concurrent access+-- patterns.+--+-- ==== Resource Management+--+-- The entry includes a worker lock ('tbeWorkerLock') that coordinates worker+-- thread lifecycle:+--+-- * __Empty TMVar__: No worker thread is currently running for this bucket+-- * __Full TMVar__: A worker thread is active and processing requests+--+-- ==== Cleanup Behavior+--+-- When purging expired entries, the system:+--+-- 1. Attempts to take the worker lock (non-blocking)+-- 2. If successful, terminates any associated worker thread+-- 3. Removes the entry from the map+-- 4. Releases all associated STM resources+--+-- ==== Example+--+-- @+-- -- Create a token bucket entry+-- state <- newTVarIO $ TokenBucketState 100 now+-- queue <- TQueue.newTBroadcastTQueueIO+-- lock <- newEmptyTMVarIO+--+-- let entry = TokenBucketEntry+-- { tbeState = state+-- , tbeQueue = queue +-- , tbeWorkerLock = lock+-- }+-- @+data TokenBucketEntry = TokenBucketEntry+ { tbeState :: TVar TokenBucketState -- ^ Atomic bucket state containing+ -- current token count and last update timestamp.+ -- Shared between worker thread and clients.+ , tbeQueue :: TQueue.TQueue (MVar Bool) -- ^ Request queue for client communication.+ -- Clients place response 'MVar's here and wait+ -- for worker to process and respond.+ , tbeWorkerLock :: TMVar () -- ^ Worker thread synchronization lock.+ -- Empty when no worker exists, full when active.+ -- Used for coordinating worker lifecycle and cleanup.+ }++-- | Container for leaky bucket state and associated worker resources.+--+-- Similar to 'TokenBucketEntry' but designed for leaky bucket algorithm+-- requirements. The leaky bucket uses continuous time calculations and+-- different queue communication patterns.+--+-- ==== Differences from Token Bucket+--+-- * Uses 'TMVar Bool' instead of 'MVar Bool' for queue communication+-- * State contains 'Double' timestamps for sub-second precision+-- * Worker threads implement continuous draining logic+--+-- ==== Resource Lifecycle+--+-- The entry lifecycle mirrors token buckets:+--+-- 1. __Creation__: Entry is created and added to STM map+-- 2. __Activation__: First request triggers worker thread creation+-- 3. __Processing__: Worker handles requests and updates state+-- 4. __Expiration__: Entry is purged after TTL period of inactivity+-- 5. __Cleanup__: Worker is terminated and resources are released+--+-- ==== Example+--+-- @+-- -- Create a leaky bucket entry+-- state <- newTVarIO $ LeakyBucketState 0.0 now+-- queue <- TQueue.newTBroadcastTQueueIO+-- lock <- newEmptyTMVarIO+--+-- let entry = LeakyBucketEntry+-- { lbeState = state+-- , lbeQueue = queue+-- , lbeWorkerLock = lock +-- }+-- @+data LeakyBucketEntry = LeakyBucketEntry+ { lbeState :: TVar LeakyBucketState -- ^ Atomic bucket state with current level+ -- and last update timestamp (Double precision).+ -- Shared between worker and clients.+ , lbeQueue :: TQueue.TQueue (TMVar Bool) -- ^ Request queue using 'TMVar' for STM-based+ -- client communication. Enables atomic+ -- request queuing and response handling.+ , lbeWorkerLock :: TMVar () -- ^ Worker thread coordination lock, same+ -- semantics as token bucket but for leaky+ -- bucket worker lifecycle management.+ }++-- | Start a background thread that periodically purges expired entries from a generic cache.+--+-- This function creates a self-regulating purge thread that maintains precise timing+-- intervals regardless of purge operation duration. It uses monotonic clock measurements+-- to ensure accurate scheduling even under system load.+--+-- ==== Timing Algorithm+--+-- The purge cycle works as follows:+--+-- 1. __Start Timer__: Record monotonic timestamp before purge+-- 2. __Purge Operation__: Call 'C.purgeExpired' on the cache+-- 3. __Calculate Remaining__: Subtract elapsed time from target interval+-- 4. __Precise Sleep__: Wait for exactly the remaining time+-- 5. __Repeat__: Continue with next cycle+--+-- ==== Timing Precision+--+-- The algorithm ensures that purge cycles happen at regular intervals:+--+-- @+-- Target Interval: |----10s----|----10s----|----10s----|+-- Actual Timing: |--9s purge-|1s wait|--8s purge--|2s wait|+-- Result: Consistent 10-second intervals maintained+-- @+--+-- ==== Performance Considerations+--+-- * __Non-blocking__: Returns immediately after starting background thread+-- * __Self-correcting__: Adapts sleep time based on actual purge duration+-- * __Memory efficient__: Only maintains minimal state for timing+-- * __CPU friendly__: Sleeps for majority of time between purges+--+-- ==== Example Usage+--+-- @+-- import qualified Data.Cache as C+-- import Data.Text+--+-- -- Create cache with 30-minute TTL+-- cache <- C.newCache (Just $ C.minutes 30)+--+-- -- Purge expired entries every 5 minutes (300 seconds)+-- startAutoPurge cache 300+--+-- -- Cache now automatically maintains itself+-- -- Memory usage remains bounded regardless of request patterns+-- @+--+-- __Thread Safety:__ Safe to call concurrently. Each call creates independent purge thread.+--+-- __Resource Usage:__ Creates one background thread with minimal memory footprint.+--+-- __Error Handling:__ Thread continues running even if individual purge operations fail.+startAutoPurge + :: C.Cache Text v -- ^ The cache instance to purge. Can contain any value type 'v'.+ -- Must support 'C.purgeExpired' operation for TTL-based cleanup.+ -> Integer -- ^ Purge interval in seconds. Determines how frequently expired+ -- entries are removed. Shorter intervals provide more responsive+ -- cleanup but use more CPU. Typical values: 60-3600 seconds.+ -> IO () -- ^ Returns immediately. The purge thread runs in background indefinitely.+startAutoPurge cache intervalSeconds = do+ purgeSignal <- newMVar ()+ void $ forkIO $ forever $ do+ takeMVar purgeSignal+ startTime <- getTime Monotonic+ C.purgeExpired cache+ endTime <- getTime Monotonic+ let elapsedMicros = (toNanoSecs endTime - toNanoSecs startTime) `div` 1000+ remainingMicros = max (0 :: Integer) (intervalSeconds * 1000000 - elapsedMicros)+ waitUntilNextPurge startTime remainingMicros purgeSignal+ where+ waitUntilNextPurge :: TimeSpec -> Integer -> MVar () -> IO ()+ waitUntilNextPurge startTime remainingMicros purgeSignal = do+ currentTime <- getTime Monotonic+ let elapsedMicros = (toNanoSecs currentTime - toNanoSecs startTime) `div` 1000+ if elapsedMicros >= remainingMicros+ then putMVar purgeSignal ()+ else do+ let sleepMicros = fromIntegral (min remainingMicros (toInteger (maxBound :: Int))) :: Int+ threadDelay sleepMicros+ putMVar purgeSignal ()++-- | Start a specialized purge thread for token bucket entries in an STM map.+--+-- This function provides a convenient wrapper around 'startCustomPurge' specifically+-- configured for 'TokenBucketEntry' cleanup. It handles the complexities of extracting+-- timestamps from token bucket state and properly cleaning up worker threads.+--+-- ==== Token Bucket Specific Behavior+--+-- * __Timestamp Extraction__: Uses 'lastUpdate' field from 'TokenBucketState'+-- * __Worker Cleanup__: Attempts to acquire worker lock and terminate threads+-- * __Resource Release__: Removes entry from STM map and frees all resources+--+-- ==== TTL Behavior+--+-- Token buckets are considered expired when:+--+-- @+-- currentTime - lastUpdateTime >= ttlSeconds+-- @+--+-- This means buckets are purged based on when they were last used for rate limiting,+-- not when they were created. Active buckets are never purged regardless of age.+--+-- ==== Example Scenarios+--+-- @+-- -- High-frequency API with 5-minute cleanup cycles+-- tokenBuckets <- StmMap.newIO+-- threadId <- startCustomPurgeTokenBucket tokenBuckets 300 3600 -- 5min interval, 1hr TTL+--+-- -- Low-frequency batch processing with daily cleanup +-- batchBuckets <- StmMap.newIO+-- threadId <- startCustomPurgeTokenBucket batchBuckets 86400 604800 -- 1day interval, 1week TTL+-- @+--+-- __Thread Management:__ Returns 'ThreadId' for thread control (e.g., 'killThread').+--+-- __Memory Impact:__ Prevents unbounded memory growth in applications with dynamic rate limiting keys.+--+-- __Performance:__ O(n) scan of all buckets, but typically runs infrequently during low-traffic periods.+startCustomPurgeTokenBucket+ :: StmMap.Map Text TokenBucketEntry -- ^ STM map containing token bucket entries keyed by identifier.+ -- Typically uses API keys, user IDs, or IP addresses as keys.+ -- Map is scanned atomically during each purge cycle.+ -> Integer -- ^ Purge interval in seconds. How often to scan for expired buckets.+ -- Balance between cleanup responsiveness and CPU usage.+ -- Recommended: 300-3600 seconds depending on traffic patterns.+ -> Integer -- ^ TTL (time-to-live) in seconds. Buckets unused for this duration+ -- are considered expired and eligible for removal.+ -- Should be much larger than typical request intervals.+ -- Recommended: 3600-86400 seconds.+ -> IO ThreadId -- ^ Returns thread ID of the background purge thread.+ -- Can be used with 'killThread' to stop purging.+startCustomPurgeTokenBucket stmMap intervalSeconds ttlSeconds = startCustomPurge+ (\entry -> do+ TokenBucketState _ lastT <- readTVar (tbeState entry)+ pure (fromIntegral lastT))+ (\key entry -> do+ void $ tryTakeTMVar (tbeWorkerLock entry)+ StmMap.delete key stmMap)+ stmMap+ intervalSeconds+ ttlSeconds++-- | Start a specialized purge thread for leaky bucket entries in an STM map.+--+-- Similar to 'startCustomPurgeTokenBucket' but configured for 'LeakyBucketEntry' cleanup.+-- Handles the specific requirements of leaky bucket timestamp extraction and worker+-- thread management.+--+-- ==== Leaky Bucket Specific Behavior+--+-- * __Timestamp Precision__: Uses 'Double' timestamp from 'LeakyBucketState' for sub-second accuracy+-- * __Continuous Time Model__: Supports fractional timestamps for precise drain calculations+-- * __Worker Cleanup__: Terminates continuous drain worker threads properly+--+-- ==== TTL Calculation+--+-- Leaky buckets are expired when:+--+-- @+-- currentTime - lastTime >= fromIntegral ttlSeconds+-- @+--+-- The 'lastTime' field represents the most recent bucket level update, providing+-- more precise expiration timing than integer-based systems.+--+-- ==== Use Cases+--+-- * __Streaming Applications__: Rate limiting for continuous data flows+-- * __Real-time APIs__: Sub-second precision rate limiting+-- * __Traffic Shaping__: Network bandwidth management with smooth rates+--+-- ==== Example Configuration+--+-- @+-- -- Real-time streaming with frequent cleanup+-- streamBuckets <- StmMap.newIO +-- threadId <- startCustomPurgeLeakyBucket streamBuckets 60 900 -- 1min interval, 15min TTL+--+-- -- Network traffic shaping with moderate cleanup+-- trafficBuckets <- StmMap.newIO+-- threadId <- startCustomPurgeLeakyBucket trafficBuckets 600 7200 -- 10min interval, 2hr TTL+-- @+--+-- __Precision:__ Sub-second timestamp accuracy for fine-grained rate control.+--+-- __Cleanup Behavior:__ More aggressive cleanup suitable for high-frequency, short-lived connections.+startCustomPurgeLeakyBucket+ :: StmMap.Map Text LeakyBucketEntry -- ^ STM map containing leaky bucket entries keyed by identifier.+ -- Keys typically represent connections, streams, or data flows.+ -- All entries are scanned atomically during purge operations.+ -> Integer -- ^ Purge interval in seconds. Frequency of expired entry cleanup.+ -- For high-frequency applications, shorter intervals (60-600s)+ -- provide more responsive cleanup.+ -> Integer -- ^ TTL in seconds. Buckets inactive for this duration are purged.+ -- Should account for typical connection/stream lifetime patterns.+ -- Shorter TTL (900-3600s) suitable for transient connections.+ -> IO ThreadId -- ^ Thread ID for background purge thread management.+ -- Thread runs continuously until explicitly terminated.+startCustomPurgeLeakyBucket stmMap intervalSeconds ttlSeconds = startCustomPurge+ (\entry -> do+ LeakyBucketState _ lastT <- readTVar (lbeState entry)+ pure lastT)+ (\key entry -> do+ void $ tryTakeTMVar (lbeWorkerLock entry)+ StmMap.delete key stmMap)+ stmMap+ intervalSeconds+ ttlSeconds++-- | Generic purge loop implementation for STM-based data structures.+--+-- This is the foundational purge function that powers both token bucket and leaky bucket+-- purging. It provides a flexible framework for implementing custom purge logic while+-- maintaining consistent timing and cleanup behavior.+--+-- ==== Algorithm Overview+--+-- The purge cycle consists of several phases:+--+-- 1. __Scan Phase__: Atomically list all entries in the STM map+-- 2. __Filter Phase__: Identify expired entries based on timestamp extraction+-- 3. __Cleanup Phase__: Execute custom delete actions for expired entries +-- 4. __Timing Phase__: Calculate sleep duration to maintain precise intervals+-- 5. __Sleep Phase__: Wait until next purge cycle should begin+--+-- ==== Atomicity Guarantees+--+-- * __Entry Listing__: All entries are captured in a single STM transaction+-- * __Expiration Check__: Timestamp extraction is atomic per entry+-- * __Deletion__: Custom delete actions are executed atomically+-- * __Consistency__: Map state remains consistent throughout the process+--+-- ==== Custom Delete Actions+--+-- The 'deleteAction' parameter allows specialized cleanup logic:+--+-- @+-- -- Simple deletion+-- deleteAction key entry = StmMap.delete key stmMap+--+-- -- Cleanup with resource release+-- deleteAction key entry = do+-- releaseResources entry -- Custom cleanup+-- StmMap.delete key stmMap+--+-- -- Conditional deletion +-- deleteAction key entry = do+-- shouldDelete <- checkCondition entry+-- when shouldDelete $ StmMap.delete key stmMap+-- @+--+-- ==== Error Handling+--+-- The function is designed to be resilient:+--+-- * Individual entry failures don't stop the purge cycle+-- * Timing calculations handle clock adjustments gracefully+-- * Thread continues running even if STM transactions retry+--+-- ==== Performance Optimization+--+-- * __Batch Processing__: All deletions happen in a single STM transaction+-- * __Minimal Copying__: Entries are processed in-place where possible+-- * __Efficient Filtering__: Uses lazy evaluation for timestamp checks+-- * __Precise Timing__: Avoids unnecessary CPU usage during sleep periods+--+-- ==== Example Usage+--+-- @+-- -- Custom purge for application-specific entries+-- startCustomPurge+-- -- Extract timestamp from custom entry type+-- (\\entry -> readTVar (customTimestamp entry))+-- -- Custom cleanup with logging+-- (\\key entry -> do+-- liftIO $ logInfo ("Purging entry: " ++ show key)+-- releaseCustomResources entry+-- StmMap.delete key stmMap)+-- customMap+-- 600 -- 10-minute intervals+-- 3600 -- 1-hour TTL+-- @+--+-- __Flexibility:__ Supports any entry type with extractable timestamps.+--+-- __Extensibility:__ Custom delete actions enable complex cleanup scenarios.+--+-- __Reliability:__ Robust error handling ensures continuous operation.+startCustomPurge+ :: forall entry.+ (entry -> STM Double) -- ^ Timestamp extraction function. Called for each entry+ -- to determine last activity time. Should be fast and+ -- side-effect free. Returns Unix timestamp as 'Double'+ -- for sub-second precision.+ -> (Text -> entry -> STM ()) -- ^ Custom delete action executed for expired entries.+ -- Receives both key and entry for context. Should+ -- handle resource cleanup and map removal atomically.+ -- Can perform logging, resource release, etc.+ -> StmMap.Map Text entry -- ^ STM map to purge. Entries are identified by 'Text' keys+ -- and can be any type 'entry'. Map is scanned completely+ -- during each purge cycle for expired entries.+ -> Integer -- ^ Purge interval in seconds. Controls how frequently+ -- the purge operation runs. Shorter intervals provide+ -- more responsive cleanup but increase CPU usage.+ -> Integer -- ^ TTL (time-to-live) in seconds. Entries with timestamps+ -- older than (currentTime - ttlSeconds) are considered+ -- expired and eligible for removal.+ -> IO ThreadId -- ^ Returns thread ID of the purge thread. Thread runs+ -- indefinitely until killed. Can be used for thread+ -- management and monitoring.+startCustomPurge getTimestamp deleteAction stmMap intervalSeconds ttlSeconds = do+ purgeSignal <- newMVar ()+ forkIO $ forever $ do+ takeMVar purgeSignal+ startTime <- getTime Monotonic+ now <- realToFrac <$> getPOSIXTime+ expiredKVs <- atomically $ do+ kvs <- ListT.toList (StmMap.listT stmMap)+ filterM+ (\(_, entry) -> do+ ts <- getTimestamp entry+ pure (now - ts >= fromIntegral ttlSeconds))+ kvs+ atomically $ mapM_ (\(k, v) -> deleteAction k v) expiredKVs+ endTime <- getTime Monotonic+ let elapsedMicros = (toNanoSecs endTime - toNanoSecs startTime) `div` 1000+ remainingMicros = max (0 :: Integer) (intervalSeconds * 1000000 - elapsedMicros)+ waitUntilNextPurge startTime remainingMicros purgeSignal+ where+ waitUntilNextPurge :: TimeSpec -> Integer -> MVar () -> IO ()+ waitUntilNextPurge startTime remainingMicros purgeSignal = do+ currentTime <- getTime Monotonic+ let elapsedMicros = (toNanoSecs currentTime - toNanoSecs startTime) `div` 1000+ if elapsedMicros >= remainingMicros+ then putMVar purgeSignal ()+ else do+ let sleepMicros = fromIntegral (min remainingMicros (toInteger (maxBound :: Int))) :: Int+ threadDelay sleepMicros+ putMVar purgeSignal ()
+ src/Keter/RateLimiter/Cache.hs view
@@ -0,0 +1,1240 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE InstanceSigs #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE GADTs #-}++{-|+Module : Keter.RateLimiter.Cache+Description : Cache abstraction and in-memory store for rate limiting, with convenient and customisable key management+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This file is a ported to Haskell language code with some simplifications of rack-attack+https://github.com/rack/rack-attack/blob/main/lib/rack/attack/cache.rb+and is based on the structure of the original code of+rack-attack, Copyright (c) 2016 by Kickstarter, PBC, under the MIT License.++Oleksandr Zhabenko added several implementations of the window algorithm: sliding window, token bucket window, leaky bucket window alongside with the initial count algorithm using AI chatbots. Also there is extended multiple IP zones and combined usage of the algorithms with convenient wrappers provided.++This implementation is released under the MIT License.++This module provides a unified cache abstraction layer that supports multiple+rate limiting algorithms and storage backends. It uses advanced Haskell type+system features including GADTs, DataKinds, and functional dependencies to+provide type-safe, algorithm-specific storage while maintaining a common interface.++== Architecture Overview++@+┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐+│ Algorithm │ │ Cache Layer │ │ Storage Backend│+│ │ │ │ │ │+│ • FixedWindow │◄───┤ • Type Safety │◄───┤ • InMemoryStore │+│ • SlidingWindow │ │ • Key Prefixing │ │ • Auto Purging │+│ • TokenBucket │ │ • Serialization │ │ • STM Based │+│ • LeakyBucket │ │ • Error Handling │ │ • Thread Safe │+│ • TinyLRU │ │ │ │ │+└─────────────────┘ └──────────────────┘ └─────────────────┘+@++== Type-Level Algorithm Safety++The module uses DataKinds and GADTs to ensure compile-time type safety:++@+-- Algorithm types are promoted to type-level+data Algorithm = FixedWindow | TokenBucket | ...++-- Storage is parameterized by algorithm type+data InMemoryStore (a :: Algorithm) where+ CounterStore :: TVar (C.Cache Text Text) -> InMemoryStore 'FixedWindow+ TokenBucketStore :: TVar (StmMap.Map Text TokenBucketEntry) -> InMemoryStore 'TokenBucket+ -- ... other algorithms+@++This prevents runtime errors like trying to use token bucket operations on+a sliding window cache.++== Supported Algorithms++=== Fixed Window+* __Use Case__: Simple request counting per time window+* __Storage__: JSON-serialized counters with TTL+* __Performance__: O(1) read/write operations+* __Memory__: Minimal overhead, automatic expiration++=== Sliding Window +* __Use Case__: Precise rate limiting with timestamp tracking+* __Storage__: Lists of request timestamps per key+* __Performance__: O(n) where n is requests in window+* __Memory__: Proportional to request frequency++=== Token Bucket+* __Use Case__: Bursty traffic with sustained rate limits+* __Storage__: Worker threads with STM-based state+* __Performance__: O(1) with background token refill+* __Memory__: Fixed per bucket, automatic cleanup++=== Leaky Bucket+* __Use Case__: Smooth rate limiting without bursts+* __Storage__: Continuous drain workers with STM state+* __Performance__: O(1) with background draining+* __Memory__: Fixed per bucket, automatic cleanup++=== TinyLRU+* __Use Case__: Bounded cache with LRU eviction+* __Storage__: In-memory LRU cache with expiration+* __Performance__: O(1) average case operations+* __Memory__: Bounded by cache size limit++== Example Usage++=== Creating Algorithm-Specific Caches++@+import Keter.RateLimiter.Cache+import Data.Proxy++-- Type-safe cache creation+tokenCache <- do+ store <- createInMemoryStore @'TokenBucket+ return $ newCache TokenBucket store++fixedWindowCache <- do+ store <- createInMemoryStore @'FixedWindow + return $ newCache FixedWindow store+@++=== Basic Operations++@+-- Write/Read operations (type-safe based on algorithm)+writeCache tokenCache "user123" initialTokenState 3600+maybeState <- readCache tokenCache "user123"++-- Increment operations for counter-based algorithms+newCount <- incrementCache fixedWindowCache "api_key" 60+@++=== Advanced Usage with Custom Keys++@+-- Composite key generation+let userKey = makeCacheKey TokenBucket "zone1" "user456"+writeCache tokenCache userKey state 7200++-- Cleanup and reset+cacheReset tokenCache -- Clear all entries+clearInMemoryStore store -- Direct store cleanup+@++== Thread Safety and Concurrency++All operations are thread-safe using Software Transactional Memory (STM):++* __Atomic Operations__: All read/write operations are atomic+* __Lock-Free__: No explicit locking, uses STM for coordination +* __Concurrent Access__: Multiple threads can safely access same cache+* __Worker Threads__: Token/Leaky bucket algorithms use background workers+* __Auto-Purging__: Background threads clean up expired entries++== Performance Characteristics++=== Time Complexity+* __Fixed Window__: O(1) for all operations+* __Sliding Window__: O(n) for timestamp list operations+* __Token Bucket__: O(1) with background O(1) refill+* __Leaky Bucket__: O(1) with background O(1) drain+* __TinyLRU__: O(1) average case, O(n) worst case++=== Space Complexity+* __Fixed Window__: O(k) where k is number of active keys+* __Sliding Window__: O(k*n) where n is requests per window+* __Token Bucket__: O(k) with fixed per-bucket overhead+* __Leaky Bucket__: O(k) with fixed per-bucket overhead +* __TinyLRU__: O(min(k, cache_size)) bounded by cache limit++== Error Handling++The module provides robust error handling:++* __Serialization Errors__: Graceful handling of JSON encode/decode failures+* __Type Safety__: Compile-time prevention of algorithm mismatches+* __Resource Cleanup__: Automatic cleanup of failed operations+* __Thread Exceptions__: Worker threads handle exceptions gracefully+-}+module Keter.RateLimiter.Cache+ ( -- * Core Types+ -- ** Algorithm Specification+ Algorithm(..)+ , Cache(..)+ -- ** Storage Abstraction+ , CacheStore(..)+ , InMemoryStore(..)+ , ResettableStore(..)+ , CreateStore(..)+ -- * Cache Operations + -- ** Basic Operations+ , readCache+ , writeCache+ , deleteCache+ , incrementCache+ -- ** Cache Management+ , newCache+ , createInMemoryStore+ , clearInMemoryStore+ , cacheReset+ -- * Utility Functions+ -- ** Key Management+ , algorithmPrefix+ , makeCacheKey+ -- ** Time Utilities+ , secondsToTimeSpec+ -- * Background Services+ -- ** Auto-Purging+ , startAutoPurge+ , startCustomPurgeTokenBucket+ , startCustomPurgeLeakyBucket+ -- ** Worker Threads+ , startTokenBucketWorker+ , startLeakyBucketWorker+ -- * Entry Creation+ , createTokenBucketEntry+ , createLeakyBucketEntry+ ) where++import Control.Concurrent.STM+import Control.Concurrent.MVar (putMVar, takeMVar, newMVar, readMVar, MVar)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Control.Monad (void, forever)+import Control.Concurrent (forkIO, threadDelay)+import Data.Aeson (ToJSON, FromJSON, decodeStrict, encode)+import qualified Data.ByteString.Lazy as LBS+import Data.Text (Text)+import qualified Data.TinyLRU as TinyLRU+import Data.Text.Encoding (encodeUtf8, decodeUtf8')+import qualified Data.Cache as C+import System.Clock (TimeSpec(..), Clock(Monotonic), getTime, toNanoSecs)+import Data.Time.Clock.POSIX (getPOSIXTime)+import qualified StmContainers.Map as StmMap+import qualified Control.Concurrent.STM.TQueue as TQueue+import qualified Focus+import Keter.RateLimiter.Types (TokenBucketState(..), LeakyBucketState(..))+import Keter.RateLimiter.AutoPurge+import Keter.RateLimiter.TokenBucketWorker (startTokenBucketWorker)+import Data.Maybe (fromMaybe)++-- | Enumeration of supported rate limiting algorithms.+--+-- Each algorithm represents a different approach to rate limiting with distinct+-- characteristics, use cases, and performance profiles. The type is promoted+-- to the kind level using DataKinds for compile-time algorithm verification.+--+-- ==== Algorithm Characteristics Comparison+--+-- @+-- Algorithm | Bursts | Precision | Memory | Use Case+-- ---------------|--------|-----------|-----------|-------------------------+-- FixedWindow | Yes | Low | Minimal | Simple API rate limiting+-- SlidingWindow | Smooth | High | Variable | Precise traffic shaping+-- TokenBucket | Yes | Medium | Fixed | Bursty API with sustained limits+-- LeakyBucket | No | High | Fixed | Smooth streaming/bandwidth+-- TinyLRU | N/A | N/A | Bounded | General caching with eviction+-- @+--+-- ==== Detailed Algorithm Descriptions+--+-- * __FixedWindow__: Divides time into fixed intervals, counts requests per interval+-- * __SlidingWindow__: Maintains precise timestamps, allows smooth rate distribution +-- * __TokenBucket__: Accumulates tokens over time, consumes tokens per request+-- * __LeakyBucket__: Continuous leak rate, requests fill the bucket+-- * __TinyLRU__: Least-Recently-Used cache with size bounds and TTL+data Algorithm = FixedWindow | SlidingWindow | TokenBucket | LeakyBucket | TinyLRU+ deriving (Show, Eq)++-- | Map each algorithm to its unique cache key prefix.+--+-- Prefixes prevent key collisions between different algorithms and provide+-- clear identification of cached data types. Each algorithm uses a distinct+-- namespace within the same storage backend.+--+-- ==== Prefix Usage Pattern+--+-- @+-- -- Fixed window counter for API key "abc123"+-- Key: "rate_limiter:api:abc123"+--+-- -- Token bucket state for user "user456" +-- Key: "token_bucket:user:user456"+--+-- -- Sliding window timestamps for IP "192.168.1.1"+-- Key: "timestamps:ip:192.168.1.1"+-- @+--+-- ==== Example+--+-- @+-- ghci> algorithmPrefix TokenBucket+-- "token_bucket"+-- ghci> algorithmPrefix FixedWindow +-- "rate_limiter"+-- @+algorithmPrefix :: Algorithm -- ^ The rate limiting algorithm+ -> Text -- ^ Corresponding cache key prefix+algorithmPrefix FixedWindow = "rate_limiter"+algorithmPrefix SlidingWindow = "timestamps"+algorithmPrefix TokenBucket = "token_bucket"+algorithmPrefix LeakyBucket = "leaky_bucket"+algorithmPrefix TinyLRU = "tiny_lru"++-- | Cache wrapper that combines an algorithm specification with a storage backend.+--+-- The cache type provides a unified interface while maintaining algorithm-specific+-- behavior through the type system. It encapsulates both the algorithm logic+-- and the underlying storage implementation.+--+-- ==== Type Parameters+--+-- * @store@: The storage backend type (e.g., 'InMemoryStore', Redis, etc.)+-- * The algorithm is captured in the store type for type safety+--+-- ==== Example Usage+--+-- @+-- -- Create a token bucket cache+-- tokenStore <- createInMemoryStore @'TokenBucket+-- let tokenCache = Cache TokenBucket tokenStore+--+-- -- Create a fixed window cache+-- counterStore <- createInMemoryStore @'FixedWindow+-- let counterCache = Cache FixedWindow counterStore+-- @+data Cache store = Cache+ { cacheAlgorithm :: Algorithm -- ^ The rate limiting algorithm this cache implements.+ -- Used for key prefixing and operation validation.+ , cacheStore :: store -- ^ The underlying storage backend. Type determines+ -- supported operations and value types.+ }++-- | Typeclass abstracting cache storage backends with functional dependencies.+--+-- This typeclass provides a uniform interface for different storage implementations+-- while allowing each backend to specify its supported value types. The functional+-- dependency @store -> v@ ensures that each store type uniquely determines its+-- value type, providing additional type safety.+--+-- ==== Design Principles+--+-- * __Type Safety__: Functional dependencies prevent type mismatches+-- * __Flexibility__: Support for different storage backends (memory, Redis, etc.)+-- * __Performance__: Allow backend-specific optimizations+-- * __Consistency__: Uniform interface across all implementations+--+-- ==== Default Implementations+--+-- The typeclass provides sensible defaults for increment operations, but backends+-- can override for performance optimizations:+--+-- @+-- -- Default increment: read, modify, write+-- incStore store prefix key expires = do+-- mval <- readStore store prefix key+-- let newVal = maybe 1 (+1) mval+-- writeStore store prefix key newVal expires+-- return newVal+-- @+--+-- ==== Atomicity Guarantees+--+-- Implementations should provide atomicity guarantees appropriate for their+-- backend:+--+-- * __STM-based stores__: Full ACID transactions+-- * __Memory stores__: Process-level atomicity +-- * __Distributed stores__: Network-level consistency+class MonadIO m => CacheStore store v m | store -> v where+ -- | Read a value from the store.+ --+ -- @+ -- result <- readStore store "rate_limiter" "api_key_123"+ -- -- result: Maybe Int (for counter-based algorithms)+ -- @+ readStore :: store -- ^ Storage backend instance+ -> Text -- ^ Key prefix (algorithm-specific)+ -> Text -- ^ Full cache key+ -> m (Maybe v) -- ^ Retrieved value, or Nothing if not found++ -- | Write a value to the store with expiration.+ --+ -- @+ -- writeStore store "token_bucket" "user_456" bucketState 3600+ -- -- Stores bucket state with 1-hour TTL+ -- @+ writeStore :: store -- ^ Storage backend instance+ -> Text -- ^ Key prefix (algorithm-specific)+ -> Text -- ^ Full cache key+ -> v -- ^ Value to store+ -> Int -- ^ TTL in seconds+ -> m ()++ -- | Delete a key from the store.+ --+ -- @+ -- deleteStore store "timestamps" "ip_192_168_1_1"+ -- -- Removes sliding window timestamps for IP+ -- @+ deleteStore :: store -- ^ Storage backend instance+ -> Text -- ^ Key prefix (algorithm-specific) + -> Text -- ^ Full cache key+ -> m ()++ -- | Atomically increment a numeric value.+ --+ -- Provides atomic increment-or-initialize semantics. If the key doesn't exist,+ -- initializes to 1. If it exists, increments by 1. Essential for counter-based+ -- rate limiting algorithms.+ --+ -- @+ -- newCount <- incStore store "rate_limiter" "api_throttle" 60+ -- -- Returns new count after increment, with 60-second TTL+ -- @+ incStore :: (FromJSON v, ToJSON v, Ord v, Num v) + => store -- ^ Storage backend instance+ -> Text -- ^ Key prefix (algorithm-specific)+ -> Text -- ^ Full cache key + -> Int -- ^ TTL in seconds for the incremented value+ -> m v -- ^ New value after increment+ incStore store prefix key expiresIn = do -- Default implementation+ mval <- readStore store prefix key+ let newVal = case mval of+ Nothing -> 1+ Just v -> if v <= 0 then 1 else v + 1+ writeStore store prefix key newVal expiresIn+ return newVal++-- | Typeclass for storage backends that support complete reset operations.+--+-- Provides a way to clear all data from a store, useful for testing,+-- maintenance, and emergency reset scenarios. Implementations should+-- ensure thread safety and atomic reset behavior.+--+-- ==== Use Cases+--+-- * __Testing__: Clean state between test runs+-- * __Maintenance__: Clear corrupted or stale data+-- * __Memory Management__: Recover from memory pressure+-- * __Configuration Changes__: Reset after algorithm parameter changes+--+-- ==== Example+--+-- @+-- -- Reset all rate limiting data+-- resetStore myTokenBucketStore+--+-- -- Reset entire cache+-- cacheReset myCache+-- @+class ResettableStore store where+ -- | Clear all entries from the store.+ --+ -- Should be atomic and thread-safe. After reset, the store should behave+ -- as if it were newly created.+ resetStore :: store -> IO ()++-- | Algorithm-parameterized in-memory storage using GADTs.+--+-- This type uses GADTs (Generalized Algebraic Data Types) to provide compile-time+-- guarantees that each algorithm uses appropriate storage structures. The phantom+-- type parameter ensures that token bucket operations can't be used on sliding+-- window stores, etc.+--+-- ==== GADT Benefits+--+-- * __Type Safety__: Prevents algorithm/storage mismatches at compile time+-- * __Performance__: Specialized storage for each algorithm's needs+-- * __Extensibility__: Easy to add new algorithms with appropriate storage+-- * __Documentation__: Types serve as executable documentation+--+-- ==== Storage Specialization+--+-- Each algorithm gets optimized storage:+--+-- * __Counters__: Simple key-value cache with TTL+-- * __Timestamps__: STM Map of timestamp lists +-- * __Token Buckets__: STM Map of worker entries with background threads+-- * __Leaky Buckets__: STM Map with continuous drain workers+-- * __TinyLRU__: Bounded LRU cache with automatic eviction+--+-- ==== Memory Management+--+-- * Token and Leaky bucket stores include automatic purging+-- * TinyLRU provides bounded memory usage+-- * Counter stores use TTL-based expiration+-- * All stores support manual reset for cleanup+data InMemoryStore (a :: Algorithm) where+ -- | Counter-based storage for fixed window algorithm.+ --+ -- Uses 'Data.Cache' for automatic TTL-based expiration. Stores JSON-serialized+ -- counter values with precise expiration timing.+ CounterStore :: TVar (C.Cache Text Text) -> InMemoryStore 'FixedWindow++ -- | Timestamp list storage for sliding window algorithm.+ --+ -- Maintains lists of request timestamps per key. Enables precise rate+ -- calculation by examining timestamps within the sliding time window.+ TimestampStore :: TVar (StmMap.Map Text [Double]) -> InMemoryStore 'SlidingWindow++ -- | Token bucket entry storage with worker thread management.+ --+ -- Each entry includes bucket state, request queue, and worker synchronization.+ -- Automatically starts purge threads to clean up inactive buckets.+ TokenBucketStore :: TVar (StmMap.Map Text TokenBucketEntry) -> InMemoryStore 'TokenBucket++ -- | Leaky bucket entry storage with continuous drain workers.+ --+ -- Similar to token buckets but with continuous drain semantics. Each bucket+ -- has a worker thread that continuously drains at the specified rate.+ LeakyBucketStore :: TVar (StmMap.Map Text LeakyBucketEntry) -> InMemoryStore 'LeakyBucket++ -- | Bounded LRU cache storage.+ --+ -- Provides fixed-size cache with least-recently-used eviction. Suitable+ -- for scenarios where memory bounds are more important than precise rate limiting.+ TinyLRUStore :: TVar (TinyLRU.TinyLRUCache s) -> InMemoryStore 'TinyLRU++-- | Typeclass for creating algorithm-specific storage instances.+--+-- Uses type-level programming to ensure each algorithm gets appropriate storage.+-- The phantom type parameter prevents creation of incompatible store types.+--+-- ==== Type-Level Dispatch+--+-- @+-- -- Compiler ensures correct store type+-- tokenStore <- createStore @'TokenBucket -- Creates TokenBucketStore+-- counterStore <- createStore @'FixedWindow -- Creates CounterStore+-- +-- -- This would be a compile error:+-- -- tokenStore <- createStore @'SlidingWindow -- Type mismatch!+-- @+--+-- ==== Automatic Services+--+-- Some storage types automatically start background services:+--+-- * __Token/Leaky buckets__: Auto-purge threads for inactive entries+-- * __Counter stores__: TTL-based expiration threads+-- * __Timestamp stores__: Manual cleanup required+-- * __TinyLRU__: Built-in eviction on size limits+class CreateStore (a :: Algorithm) where+ -- | Create a new storage instance for the specified algorithm.+ --+ -- Initializes all necessary data structures and background services.+ -- The created store is immediately ready for use.+ createStore :: IO (InMemoryStore a)++-- | Convert seconds to TimeSpec for use with Data.Cache.+--+-- Calculates an absolute future time by adding the specified duration+-- to the current monotonic time. Used for setting TTL values in cache+-- operations.+--+-- ==== Monotonic Time Benefits+--+-- * __Clock Adjustments__: Unaffected by system clock changes+-- * __Precision__: Nanosecond resolution for accurate timing+-- * __Performance__: Fast system call with minimal overhead+-- * __Reliability__: Guaranteed monotonic progression+--+-- ==== Example+--+-- @+-- -- Create 5-minute expiration time+-- expiryTime <- secondsToTimeSpec 300+-- +-- -- Use with cache operations+-- C.insertSTM key value cache (Just expiryTime)+-- @+secondsToTimeSpec :: Int -- ^ Duration in seconds from now+ -> IO TimeSpec -- ^ Absolute future time for expiration+secondsToTimeSpec seconds = do+ now <- getTime Monotonic+ return $ now + TimeSpec (fromIntegral seconds) 0++-- | Create store instances for each Algorithm.+instance CreateStore 'FixedWindow where+ createStore = createStoreWith CounterStore++instance CreateStore 'SlidingWindow where+ createStore = do+ emptyMap <- atomically (StmMap.new :: STM (StmMap.Map Text [Double]))+ tvar <- newTVarIO emptyMap+ pure $ TimestampStore tvar++instance CreateStore 'TokenBucket where+ createStore = do+ emptyMap <- atomically (StmMap.new :: STM (StmMap.Map Text TokenBucketEntry))+ tvar <- newTVarIO emptyMap+ void $ startCustomPurgeTokenBucket emptyMap (60 :: Integer) (3600 :: Integer)+ pure $ TokenBucketStore tvar++instance CreateStore 'LeakyBucket where+ createStore = do+ emptyMap <- atomically (StmMap.new :: STM (StmMap.Map Text LeakyBucketEntry))+ tvar <- newTVarIO emptyMap+ void $ startCustomPurgeLeakyBucket emptyMap (60 :: Integer) (3600 :: Integer)+ pure $ LeakyBucketStore tvar++instance CreateStore 'TinyLRU where+ createStore = TinyLRUStore <$> (atomically $ newTVar =<< TinyLRU.initTinyLRU 100)++createStoreWith :: (TVar (C.Cache Text Text) -> InMemoryStore a) -> IO (InMemoryStore a)+createStoreWith mkStore = do+ rawCache <- C.newCache Nothing+ purgeInterval <- newMVar (60 :: Integer) -- Purge every 60 seconds+ purgeSignal <- newMVar () -- Signal to trigger purge+ void $ forkIO $ forever $ do+ takeMVar purgeSignal+ interval <- readMVar purgeInterval+ startTime <- getTime Monotonic+ C.purgeExpired rawCache+ endTime <- getTime Monotonic+ let elapsedMicros = (toNanoSecs endTime - toNanoSecs startTime) `div` 1000+ remainingMicros = max (0 :: Integer) (interval * 1000000 - elapsedMicros)+ waitUntilNextPurge startTime remainingMicros purgeSignal+ tvar <- newTVarIO rawCache+ return $ mkStore tvar++-- | Wait until the next purge interval deterministically+waitUntilNextPurge :: TimeSpec -> Integer -> MVar () -> IO ()+waitUntilNextPurge startTime remainingMicros purgeSignal = do+ currentTime <- getTime Monotonic+ let elapsedMicros = (toNanoSecs currentTime - toNanoSecs startTime) `div` 1000+ if elapsedMicros >= remainingMicros+ then putMVar purgeSignal () -- Signal the next purge+ else do+ let sleepMicros = fromIntegral (min remainingMicros (toInteger (maxBound :: Int))) :: Int+ threadDelay sleepMicros -- Use threadDelay for the remaining time+ putMVar purgeSignal () -- Signal after waiting++-- | Create a new in-memory store for a specific rate-limiting algorithm.+--+-- This function provides a convenient, type-safe way to create algorithm-specific+-- storage. It uses TypeApplications to specify which algorithm's store to create,+-- ensuring compile-time correctness.+--+-- ==== Type Safety Example+--+-- @+-- -- These are all valid and type-safe:+-- tokenStore <- createInMemoryStore @'TokenBucket+-- counterStore <- createInMemoryStore @'FixedWindow+-- lruStore <- createInMemoryStore @'TinyLRU+--+-- -- This would be a compile error (typo):+-- -- badStore <- createInMemoryStore @'TokenBuckett -- Not a valid algorithm+-- @+--+-- ==== Background Services+--+-- Some algorithms automatically start background services:+--+-- * __TokenBucket/LeakyBucket__: Purge threads for cleanup (60s interval, 1h TTL)+-- * __FixedWindow__: TTL-based expiration threads+-- * __SlidingWindow/TinyLRU__: No automatic background services+--+-- ==== Example Usage+--+-- @+-- import Data.Proxy+--+-- main = do+-- -- Create stores for different algorithms+-- tokenStore <- createInMemoryStore @'TokenBucket+-- slidingStore <- createInMemoryStore @'SlidingWindow+-- +-- -- Use in cache creation+-- let tokenCache = newCache TokenBucket tokenStore+-- let slidingCache = newCache SlidingWindow slidingStore+-- @+createInMemoryStore :: forall (a :: Algorithm). CreateStore a => IO (InMemoryStore a)+createInMemoryStore = createStore @a++-- | Create a new cache with a given Algorithm and store.+--+-- This is the primary constructor for cache instances. It combines an algorithm+-- specification with a storage backend to create a fully functional cache.+-- The algorithm parameter is used for key prefixing and operation validation.+--+-- ==== Design Rationale+--+-- * __Separation of Concerns__: Algorithm logic separate from storage implementation+-- * __Flexibility__: Same algorithm can use different storage backends+-- * __Type Safety__: Algorithm-store compatibility enforced by types+-- * __Testability__: Easy to mock storage for testing+--+-- ==== Example+--+-- @+-- -- Create a token bucket cache with in-memory storage+-- store <- createInMemoryStore @'TokenBucket+-- let cache = newCache TokenBucket store+--+-- -- Later operations use the unified cache interface+-- writeCache cache "user123" initialState 3600+-- result <- readCache cache "user123"+-- @+newCache :: Algorithm -- ^ The rate limiting algorithm this cache implements+ -> store -- ^ The storage backend (must be compatible with algorithm)+ -> Cache store -- ^ Complete cache instance ready for use+newCache algo store = Cache+ { cacheAlgorithm = algo+ , cacheStore = store+ }++-- | Read from cache using the algorithm-prefixed key.+--+-- Automatically applies the appropriate key prefix based on the cache's algorithm,+-- then delegates to the storage backend's read operation. This ensures consistent+-- key namespacing across all cache operations.+--+-- ==== Key Transformation+--+-- @+-- -- For a TokenBucket cache with key "user123":+-- -- Actual key used: "token_bucket:token_bucket:user123"+-- -- ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^+-- -- prefix prefixed key+-- @+--+-- ==== Type Safety+--+-- The return type is determined by the storage backend's CacheStore instance,+-- ensuring you get the correct value type for the algorithm:+--+-- * __FixedWindow__: @Maybe Int@ (counter values)+-- * __TokenBucket__: @Maybe TokenBucketState@ (bucket state)+-- * __SlidingWindow__: @Maybe [Double]@ (timestamp lists)+--+-- ==== Example+--+-- @+-- -- Read token bucket state+-- maybeState <- readCache tokenCache "user456"+-- case maybeState of+-- Nothing -> putStrLn "No bucket exists for user"+-- Just state -> putStrLn $ "User has " ++ show (tokens state) ++ " tokens"+-- @+readCache :: (CacheStore store v IO) + => Cache store -- ^ Cache instance with algorithm and storage+ -> Text -- ^ Unprefixed key (e.g., "user123", "api_key_abc")+ -> IO (Maybe v) -- ^ Retrieved value with algorithm-appropriate type+readCache cache unprefixedKey =+ readStore (cacheStore cache) (algorithmPrefix $ cacheAlgorithm cache)+ (algorithmPrefix (cacheAlgorithm cache) <> ":" <> unprefixedKey)++-- | Write to cache using the algorithm-prefixed key.+--+-- Stores a value with automatic key prefixing and TTL handling. The value type+-- must match what the storage backend expects for the cache's algorithm.+--+-- ==== TTL Behavior+--+-- * __Absolute TTL__: Expiration time calculated from current time+-- * __Background Cleanup__: Most stores have automatic cleanup threads+-- * __Precision__: Uses monotonic clock for accurate timing+-- * __Consistency__: TTL behavior consistent across all algorithms+--+-- ==== Example+--+-- @+-- -- Store token bucket state for 1 hour+-- let initialState = TokenBucketState 100 currentTime+-- writeCache tokenCache "new_user" initialState 3600+--+-- -- Store counter value for 5 minutes+-- writeCache counterCache "api_limit" (42 :: Int) 300+-- @+writeCache :: (CacheStore store v IO) + => Cache store -- ^ Cache instance+ -> Text -- ^ Unprefixed key+ -> v -- ^ Value to store (type must match store's expectation)+ -> Int -- ^ TTL in seconds (time until expiration)+ -> IO () -- ^ No return value, throws on error+writeCache cache unprefixedKey val expiresIn =+ writeStore (cacheStore cache) (algorithmPrefix $ cacheAlgorithm cache)+ (algorithmPrefix (cacheAlgorithm cache) <> ":" <> unprefixedKey) val expiresIn++-- | Delete a key from cache using the algorithm-prefixed key.+--+-- Removes an entry from the cache, including any associated resources+-- (worker threads, background tasks, etc.). The operation is atomic+-- and thread-safe.+--+-- ==== Resource Cleanup+--+-- * __Token/Leaky Buckets__: Terminates associated worker threads+-- * __Counters__: Simple key removal+-- * __Timestamps__: Clears timestamp lists+-- * __LRU__: Updates LRU ordering and frees space+--+-- ==== Example+--+-- @+-- -- Remove user's rate limiting state+-- deleteCache tokenCache "inactive_user"+--+-- -- Clear API key's counter+-- deleteCache counterCache "expired_api_key"+-- @+deleteCache :: (CacheStore store v IO) + => Cache store -- ^ Cache instance+ -> Text -- ^ Unprefixed key to delete+ -> IO () -- ^ No return value, silent if key doesn't exist+deleteCache cache unprefixedKey =+ deleteStore (cacheStore cache) (algorithmPrefix $ cacheAlgorithm cache)+ (algorithmPrefix (cacheAlgorithm cache) <> ":" <> unprefixedKey)++-- | Increment a numeric cache value or initialise it if missing.+--+-- Provides atomic increment-or-initialize semantics essential for counter-based+-- rate limiting. If the key doesn't exist, initializes to 1. If it exists,+-- increments by 1. The operation is atomic even under high concurrency.+--+-- ==== Atomicity Guarantees+--+-- * __STM-based stores__: Full transaction isolation+-- * __Memory stores__: Process-level atomicity+-- * __Distributed stores__: Backend-specific consistency+--+-- ==== Error Handling+--+-- * __Type Mismatch__: Returns error if existing value isn't numeric+-- * __Serialization__: Handles JSON encoding/decoding failures gracefully+-- * __Overflow__: Behavior depends on numeric type (Int, Double, etc.)+--+-- ==== Example+--+-- @+-- -- Increment API request counter+-- newCount <- incrementCache apiCache "requests_per_minute" 60+-- when (newCount > 1000) $ throwError "Rate limit exceeded"+--+-- -- Initialize or increment user action count+-- actionCount <- incrementCache userCache "daily_actions" 86400+-- @+incrementCache :: (CacheStore store v IO, FromJSON v, ToJSON v, Ord v, Num v) + => Cache store -- ^ Cache instance (must support numeric values)+ -> Text -- ^ Unprefixed key to increment+ -> Int -- ^ TTL in seconds for the incremented value+ -> IO v -- ^ New value after increment+incrementCache cache unprefixedKey expiresIn = do+ let fullKey = algorithmPrefix (cacheAlgorithm cache) <> ":" <> unprefixedKey+ prefix = algorithmPrefix (cacheAlgorithm cache)+ incStore (cacheStore cache) prefix fullKey expiresIn++-- | Clear all entries in an in-memory store.+--+-- Provides a direct interface to the ResettableStore functionality.+-- Useful when you need to reset storage without going through the+-- cache wrapper.+--+-- ==== Use Cases+--+-- * __Testing__: Clean slate between test cases+-- * __Maintenance__: Clear corrupted state+-- * __Memory Management__: Free up memory during low usage+-- * __Reconfiguration__: Reset before changing algorithm parameters+--+-- ==== Thread Safety+--+-- The operation is atomic and thread-safe, but concurrent operations+-- may see the reset at different times. Consider coordinating with+-- other threads if precise timing is required.+--+-- ==== Example+--+-- @+-- -- Direct store reset+-- clearInMemoryStore myTokenBucketStore+--+-- -- Conditional reset based on memory usage+-- when memoryPressure $ clearInMemoryStore store+-- @+clearInMemoryStore :: ResettableStore store + => store -- ^ Storage instance to clear+ -> IO () -- ^ No return value, completes when reset is done+clearInMemoryStore = resetStore++-- | Reset all entries in a cache.+--+-- Clears all data from the cache's storage backend. This is a convenience+-- wrapper around clearInMemoryStore that works at the cache level rather+-- than the storage level.+--+-- ==== Behavior+--+-- * __Complete Reset__: All keys and values are removed+-- * __Background Services__: Worker threads and purge threads continue running+-- * __Algorithm State__: Any algorithm-specific state is cleared+-- * __Immediate Effect__: Reset is visible to all threads immediately+--+-- ==== Example+--+-- @+-- -- Reset entire token bucket cache+-- cacheReset tokenBucketCache+--+-- -- Reset counter cache for new time period+-- cacheReset apiCounterCache+-- @+cacheReset :: ResettableStore store + => Cache store -- ^ Cache instance to reset+ -> IO () -- ^ No return value, completes when reset is done+cacheReset (Cache _ store) = resetStore store++-- | Helper function to create a TokenBucketEntry with proper TMVar initialization.+--+-- Creates a complete token bucket entry with all necessary components:+-- state storage, request queue, and worker synchronization. This ensures+-- proper initialization of all STM components.+--+-- ==== Entry Components+--+-- * __State TVar__: Atomic storage for bucket state (tokens, last update)+-- * __Request Queue__: TQueue for client-worker communication+-- * __Worker Lock__: TMVar for coordinating worker thread lifecycle+--+-- ==== Example+--+-- @+-- -- Create entry for new user bucket+-- now <- floor <$> getPOSIXTime+-- let initialState = TokenBucketState 100 now -- 100 tokens, current time+-- entry <- createTokenBucketEntry initialState+--+-- -- Entry is ready for insertion into STM map+-- atomically $ StmMap.insert entry "user123" bucketMap+-- @+createTokenBucketEntry :: TokenBucketState -- ^ Initial bucket state (tokens, timestamp)+ -> IO TokenBucketEntry -- ^ Complete entry ready for use+createTokenBucketEntry state = do+ stateVar <- newTVarIO state+ queue <- atomically TQueue.newTQueue+ workerLock <- atomically newEmptyTMVar+ return $ TokenBucketEntry stateVar queue workerLock++-- | Helper function to create a LeakyBucketEntry with proper TMVar initialization.+--+-- Similar to createTokenBucketEntry but for leaky bucket algorithm. Creates+-- all necessary STM components for leaky bucket operation with continuous+-- drain semantics.+--+-- ==== Entry Components+--+-- * __State TVar__: Atomic storage for bucket level and last update time+-- * __Request Queue__: TQueue using TMVar for STM-based responses+-- * __Worker Lock__: TMVar for worker thread coordination+--+-- ==== Example+--+-- @+-- -- Create entry for streaming connection+-- now <- realToFrac <$> getPOSIXTime+-- let initialState = LeakyBucketState 0.0 now -- Empty bucket, current time+-- entry <- createLeakyBucketEntry initialState+--+-- -- Entry ready for continuous drain processing+-- atomically $ StmMap.insert entry "stream123" bucketMap+-- @+createLeakyBucketEntry :: LeakyBucketState -- ^ Initial bucket state (level, timestamp)+ -> IO LeakyBucketEntry -- ^ Complete entry ready for use+createLeakyBucketEntry state = do+ stateVar <- newTVarIO state+ queue <- atomically TQueue.newTQueue+ workerLock <- atomically newEmptyTMVar+ return $ LeakyBucketEntry stateVar queue workerLock++-- | CacheStore instances++-- | CacheStore instance for FixedWindow algorithm using integer counters.+--+-- Implements counter-based rate limiting with JSON serialization and TTL support.+-- Uses Data.Cache for automatic expiration and efficient storage.+--+-- ==== Storage Format+--+-- * __Keys__: Text identifiers (API keys, user IDs, etc.)+-- * __Values__: JSON-serialized integers representing request counts+-- * __Expiration__: Automatic TTL-based cleanup+--+-- ==== Atomicity+--+-- Increment operations use STM for thread-safe atomic updates, ensuring+-- accurate counting even under high concurrency.+instance CacheStore (InMemoryStore 'FixedWindow) Int IO where+ readStore (CounterStore tvar) _prefix key = do+ cache <- readTVarIO tvar+ mval <- C.lookup cache key+ case mval of+ Nothing -> return Nothing+ Just txt -> return $ decodeStrict (encodeUtf8 txt)+ writeStore (CounterStore tvar) _prefix key val expiresIn = do+ let bs = encode val+ strictBs = LBS.toStrict bs+ jsonTxt = case decodeUtf8' strictBs of+ Left _ -> ""+ Right txt -> txt+ expiryTimeSpec <- secondsToTimeSpec expiresIn+ atomically $ do+ cache <- readTVar tvar+ C.insertSTM key jsonTxt cache (Just expiryTimeSpec)+ deleteStore (CounterStore tvar) _prefix key = do+ cache <- readTVarIO tvar+ C.delete cache key+ incStore (CounterStore tvar) _prefix key expiresIn = do+ now <- getTime Monotonic+ expiryTimeSpec <- secondsToTimeSpec expiresIn+ atomically $ do+ cache <- readTVar tvar+ mval <- C.lookupSTM False key cache now+ let currentVal = case mval of+ Nothing -> 0+ Just txt -> fromMaybe 0 (decodeStrict (encodeUtf8 txt))+ let newVal = currentVal + 1+ let bs = encode newVal+ strictBs = LBS.toStrict bs+ jsonTxt = case decodeUtf8' strictBs of+ Left _ -> ""+ Right txt -> txt+ C.insertSTM key jsonTxt cache (Just expiryTimeSpec)+ return newVal++-- | CacheStore instance for SlidingWindow algorithm using timestamp lists.+--+-- Stores lists of request timestamps for precise sliding window calculations.+-- Does not use TTL as timestamps are managed by the sliding window logic.+instance CacheStore (InMemoryStore 'SlidingWindow) [Double] IO where+ readStore (TimestampStore tvar) _prefix key = atomically $ do+ stmMap <- readTVar tvar+ StmMap.lookup key stmMap+ writeStore (TimestampStore tvar) _prefix key val _expiresIn = atomically $ do+ stmMap <- readTVar tvar+ StmMap.insert val key stmMap+ deleteStore (TimestampStore tvar) _prefix key = atomically $ do+ stmMap <- readTVar tvar+ StmMap.delete key stmMap++-- | CacheStore instance for TokenBucket algorithm.+--+-- Provides access to token bucket state while maintaining the worker thread+-- infrastructure. Read operations return current bucket state, write operations+-- update state and manage worker lifecycle.+instance CacheStore (InMemoryStore 'TokenBucket) TokenBucketState IO where+ readStore (TokenBucketStore tvar) _prefix key = atomically $ do+ stmMap <- readTVar tvar+ mval <- StmMap.lookup key stmMap+ case mval of+ Nothing -> return Nothing+ Just entry -> Just <$> readTVar (tbeState entry)+ writeStore (TokenBucketStore tvar) _prefix key val _expiresIn = do+ entry <- createTokenBucketEntry val+ atomically $ do+ stmMap <- readTVar tvar+ StmMap.focus (focusInsertOrUpdate tbeState entry val) key stmMap+ deleteStore (TokenBucketStore tvar) _prefix key = atomically $ do+ stmMap <- readTVar tvar+ StmMap.delete key stmMap++-- | CacheStore instance for LeakyBucket algorithm.+--+-- Similar to TokenBucket but for continuous drain semantics. Manages+-- leaky bucket state and worker thread lifecycle.+instance CacheStore (InMemoryStore 'LeakyBucket) LeakyBucketState IO where+ readStore (LeakyBucketStore tvar) _prefix key = atomically $ do+ stmMap <- readTVar tvar+ mval <- StmMap.lookup key stmMap+ case mval of+ Nothing -> return Nothing+ Just entry -> Just <$> readTVar (lbeState entry)+ writeStore (LeakyBucketStore tvar) _prefix key val _expiresIn = do+ entry <- createLeakyBucketEntry val+ atomically $ do+ stmMap <- readTVar tvar+ StmMap.focus (focusInsertOrUpdate lbeState entry val) key stmMap+ deleteStore (LeakyBucketStore tvar) _prefix key = atomically $ do+ stmMap <- readTVar tvar+ StmMap.delete key stmMap++-- | Generic helper for Focus insert-or-update operations.+--+-- Provides atomic insert-or-update semantics for STM maps. If the key doesn't+-- exist, inserts the new entry. If it exists, updates the state within the+-- existing entry.+focusInsertOrUpdate+ :: (entry -> TVar v) -- ^ Function to extract state TVar from entry+ -> entry -- ^ New entry to insert if key doesn't exist+ -> v -- ^ New value to set in state TVar+ -> Focus.Focus entry STM () -- ^ Focus operation for atomic insert-or-update+focusInsertOrUpdate getState entry newVal = Focus.Focus+ (pure ((), Focus.Set entry))+ (\existing -> do+ writeTVar (getState existing) newVal+ pure ((), Focus.Leave))++-- | Leaky bucket worker thread implementation.+--+-- Implements the continuous drain algorithm for leaky buckets. Processes+-- requests from a queue and updates bucket state based on elapsed time+-- and leak rate.+--+-- ==== Algorithm Details+--+-- 1. __Drain Calculation__: level' = max(0, level - elapsed * leakRate)+-- 2. __Request Processing__: level'' = level' + requestSize (typically 1)+-- 3. __Capacity Check__: allowed = level'' <= capacity+-- 4. __State Update__: Apply leak even on denial for accurate timing+--+-- ==== Example+--+-- @+-- -- Start worker for streaming rate limiter+-- startLeakyBucketWorker stateVar queue 100 2.0 "stream123"+-- -- Capacity: 100 requests, Leak rate: 2 requests/second+-- @+startLeakyBucketWorker+ :: TVar LeakyBucketState -- ^ Shared bucket state+ -> TQueue.TQueue (TMVar Bool) -- ^ Request queue with STM responses+ -> Int -- ^ Bucket capacity (maximum level)+ -> Double -- ^ Leak rate (requests drained per second)+ -> IO () -- ^ Returns immediately, worker runs in background+startLeakyBucketWorker stateVar queue capacity leakRate = void . forkIO $ + forever $ do+ replyVar <- atomically $ readTQueue queue+ now <- realToFrac <$> getPOSIXTime+ result <- atomically $ do+ LeakyBucketState{level,lastTime} <- readTVar stateVar+ let elapsed = now - lastTime+ leakedLevel = max 0 (level - elapsed * leakRate)+ nextLevel = leakedLevel + 1+ allowed = nextLevel <= fromIntegral capacity+ -- **FIX:** apply leak even on denial+ finalLevel = if allowed then nextLevel else leakedLevel+ writeTVar stateVar LeakyBucketState{ level = finalLevel, lastTime = now }+ return allowed+ atomically $ putTMVar replyVar result++-- | CacheStore instance for TinyLRU algorithm.+--+-- Provides bounded cache with least-recently-used eviction and TTL support.+-- Automatically handles expiration and LRU ordering updates.+instance CacheStore (InMemoryStore 'TinyLRU) Int IO where+ readStore (TinyLRUStore ref) _prefix key = do+ now <- liftIO $ getTime Monotonic+ atomically $ do+ cache <- readTVar ref+ maybeNodeRef <- StmMap.lookup key (TinyLRU.lruCache cache)+ case maybeNodeRef of+ Just nodeRef -> do+ node <- readTVar nodeRef+ let expired = TinyLRU.isExpired now node+ if expired+ then do+ TinyLRU.deleteKey key cache+ return Nothing+ else do+ let decoded :: Maybe Int = decodeStrict (TinyLRU.nodeValue node)+ TinyLRU.moveToFrontInCache cache nodeRef+ return decoded+ Nothing -> return Nothing+ writeStore (TinyLRUStore ref) _prefix key val expiresIn = do+ now <- liftIO $ getTime Monotonic+ atomically $ do+ cache <- readTVar ref+ _ <- TinyLRU.updateValue now key val expiresIn cache+ return ()+ deleteStore (TinyLRUStore ref) _prefix key = do+ atomically $ do+ cache <- readTVar ref+ TinyLRU.deleteKey key cache++-- | ResettableStore instances++-- | ResettableStore instances for all InMemoryStore variants.+--+-- Provides uniform reset behavior across all algorithm-specific stores.+-- Each implementation handles algorithm-specific cleanup requirements.+instance ResettableStore (InMemoryStore a) where+ resetStore (CounterStore tvar) = resetStoreWith tvar+ resetStore (TimestampStore tvar) = atomically $ do+ stmMap <- readTVar tvar+ StmMap.reset stmMap+ resetStore (TokenBucketStore tvar) = atomically $ do+ stmMap <- readTVar tvar+ StmMap.reset stmMap+ resetStore (LeakyBucketStore tvar) = atomically $ do+ stmMap <- readTVar tvar+ StmMap.reset stmMap+ resetStore (TinyLRUStore ref) = atomically $ do+ cache <- readTVar ref+ TinyLRU.resetTinyLRU cache++-- | Reset helper for Data.Cache-based stores.+--+-- Creates a fresh cache instance and atomically replaces the old one.+-- This ensures all TTL timers and cached data are completely cleared.+resetStoreWith :: TVar (C.Cache Text Text) -> IO ()+resetStoreWith tvar = do+ newCache <- C.newCache Nothing+ atomically $ writeTVar tvar newCache++-- | Compose a unique cache key from algorithm, IP zone, and user identifier.+--+-- Creates hierarchical cache keys that prevent collisions and enable+-- efficient organization of rate limiting data. The key format follows+-- a consistent pattern across all algorithms.+--+-- ==== Key Format+--+-- @+-- "algorithm_prefix:ip_zone:user_key"+-- @+--+-- ==== Examples+--+-- @+-- makeCacheKey TokenBucket "api" "user123"+-- -- Result: "token_bucket:api:user123"+--+-- makeCacheKey FixedWindow "web" "192.168.1.1" +-- -- Result: "rate_limiter:web:192.168.1.1"+-- @+--+-- ==== Use Cases+--+-- * __Multi-tenant Applications__: Separate rate limits per tenant+-- * __Geographic Zones__: Different limits for different regions+-- * __Service Tiers__: Varied limits based on user subscription level+-- * __API Versioning__: Separate limits for different API versions+--+-- ==== Benefits+--+-- * __Collision Prevention__: Hierarchical structure prevents key conflicts+-- * __Query Efficiency__: Pattern-based queries and cleanup+-- * __Debugging__: Clear key structure aids troubleshooting+-- * __Monitoring__: Easy to aggregate metrics by zone or user type+makeCacheKey :: Algorithm -- ^ Rate limiting algorithm for prefix+ -> Text -- ^ IP zone or service identifier (e.g., "api", "web", "zone1")+ -> Text -- ^ User key (e.g., API key, user ID, IP address)+ -> Text -- ^ Complete hierarchical cache key+makeCacheKey algo ipZone userKey = algorithmPrefix algo <> ":" <> ipZone <> ":" <> userKey
+ src/Keter/RateLimiter/CacheWithZone.hs view
@@ -0,0 +1,164 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE DataKinds #-}++{-|+Module : Keter.RateLimiter.CacheWithZone+Description : Helpers for cache access with zone-aware composite keys+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module provides utility functions for interacting with `Cache` instances+in a zone-aware manner. Each key is built from an algorithm-specific prefix,+an IP zone label, and a user-specific identifier. This allows different+logical groups (IP zones) to maintain independent rate limiting state even if+they share the same user identifiers.++These helpers wrap common operations — incrementing counters, reading or+writing values, or deleting entries — by automatically composing the correct+cache keys.++A convenience function, 'allowFixedWindowRequest', is provided to evaluate+requests using the Fixed Window rate-limiting strategy.++-}++module Keter.RateLimiter.CacheWithZone+ ( -- * General Cache Helpers+ incStoreWithZone+ , readCacheWithZone+ , writeCacheWithZone+ , deleteCacheWithZone+ -- * Fixed Window Rate Limiting+ , allowFixedWindowRequest+ ) where++import Data.Aeson (ToJSON, FromJSON)+import Data.Text (Text)+import Keter.RateLimiter.Cache++--------------------------------------------------------------------------------++-- | Attempt to allow a request under a Fixed Window rate-limiting strategy.+--+-- This increments the counter for the composed key built from the given+-- zone and user key. The request is allowed if the resulting count+-- does not exceed the limit for the given period.+--+-- == Parameters+--+-- [@cache@] The FixedWindow cache to use.+-- [@ipZone@] IP zone or logical tenant label.+-- [@userKey@] Unique per-client identifier (e.g., IP or token).+-- [@limit@] Maximum number of allowed requests in the given period.+-- [@period@] Time window in seconds.+--+-- == Returns+--+-- A boolean indicating whether the request is allowed.+--+-- == Example+--+-- > allowed <- allowFixedWindowRequest myCache "zone1" "userA" 10 60+allowFixedWindowRequest+ :: Cache (InMemoryStore 'FixedWindow)+ -- ^ FixedWindow-based cache handle+ -> Text+ -- ^ IP zone or tenant key+ -> Text+ -- ^ User key+ -> Int+ -- ^ Request limit+ -> Int+ -- ^ Period in seconds+ -> IO Bool+ -- ^ Whether the request is allowed+allowFixedWindowRequest cache ipZone userKey limit period = do+ count <- incStoreWithZone cache ipZone userKey period+ return $ count <= limit++--------------------------------------------------------------------------------++-- | Increment a cache entry using a composed key derived from the zone and user key.+--+-- This is typically used in fixed-window rate limiters to track the number+-- of requests within a time period.+--+-- == Requirements+--+-- The stored type @v@ must be JSON-serializable and support numeric operations.+incStoreWithZone+ :: (CacheStore store v IO, FromJSON v, ToJSON v, Num v, Ord v)+ => Cache store+ -- ^ Cache with algorithm-specific store+ -> Text+ -- ^ IP zone+ -> Text+ -- ^ User key+ -> Int+ -- ^ TTL (seconds)+ -> IO v+ -- ^ New counter value+incStoreWithZone cache ipZone userKey expiresIn = do+ let key = makeCacheKey (cacheAlgorithm cache) ipZone userKey+ prefix = algorithmPrefix (cacheAlgorithm cache)+ incStore (cacheStore cache) prefix key expiresIn++-- | Read a cache entry using a composed key.+--+-- Useful for debugging or non-mutating cache inspection.+readCacheWithZone+ :: (CacheStore store v IO)+ => Cache store+ -- ^ Cache handle+ -> Text+ -- ^ IP zone+ -> Text+ -- ^ User key+ -> IO (Maybe v)+ -- ^ Optional stored value+readCacheWithZone cache ipZone userKey = do+ let key = makeCacheKey (cacheAlgorithm cache) ipZone userKey+ prefix = algorithmPrefix (cacheAlgorithm cache)+ readStore (cacheStore cache) prefix key++-- | Write a value into the cache using a zone-aware key.+--+-- Typically used for manual updates, testing, or initializing state.+writeCacheWithZone+ :: (CacheStore store v IO)+ => Cache store+ -- ^ Cache handle+ -> Text+ -- ^ IP zone+ -> Text+ -- ^ User key+ -> v+ -- ^ Value to store+ -> Int+ -- ^ Expiration in seconds+ -> IO ()+writeCacheWithZone cache ipZone userKey val expiresIn = do+ let key = makeCacheKey (cacheAlgorithm cache) ipZone userKey+ prefix = algorithmPrefix (cacheAlgorithm cache)+ writeStore (cacheStore cache) prefix key val expiresIn++-- | Delete an entry from the cache using a zone-aware key.+--+-- Can be used to forcibly reset rate limiter state or clean up.+deleteCacheWithZone+ :: (CacheStore store v IO)+ => Cache store+ -- ^ Cache handle+ -> Text+ -- ^ IP zone+ -> Text+ -- ^ User key+ -> IO ()+deleteCacheWithZone cache ipZone userKey = do+ let key = makeCacheKey (cacheAlgorithm cache) ipZone userKey+ prefix = algorithmPrefix (cacheAlgorithm cache)+ deleteStore (cacheStore cache) prefix key
+ src/Keter/RateLimiter/IPZones.hs view
@@ -0,0 +1,176 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}++{-|+Module : Keter.RateLimiter.IPZones+Description : Management of caches specific to IP zones for rate limiting+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module provides zone-based isolation for rate-limiting caches. It enables+each IP zone to maintain its own independent instances of rate-limiting+algorithms (e.g., token bucket, leaky bucket, sliding window, etc.). This+ensures multi-tenant systems can rate-limit different clients or groups in+isolation.++The primary structure here is `ZoneSpecificCaches`, which contains multiple+caches per zone. Utility functions allow dynamic creation, reset, and+lookup of caches for specific zones.++-}++module Keter.RateLimiter.IPZones+ ( -- * IP Zone Identification+ IPZoneIdentifier+ , defaultIPZone+ -- * Zone-specific Caches+ , ZoneSpecificCaches(..)+ , createZoneCaches+ , newZoneSpecificCaches+ -- * Cache Management+ , resetSingleZoneCaches+ , resetZoneCache+ -- * Address to Zone Resolution+ , sockAddrToIPZone + ) where++import Data.Text (Text)+import qualified Data.Text as T+import Keter.RateLimiter.Cache+ ( Cache(..)+ , InMemoryStore(..)+ , newCache+ , createInMemoryStore+ , cacheReset+ , Algorithm(..)+ , startCustomPurgeLeakyBucket+ )+import Network.Socket (SockAddr(..))+import Data.IP (fromHostAddress)+import Numeric (showHex)+import Data.Bits+import Control.Concurrent.STM (newTVarIO, atomically, readTVar) +import qualified StmContainers.Map as StmMap++--------------------------------------------------------------------------------++-- | Type alias representing an identifier for an IP zone.+--+-- This is used as a logical namespace or grouping key for assigning and isolating rate limiters.+-- Examples: `"default"`, `"zone-a"`, or `"192.168.1.0/24"`.+type IPZoneIdentifier = Text++-- | The default IP zone identifier used when no specific zone is assigned.+--+-- Used as a fallback when no zone-specific routing is determined.+defaultIPZone :: IPZoneIdentifier+defaultIPZone = "default"++-- | A collection of caches dedicated to a specific IP zone.+--+-- Each cache corresponds to one of the supported rate-limiting algorithms,+-- maintained independently per zone.+data ZoneSpecificCaches = ZoneSpecificCaches+ { zscCounterCache :: Cache (InMemoryStore 'FixedWindow)+ -- ^ Cache for Fixed Window counters.+ , zscTimestampCache :: Cache (InMemoryStore 'SlidingWindow)+ -- ^ Cache for timestamp lists used in Sliding Window.+ , zscTokenBucketCache :: Cache (InMemoryStore 'TokenBucket)+ -- ^ Token Bucket cache.+ , zscLeakyBucketCache :: Cache (InMemoryStore 'LeakyBucket)+ -- ^ Leaky Bucket queue-based cache.+ , zscTinyLRUCache :: Cache (InMemoryStore 'TinyLRU)+ -- ^ Optional auxiliary LRU cache.+ }++-- | Create a new set of caches for a single IP zone.+--+-- Each algorithm receives its own store. For `LeakyBucket`, a background+-- cleanup thread is also started to remove inactive entries periodically.+--+-- == Example+--+-- > zoneCaches <- createZoneCaches+-- > cacheReset (zscTokenBucketCache zoneCaches)+createZoneCaches :: IO ZoneSpecificCaches+createZoneCaches = do+ counterStore <- createInMemoryStore @'FixedWindow+ slidingStore <- createInMemoryStore @'SlidingWindow+ tokenBucketStore <- createInMemoryStore @'TokenBucket+ leakyBucketTVar <- newTVarIO =<< atomically StmMap.new+ let leakyBucketStore = LeakyBucketStore leakyBucketTVar+ leakyBucketMap <- atomically $ readTVar leakyBucketTVar+ _ <- startCustomPurgeLeakyBucket+ leakyBucketMap+ (60 :: Integer) -- Purge interval (every 60 seconds)+ (7200 :: Integer) -- TTL (2 hours)+ tinyLRUStore <- createInMemoryStore @'TinyLRU+ return ZoneSpecificCaches+ { zscCounterCache = newCache FixedWindow counterStore+ , zscTimestampCache = newCache SlidingWindow slidingStore+ , zscTokenBucketCache = newCache TokenBucket tokenBucketStore+ , zscLeakyBucketCache = newCache LeakyBucket leakyBucketStore+ , zscTinyLRUCache = newCache TinyLRU tinyLRUStore+ }++-- | Alias for 'createZoneCaches'.+--+-- Useful for more readable builder-based usage or factory patterns.+newZoneSpecificCaches :: IO ZoneSpecificCaches+newZoneSpecificCaches = createZoneCaches++-- | Reset all caches within the given 'ZoneSpecificCaches'.+--+-- Clears all internal state, including token counts, timestamps, and queues.+--+-- == Example+--+-- > resetSingleZoneCaches zoneCaches+resetSingleZoneCaches :: ZoneSpecificCaches -> IO ()+resetSingleZoneCaches zsc = do+ cacheReset (zscCounterCache zsc)+ cacheReset (zscTimestampCache zsc)+ cacheReset (zscTokenBucketCache zsc)+ cacheReset (zscLeakyBucketCache zsc)+ cacheReset (zscTinyLRUCache zsc)++-- | Reset a single cache for a specific algorithm within the given 'ZoneSpecificCaches'.+--+-- This is useful when only one type of rate limiter needs a reset.+--+-- == Example+--+-- > resetZoneCache zoneCaches TokenBucket+resetZoneCache :: ZoneSpecificCaches -> Algorithm -> IO ()+resetZoneCache zsc algorithm = case algorithm of+ FixedWindow -> cacheReset (zscCounterCache zsc)+ SlidingWindow -> cacheReset (zscTimestampCache zsc)+ TokenBucket -> cacheReset (zscTokenBucketCache zsc)+ LeakyBucket -> cacheReset (zscLeakyBucketCache zsc)+ TinyLRU -> cacheReset (zscTinyLRUCache zsc)++-- | Convert a socket address into an IP zone identifier.+--+-- IPv4 addresses are rendered using `fromHostAddress`. IPv6 addresses are+-- expanded and zero-padded for consistency. Any unknown or unsupported+-- address formats fall back to the 'defaultIPZone'.+--+-- == Example+--+-- > zone <- sockAddrToIPZone (SockAddrInet 80 0x7f000001)+-- > print zone -- "127.0.0.1"+sockAddrToIPZone :: SockAddr -> IO Text+sockAddrToIPZone (SockAddrInet _ hostAddr) = do+ let ip = fromHostAddress hostAddr+ return $ T.pack $ show ip+sockAddrToIPZone (SockAddrInet6 _ _ (w1, w2, w3, w4) _) = + return $ T.intercalate ":" $ map (T.pack . showHexWord) + [w1 `shiftR` 16, w1 .&. 0xFFFF, w2 `shiftR` 16, w2 .&. 0xFFFF, + w3 `shiftR` 16, w3 .&. 0xFFFF, w4 `shiftR` 16, w4 .&. 0xFFFF]+ where+ showHexWord n = let s = showHex n "" in if length s < 4 then replicate (4 - length s) '0' ++ s else s+sockAddrToIPZone _ = return "default"
+ src/Keter/RateLimiter/LeakyBucket.hs view
@@ -0,0 +1,124 @@+-- file: Keter.RateLimiter.LeakyBucket.hs++{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}++{-|+Module : Keter.RateLimiter.LeakyBucket+Description : Leaky bucket rate limiting algorithm implementation+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module implements the core logic for the Leaky Bucket rate-limiting algorithm. The primary goal of this algorithm is to smooth out bursts of requests into a steady, predictable flow. It is conceptually similar to a bucket with a hole in the bottom.++Incoming requests are like water being added to the bucket. The bucket has a finite 'capacity'. If a request arrives when the bucket is full, it is rejected (it "spills over"). The hole in the bucket allows requests to be processed (or "leak out") at a constant 'leakRate'.++This implementation uses a dedicated worker thread for each bucket (e.g., for each unique user or IP address) to process requests from a queue. This ensures that processing is serialized and state is managed safely under concurrent access. The first request for a given key will spawn the worker, which then serves all subsequent requests for that same key.+-}+module Keter.RateLimiter.LeakyBucket+ ( -- * Algorithm Logic+ allowRequest+ ) where++import Control.Concurrent.STM+import Control.Monad (when)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Text (Text)+import Data.Time.Clock.POSIX (getPOSIXTime)+import Keter.RateLimiter.Types (LeakyBucketState(..))+import Keter.RateLimiter.Cache+import Keter.RateLimiter.AutoPurge (LeakyBucketEntry(..))+import qualified Focus as F+import qualified StmContainers.Map as StmMap++-- | Determines whether a request should be allowed based on the state of its corresponding leaky bucket.+--+-- This function is the primary entry point for the leaky bucket algorithm. When a request arrives,+-- this function is called to check if it should be processed or rejected. It operates by finding+-- or creating a bucket associated with the user's key. Every request is added to a queue for its bucket.+--+-- A dedicated worker thread is responsible for processing the queue for each bucket. This function ensures+-- that a worker is started for a new bucket but avoids starting duplicate workers. The final result+-- (`True` or `False`) is communicated back to the caller once the worker has processed the request.+--+-- ==== __Example__+--+-- > {-# LANGUAGE OverloadedStrings #-}+-- > import Keter.RateLimiter.LeakyBucket (allowRequest)+-- > import Keter.RateLimiter.Cache+-- >+-- > main :: IO ()+-- > main = do+-- > -- Initialize the cache store for the LeakyBucket algorithm.+-- > leakyStore <- createStore @'LeakyBucket+-- > let cache = newCache LeakyBucket leakyStore+-- >+-- > -- Simulate a request for a user from a specific IP zone.+-- > -- Capacity: 10 requests (burst limit)+-- > -- Leak Rate: 1 request per second+-- > isAllowed <- allowRequest cache "us-east-1" "user-123" 10 1.0+-- >+-- > if isAllowed+-- > then putStrLn "Request is allowed."+-- > else putStrLn "Request is blocked (429 Too Many Requests)."+--+allowRequest+ :: MonadIO m+ => Cache (InMemoryStore 'LeakyBucket)+ -- ^ The cache containing the state of all leaky buckets.+ -> Text+ -- ^ The IP-based zone for segmenting the cache (e.g., "eu-central-1").+ -> Text+ -- ^ A unique key identifying the user or client (e.g., an IP address or API key).+ -> Int+ -- ^ The bucket's capacity (the maximum burst size). If a request arrives when the bucket is full, it will be rejected.+ -> Double+ -- ^ The rate at which the bucket "leaks" (in requests per second). This determines the sustained throughput.+ -> m Bool+ -- ^ Returns 'True' if the request is allowed, 'False' otherwise.+allowRequest cache ipZone userKey capacity leakRate = liftIO $ do+ -- Immediately reject if capacity is non-positive, as it's an invalid configuration.+ if capacity <= 0 then pure False else do+ now <- realToFrac <$> getPOSIXTime+ let fullKey = makeCacheKey LeakyBucket ipZone userKey+ LeakyBucketStore tvBuckets = cacheStore cache+ replyVar <- newEmptyTMVarIO++ -- Create a template for a new bucket entry. Its state is neutral (level 0).+ newEntry <- createLeakyBucketEntry (LeakyBucketState 0 now)++ -- Atomically get the existing bucket for the key or create a new one.+ -- This 'focus' operation provides an atomic get-or-create pattern.+ entry <- atomically $ do+ buckets <- readTVar tvBuckets+ StmMap.focus+ -- If the key is missing, create and insert the newEntry.+ (F.cases (newEntry, F.Set newEntry)+ -- If the key exists, just return the existing entry.+ (\existing -> (existing, F.Leave)))+ fullKey buckets++ -- All requests, new or existing, queue up to be processed by the worker.+ -- This serializes access to the bucket's state.+ atomically $ writeTQueue (lbeQueue entry) replyVar++ -- The first request to arrive will acquire the lock and start the worker.+ -- Subsequent requests for the same bucket won't be able to acquire the lock+ -- and will simply wait for the running worker to process them.+ started <- atomically $ tryPutTMVar (lbeWorkerLock entry) ()+ when started $+ startLeakyBucketWorker+ (lbeState entry)+ (lbeQueue entry)+ capacity+ leakRate++ -- All callers block here, waiting for the worker to process their specific+ -- request and provide a boolean result in the reply variable.+ atomically $ takeTMVar replyVar
+ src/Keter/RateLimiter/Notifications.hs view
@@ -0,0 +1,514 @@+{-# LANGUAGE OverloadedStrings, RecordWildCards #-}++{-|+Module : Keter.RateLimiter.Notifications+Description : Notification system for rate limiting events+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++Copyright (c) 2025 Oleksandr Zhabenko+ +This file is a ported to Haskell language code with some simplifications of Ruby on Rails+https://github.com/rails/rails +https://github.com/rails/rails/blob/2318163b4b9e9604b557057c0465e2a5ef162401/activesupport/lib/active_support/notifications.rb+and is based on the structure of the original code of +rack-attack, Copyright (c) David Heinemeier Hansson, under the MIT License.++This implementation is released under the MIT License.++= Overview++The @Notifications@ module supplies a **pluggable notification layer** for the+rate-limiting parts of the /keter-rate-limiting-plugin/ project. Whenever a throttle+decides to reject or allow a request, you may want to:++* store an audit trail,+* emit a metric,+* send an e-mail / Slack message, or+* perform any other side effect.++The abstraction is intentionally minimal yet flexible:++* 'Notifier' – a "generic" notifier that works with arbitrary data (converted+ to a textual representation by the caller).+* 'WAINotifier' – a convenience type alias specialised for WAI 'Network.Wai.Request' objects.++Both flavours come with:++* "do-nothing" implementations ('noopNotifier', 'noopWAINotifier') for easy disabling or testing, and+* simple console loggers ('consoleNotifier', 'consoleWAINotifier') for straightforward debugging.++You can easily lift these notifiers into your favourite effect stack by wrapping the+'IO' action with a natural transformation (e.g., @liftIO@ for any 'Control.Monad.IO.Class.MonadIO').++== Quick example++@+{-# LANGUAGE OverloadedStrings #-}+import Keter.RateLimiter.Notifications+import Network.Wai+import Network.Socket+import Network.Wai.Handler.Warp (run)+import Network.HTTP.Types (status429)+import Control.Monad.IO.Class (liftIO)++-- A hypothetical function called when a client hits a rate limit.+blocked :: Request -> IO Response+blocked req = do+ -- Log the event using the predefined WAI console notifier.+ -- "per-ip" is the logical name of our throttle.+ -- 100 is the limit that was exceeded.+ consoleWAINotifier "per-ip" req 100+ pure $ responseLBS status429 [] "Too Many Requests"++-- A simple WAI application.+app :: Application+app req respond = do+ -- In a real application, you would have logic here to check the rate limit.+ -- If the limit is exceeded, call 'blocked'.+ let isBlocked = False -- Placeholder for actual rate-limiting logic.+ if isBlocked+ then blocked req >>= respond+ else respond $ responseLBS status200 [] "OK"++main :: IO ()+main = run 8080 app+@++== Advanced usage patterns++=== Custom notifiers++You can create custom notifiers for integration with external systems:++@+import qualified Data.Text.IO as TIO+import Control.Exception (try, SomeException)++-- A notifier that attempts to write to a file, falling back to console on error+fileNotifier :: FilePath -> Notifier+fileNotifier path = Notifier+ { notifierName = "file-logger"+ , notifierAction = \\throttle act item limit -> do+ now <- getCurrentTime+ let timestamp = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" now+ parts = [throttle, act, item, T.concat ["(limit: ", T.pack (show limit), ")"]]+ message = T.intercalate " " $ filter (not . T.null) parts+ fullMessage = T.concat [T.pack timestamp, " - ", message, "\\n"]+ + result <- try $ TIO.appendFile path fullMessage+ case result of+ Left (_ :: SomeException) -> + -- Fallback to console logging+ notifierAction consoleNotifier throttle act item limit+ Right _ -> pure ()+ }+@++=== Combining notifiers++Multiple notifiers can be combined to send notifications to different destinations:++@+multiNotifier :: [Notifier] -> Notifier+multiNotifier notifiers = Notifier+ { notifierName = "multi"+ , notifierAction = \\throttle act item limit ->+ mapM_ (\\n -> notifierAction n throttle act item limit) notifiers+ }++-- Usage example+combinedNotifier = multiNotifier [consoleNotifier, fileNotifier "/var/log/rate-limits.log"]+@++=== Converting between notifier types++The 'waiNotifier' function allows you to use any generic 'Notifier' with WAI requests:++@+-- Use a custom notifier with WAI+myWAINotifier :: WAINotifier+myWAINotifier = waiNotifier (fileNotifier "/var/log/wai-rate-limits.log")+@++== Log format specifications++=== Generic notifier format++The 'consoleNotifier' produces log entries in the following format:++@+YYYY-MM-DD HH:MM:SS - throttleName action item (limit: N)+@++Examples:+@+2025-01-30 13:45:12 - loginAttempts blocked "user123" (limit: 5)+2025-01-30 13:45:13 - api-global blocked "192.168.1.100" (limit: 1000)+@++Note that textual items are quoted due to the use of 'show' for conversion.++=== WAI notifier format++The 'consoleWAINotifier' produces more detailed log entries specifically for HTTP requests:++@+YYYY-MM-DD HH:MM:SS - throttleName blocked METHOD PATH[?QUERY] from IP:PORT (limit: N)+@++Examples:+@+2025-01-30 13:45:12 - api-global blocked GET /v1/users?id=123 from 192.0.2.1:54321 (limit: 1000)+2025-01-30 13:45:13 - auth blocked POST /login from 10.0.0.1:443 (limit: 10)+2025-01-30 13:45:14 - resource blocked DELETE /resource/123 from 127.0.0.1:9000 (limit: 50)+@++=== Request conversion details++The 'convertWAIRequest' function creates a compact representation of WAI requests:++* **IPv4 addresses**: @192.168.1.100:8080@+* **IPv6 addresses**: @2001:0db8:0000:0000:0000:0000:0000:0001:443@+* **Unix sockets**: Port information is omitted+* **Empty query strings**: Only the path is included+* **Empty throttle names**: The throttle name is simply omitted from the output++== Thread-safety++All predefined notifiers are **thread-safe** because they only use atomic+functions from the @base@ and @text@ packages. The timestamp generation using+'getCurrentTime' is also thread-safe.++If you write a custom notifier that has internal mutable state (such as counters,+caches, or connection pools), you must ensure its operations are properly synchronized+using appropriate concurrency primitives like 'Control.Concurrent.STM.STM',+'Control.Concurrent.MVar.MVar', or 'Data.IORef.IORef' with atomic operations.++== Error handling considerations++The predefined notifiers ('consoleNotifier', 'consoleWAINotifier') do not handle+IO exceptions that might occur during logging operations. In production environments,+you may want to wrap these notifiers with appropriate error handling:++@+safeNotifier :: Notifier -> Notifier+safeNotifier baseNotifier = baseNotifier+ { notifierAction = \\throttle act item limit -> do+ result <- try $ notifierAction baseNotifier throttle act item limit+ case result of+ Left (_ :: SomeException) -> + -- Log the error or use a fallback strategy+ pure ()+ Right _ -> pure ()+ }+@++== Performance considerations++* The 'convertWAIRequest' function uses 'unsafePerformIO' internally for IP resolution.+ This is marked with @NOINLINE@ to prevent duplication and ensure predictable behavior.+ +* Timestamp generation occurs for every notification, which involves system calls.+ For high-throughput applications, consider batching notifications or using+ asynchronous logging mechanisms.++* The console notifiers write to 'stdout' synchronously, which may become a bottleneck+ under heavy load. Consider using buffered or asynchronous alternatives for+ production deployments.++== Testing support++The module is designed with testability in mind:++* 'noopNotifier' and 'noopWAINotifier' can be used in test environments to suppress output+* The modular design allows easy substitution of test doubles+* All notification functions are pure except for the final IO action, making them+ easy to test with custom capture mechanisms++Example test notifier:++@+createTestNotifier :: IORef [Text] -> Notifier+createTestNotifier outputRef = Notifier+ { notifierName = "test"+ , notifierAction = \\throttle act item limit -> do+ now <- getCurrentTime+ let message = -- format message as needed+ modifyIORef' outputRef (message :)+ }+@++-}++module Keter.RateLimiter.Notifications+ ( -- * Types+ Notifier(..)+ , WAINotifier++ -- * Generic notifier helpers+ , notify+ , noopNotifier+ , consoleNotifier++ -- * WAI-specific helpers+ , notifyWAI+ , waiNotifier+ , convertWAIRequest+ , noopWAINotifier+ , consoleWAINotifier+ ) where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Data.Time.Clock (getCurrentTime)+import Data.Time.Format (defaultTimeLocale, formatTime)+import Network.Wai (Request)+import qualified Network.Wai as Wai+import qualified Data.Text.Encoding as TE+import Network.Socket (SockAddr(..))+import Keter.RateLimiter.RequestUtils+ ( getClientIP+ , getRequestMethod+ , getRequestPath )+import System.IO.Unsafe (unsafePerformIO)++----------------------------------------------------------------------+-- Types+----------------------------------------------------------------------++-- | A generic, abstract notifier.+--+-- It consists of a name for identification and an 'IO' action that performs+-- the notification. The action receives all the necessary context to log or+-- process a rate-limiting event.+--+-- The notifier is designed to be composable and can be easily integrated+-- into different effect systems by lifting the 'IO' action appropriately.+data Notifier = Notifier+ { notifierName :: Text+ -- ^ A human-readable name for the notifier (e.g., "console", "prometheus", "file-logger").+ -- This can be used for debugging, logging, or configuration purposes.+ , notifierAction :: Text -- ^ throttleName: Logical name of the limiter (e.g. "login-attempt", "api-global").+ -> Text -- ^ action: A free-form verb, typically "blocked" or "allowed".+ -> Text -- ^ item: A textual representation of whatever was rate-limited.+ -> Int -- ^ limit: The numeric limit that triggered the event.+ -> IO ()+ -- ^ The action to be executed upon a notification trigger.+ -- This action should be thread-safe if the notifier will be used concurrently.+ }++-- | A type alias for a notifier specialized for WAI 'Request's.+--+-- This simplifies the signature for notifiers that work directly with WAI,+-- avoiding the need to manually convert the 'Request' to 'Text' beforehand.+-- The conversion is handled internally using 'convertWAIRequest'.+type WAINotifier = Text -- ^ throttleName: Logical name of the limiter.+ -> Request -- ^ The WAI 'Request' that triggered the event.+ -> Int -- ^ The numeric limit that was applied.+ -> IO ()++----------------------------------------------------------------------+-- Generic helpers+----------------------------------------------------------------------++-- | A high-level wrapper to trigger a 'Notifier'.+--+-- It simplifies calling a notifier by using a fixed action verb (@"blocked"@)+-- and automatically converting the rate-limited item to 'Text' via its 'Show'+-- instance. Note that using 'show' on a 'Text' or 'String' value will add+-- quotes around it in the output.+--+-- ==== __Examples__+--+-- @+-- -- Assuming 'consoleNotifier' is defined as in this module.+-- notify consoleNotifier "login-per-ip" ("192.0.2.1" :: Text) 20+-- -- This would log: ... blocked "192.0.2.1" ...+--+-- notify consoleNotifier "api-requests" (42 :: Int) 100+-- -- This would log: ... blocked 42 ...+--+-- -- With empty throttle names:+-- notify consoleNotifier "" ("item" :: Text) 50+-- -- This would log: ... blocked "item" ...+-- @+notify :: Show req -- ^ The item being rate-limited, must be 'Show'able.+ => Notifier+ -> Text -- ^ The logical name of the throttle.+ -> req -- ^ The item itself.+ -> Int -- ^ The limit that was applied.+ -> IO ()+notify Notifier{..} throttleName req limit =+ notifierAction throttleName "blocked" (T.pack (show req)) limit++-- | A trivial 'Notifier' that performs no action.+--+-- This is useful in tests, for disabling notifications in certain environments,+-- or as a default value. It completes immediately without any side effects.+--+-- ==== __Example usage__+--+-- @+-- -- Disable notifications in test environment+-- let notifier = if inTestMode then noopNotifier else consoleNotifier+-- notify notifier "test-throttle" ("data" :: Text) 100+-- @+noopNotifier :: Notifier+noopNotifier = Notifier+ { notifierName = "noop"+ , notifierAction = \_ _ _ _ -> pure ()+ }++-- | A 'Notifier' that logs events to standard output ('stdout').+--+-- The log format is a single, timestamped line with the following structure:+-- @YYYY-MM-DD HH:MM:SS - throttleName action item (limit: N)@+--+-- Empty throttle names are handled gracefully by omitting them from the output.+-- The item is rendered using 'show', which will add quotes for textual types.+--+-- ==== __Example output__+--+-- @+-- 2025-01-30 13:45:12 - login-per-ip blocked "192.0.2.1" (limit: 20)+-- 2025-01-30 13:45:13 - blocked "item" (limit: 100)+-- @+--+-- The notifier is thread-safe as it only uses atomic IO operations.+consoleNotifier :: Notifier+consoleNotifier = Notifier+ { notifierName = "console"+ , notifierAction = \throttle act item limit -> do+ now <- getCurrentTime+ let ts = fmt now+ -- Create a list of the components for the message.+ parts = [throttle, act, item, T.concat ["(limit: ", T.pack (show limit), ")"]]+ -- Filter out any empty strings and join the rest with a single space.+ message = T.intercalate " " $ filter (not . T.null) parts+ TIO.putStrLn $ T.concat [ts, " - ", message]+ }+ where+ fmt = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S"++----------------------------------------------------------------------+-- WAI-specific helpers+----------------------------------------------------------------------++-- | A high-level wrapper to trigger a 'WAINotifier'.+--+-- This is a thin alias, defined for symmetry with 'notify'. It simply calls+-- the underlying 'WAINotifier' function directly.+--+-- ==== __Example__+--+-- @+-- notifyWAI consoleWAINotifier "api-throttle" request 100+-- @+notifyWAI :: WAINotifier -> Text -> Request -> Int -> IO ()+notifyWAI = id++-- | Lifts a generic 'Notifier' into the WAI-specific domain, creating a 'WAINotifier'.+--+-- It works by using 'convertWAIRequest' to transform the 'Request' object into a+-- summary 'Text' before passing it to the underlying 'Notifier'\'s action.+-- The action verb is fixed to "blocked".+--+-- ==== __Example__+--+-- @+-- let myWAINotifier = waiNotifier consoleNotifier+-- myWAINotifier "auth" request 10+-- -- Output: YYYY-MM-DD HH:MM:SS - auth blocked GET /login from 10.0.0.1:443 (limit: 10)+-- @+waiNotifier :: Notifier -> WAINotifier+waiNotifier Notifier{..} throttleName req limit =+ notifierAction throttleName "blocked" (convertWAIRequest req) limit++-- | Converts a WAI 'Request' to a compact, single-line textual representation.+--+-- The output format is: @METHOD PATH[?QUERY] from IP:PORT@+--+-- Port handling:+-- * For IPv4 and IPv6 addresses: includes the port number+-- * For Unix sockets or unknown socket types: omits the port+--+-- Query string handling:+-- * Non-empty query strings: included with the leading '?'+-- * Empty query strings: omitted entirely+--+-- ==== __Examples__+--+-- @+-- GET /index.html?lang=en from 127.0.0.1:8080+-- POST /login from 10.0.0.1:443+-- DELETE /resource/123 from 127.0.0.1:9000+-- GET /api/users?limit=10 from 2001:0db8:0000:0000:0000:0000:0000:0001:443+-- @+--+-- This function uses 'unsafePerformIO' internally to resolve the client's IP+-- address via 'getClientIP'. This is considered acceptable for logging and+-- notification purposes where the function's primary role is to produce a+-- human-readable summary and performance is not critically impacted by a minor,+-- contained impurity. The 'NOINLINE' pragma is used to prevent the IO action+-- from being duplicated by compiler optimizations.+convertWAIRequest :: Request -> Text+convertWAIRequest req =+ let method = getRequestMethod req+ path = getRequestPath req+ query = TE.decodeUtf8 $ Wai.rawQueryString req+ clientIP = unsafePerformIO $ getClientIP req+ portStr = case Wai.remoteHost req of+ SockAddrInet port _ -> ":" <> T.pack (show port)+ SockAddrInet6 port _ _ _ -> ":" <> T.pack (show port)+ _ -> "" -- Unix sockets or unknown; omit port+ in method <> " " <> path <> query <> " from " <> clientIP <> portStr+{-# NOINLINE convertWAIRequest #-}+ -- NOINLINE is important due to the use of unsafePerformIO,+ -- ensuring the IO action is not inadvertently duplicated.++-- | A 'WAINotifier' that performs no action.+--+-- This is the WAI-specific equivalent of 'noopNotifier'. It completes+-- immediately without any side effects and is useful for testing or+-- disabling WAI-specific notifications.+--+-- ==== __Example usage__+--+-- @+-- -- Use in test environments or to disable logging+-- let notifier = if enableLogging then consoleWAINotifier else noopWAINotifier+-- notifier "test-throttle" request 100+-- @+noopWAINotifier :: WAINotifier+noopWAINotifier _ _ _ = pure ()++-- | A 'WAINotifier' that logs formatted request data to 'stdout'.+--+-- The log format includes timestamp, throttle name, action ("blocked"),+-- formatted request information, and the limit that was exceeded:+--+-- @YYYY-MM-DD HH:MM:SS - throttleName blocked METHOD PATH[?QUERY] from IP:PORT (limit: N)@+--+-- ==== __Example output__+--+-- @+-- 2025-01-30 13:45:12 - api-global blocked GET /v1/users?id=123 from 192.0.2.1:54321 (limit: 1000)+-- 2025-01-30 13:45:13 - auth blocked POST /login from 10.0.0.1:443 (limit: 10)+-- 2025-01-30 13:45:14 - resource blocked DELETE /resource/123 from 127.0.0.1:9000 (limit: 50)+-- @+--+-- The notifier is thread-safe and handles various request types including+-- IPv4, IPv6, and Unix socket connections appropriately.+consoleWAINotifier :: WAINotifier+consoleWAINotifier throttleName req limit = do+ now <- getCurrentTime+ let timestamp = formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" now+ requestInfo = convertWAIRequest req+ TIO.putStrLn $+ T.pack timestamp <> " - " <> throttleName <> " blocked " <>+ requestInfo <> " (limit: " <> T.pack (show limit) <> ")"
+ src/Keter/RateLimiter/RequestUtils.hs view
@@ -0,0 +1,248 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Module : Keter.RateLimiter.RequestUtils+Description : Utility functions for extracting data from WAI requests+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++Utility helpers for extracting /stable textual keys/ from a WAI+'Network.Wai.Request'. They are primarily intended for use with+rate-limiting middleware (see the @keter-rate-limiting-plugin@ package) but are fully+generic and can be employed anywhere you need a deterministic identifier that+ties a request to its origin (IP address, path, user-agent, …).++The helpers follow these rules:++1. /Zero/ allocation whenever the value is already available in the request+ record (e.g. @rawPathInfo@ or @requestMethod@ are reused verbatim).+2. No reverse DNS or other network round-trips – the functions are pure and+ fast.+3. Header names are handled case-insensitively via the+ 'Data.CaseInsensitive.CI' type.++== Quick example++@+{-# LANGUAGE OverloadedStrings #-}+import Control.Monad.IO.Class (liftIO)+import Network.Wai+import Network.Wai.Handler.Warp (run)+import Keter.RateLimiter.RequestUtils (byIPAndPath)+import Data.Text.IO as TIO++logKey :: Request -> IO ()+logKey req = do+ mk <- byIPAndPath req+ case mk of+ Nothing -> TIO.putStrLn "cannot build key"+ Just key -> TIO.putStrLn ("request key = " <> key)++app :: Application+app req respond = liftIO (logKey req) >> respond (responseLBS status200 [] "OK")++main :: IO ()+main = run 8080 app+@++== Converting sockets to text++Functions 'ipv4ToString' and 'ipv6ToString' perform a /lossless/ conversion of+binary socket addresses to their canonical textual representations. The+implementation is intentionally simple and does not attempt to compress IPv6+zeros (you get four-hextet groups padded to 4 digits).++-}++module Keter.RateLimiter.RequestUtils+ ( -- * Low-level helpers+ ipv4ToString+ , ipv6ToString++ -- * Basic request information+ , getClientIP+ , getRequestPath+ , getRequestMethod+ , getRequestHost+ , getRequestUserAgent++ -- * Composite key builders+ , byIP+ , byIPAndPath+ , byIPAndUserAgent+ , byHeaderAndIP+ ) where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Network.Wai (Request)+import qualified Network.Wai as WAI+import Network.Socket+ ( SockAddr(..)+ , HostAddress+ , HostAddress6+ , hostAddressToTuple+ )+import Network.HTTP.Types.Header (HeaderName)+import Data.Bits ((.&.), shiftR)+import Data.CaseInsensitive (mk)+import Numeric (showHex)++----------------------------------------------------------------------+-- Low-level helpers+----------------------------------------------------------------------++-- | Convert an IPv4 'HostAddress' to dotted-decimal 'Text'.+--+-- ==== __Example__+--+-- >>> ipv4ToString 0x7f000001 -- 127.0.0.1+-- "127.0.0.1"+ipv4ToString :: HostAddress -> Text+ipv4ToString addr =+ let (o1, o2, o3, o4) = hostAddressToTuple addr+ in T.intercalate "." (map (T.pack . show) [o1, o2, o3, o4])++-- | Render an IPv6 'HostAddress6' as eight 16-bit hex blocks separated+-- by ‘:’. Each block is zero-padded to four characters. This rendering+-- is canonical but not compressed (e.g., it does not use @::@).+--+-- The function is micro-optimised to avoid lists and string formatting functions.+--+-- ==== __Example__+--+-- >>> ipv6ToString (0,0,0,1)+-- "0000:0000:0000:0000:0000:0000:0000:0001"+ipv6ToString :: HostAddress6 -> Text+ipv6ToString (w1, w2, w3, w4) =+ T.intercalate ":" $ map (T.pack . pad4 . (`showHex` "")) words16+ where+ words16 =+ [ w1 `shiftR` 16, w1 .&. 0xFFFF+ , w2 `shiftR` 16, w2 .&. 0xFFFF+ , w3 `shiftR` 16, w3 .&. 0xFFFF+ , w4 `shiftR` 16, w4 .&. 0xFFFF+ ]+ pad4 s = replicate (4 - length s) '0' ++ s++----------------------------------------------------------------------+-- Basic request information+----------------------------------------------------------------------++-- | Best-effort client IP address detection.+--+-- This function attempts to find the most accurate client IP address by checking+-- common proxy headers first, falling back to the direct socket address if they+-- are not present.+--+-- The priority order for detection is:+--+-- 1. @X-Forwarded-For@ (takes the first IP in the comma-separated list).+-- 2. @X-Real-Ip@.+-- 3. The 'Network.Wai.remoteHost' from the WAI 'Request' object.+--+-- Header names are matched case-insensitively. IPv4 and IPv6 addresses are+-- converted to text using 'ipv4ToString' and 'ipv6ToString' respectively.+-- Unix sockets are represented by their file path.+getClientIP :: Request -> IO Text+getClientIP req = do+ let ipTxt = case lookup (mk "x-forwarded-for") (WAI.requestHeaders req) of+ Just xff -> T.takeWhile (/= ',') $ TE.decodeUtf8 xff+ Nothing -> case lookup (mk "x-real-ip") (WAI.requestHeaders req) of+ Just rip -> TE.decodeUtf8 rip+ Nothing -> case WAI.remoteHost req of+ SockAddrInet _ addr -> ipv4ToString addr+ SockAddrInet6 _ _ addr _ -> ipv6ToString addr+ SockAddrUnix path -> T.pack path+ pure ipTxt++-- | Extracts the raw path info from the request and decodes it as UTF-8 'Text'.+-- This is equivalent to @'TE.decodeUtf8' . 'WAI.rawPathInfo'@.+getRequestPath :: Request -> Text+getRequestPath = TE.decodeUtf8 . WAI.rawPathInfo++-- | Extracts the HTTP request method (e.g., @"GET"@, @"POST"@) and returns it as a 'Text' value.+-- This is equivalent to @'TE.decodeUtf8' . 'WAI.requestMethod'@.+getRequestMethod :: Request -> Text+getRequestMethod = TE.decodeUtf8 . WAI.requestMethod++-- | Extracts the value of the @Host@ header, if present.+getRequestHost :: Request -> Maybe Text+getRequestHost = fmap TE.decodeUtf8 . WAI.requestHeaderHost++-- | Extracts the value of the @User-Agent@ header, if present.+getRequestUserAgent :: Request -> Maybe Text+getRequestUserAgent = fmap TE.decodeUtf8 . WAI.requestHeaderUserAgent++----------------------------------------------------------------------+-- Composite key builders+----------------------------------------------------------------------++-- | Creates a request key based solely on the client's IP address.+--+-- The result is always 'Just' a value, as an IP or equivalent is always available.+--+-- ==== __Example__+--+-- @+-- byIP req ⇨ pure (Just "127.0.0.1")+-- @+byIP :: Request -> IO (Maybe Text)+byIP req = Just <$> getClientIP req++-- | Creates a composite key by combining the client IP and the request path,+-- separated by a colon.+--+-- This is useful for rate-limiting access to specific endpoints rather than+-- penalizing a client for all of its requests.+--+-- ==== __Example__+--+-- @+-- -- For a request to \/api\/v1\/users from 192.168.1.10+-- byIPAndPath req ⇨ pure (Just "192.168.1.10:\/api\/v1\/users")+-- @+byIPAndPath :: Request -> IO (Maybe Text)+byIPAndPath req = do+ ip <- getClientIP req+ pure . Just $ ip <> ":" <> getRequestPath req++-- | Creates a composite key by combining the client IP and the @User-Agent@+-- header, separated by a colon.+--+-- Returns 'Nothing' if the @User-Agent@ header is not present in the request.+--+-- ==== __Example__+--+-- @+-- -- For a request from Googlebot at 8.8.8.8+-- byIPAndUserAgent req ⇨ pure (Just "8.8.8.8:Mozilla\/5.0 (compatible; Googlebot\/2.1)")+-- @+byIPAndUserAgent :: Request -> IO (Maybe Text)+byIPAndUserAgent req = do+ ip <- getClientIP req+ pure $ case getRequestUserAgent req of+ Nothing -> Nothing+ Just ua -> Just (ip <> ":" <> ua)++-- | Builds a key from an arbitrary header and the client IP, joined by a colon.+--+-- Header lookup is case-insensitive. Returns 'Nothing' if the header is absent.+--+-- ==== __Example__+--+-- This can be used to rate-limit based on an API key plus the user's IP.+--+-- @+-- -- Given a request with header "X-Api-Key: mysecret" from 1.2.3.4+-- byHeaderAndIP "x-api-key" req ⇨ pure (Just "1.2.3.4:mysecret")+-- @+byHeaderAndIP :: HeaderName -> Request -> IO (Maybe Text)+byHeaderAndIP headerName req = do+ ip <- getClientIP req+ let mVal = lookup headerName (WAI.requestHeaders req)+ pure $ fmap (\hv -> ip <> ":" <> TE.decodeUtf8 hv) mVal
+ src/Keter/RateLimiter/SlidingWindow.hs view
@@ -0,0 +1,139 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}++-- |+-- Module : Keter.RateLimiter.SlidingWindow+-- Description : Sliding window rate limiting algorithm implementation+-- Copyright : (c) 2025 Oleksandr Zhabenko+-- License : MIT+-- Maintainer : oleksandr.zhabenko@yahoo.com+-- Stability : stable+-- Portability : portable+--+-- This module provides an implementation of the /Sliding Window Counter/+-- algorithm for rate limiting. It is implemented using STM primitives and+-- integrates with the `Keter.RateLimiter.Cache` key structure.+--+-- A sliding window allows a fixed number of requests within a moving time+-- interval (window). It tracks individual request timestamps and filters them+-- to only keep those within the defined time window. This allows for+-- fine-grained request control and smooth rate-limiting behavior.+--+-- == Example usage+--+-- > let getTimeNow = realToFrac <$> getPOSIXTime+-- > result <- allowRequest getTimeNow myMap "zone1" "user42" 60 100+-- > when result (putStrLn "Request allowed")+--+-- This example checks whether "user42" from "zone1" can make a request,+-- allowing up to 100 requests in a 60-second window.++module Keter.RateLimiter.SlidingWindow+ ( -- * Sliding Window Rate Limiting+ allowRequest+ ) where++import Keter.RateLimiter.Cache (makeCacheKey, Algorithm(SlidingWindow))+import Data.Text (Text)+import Control.Concurrent.STM+import qualified StmContainers.Map as StmMap+import qualified Focus++--------------------------------------------------------------------------------++-- | Check whether a request is allowed under the sliding window policy.+--+-- This function implements a time-based sliding window algorithm. Each client+-- is associated with a list of timestamps representing past allowed requests.+-- If the number of timestamps in the current time window exceeds the limit,+-- the request is denied.+--+-- This version is /thread-safe/ and uses STM to avoid race conditions under+-- concurrency. The timestamp list is updated atomically using `StmMap.focus`.+--+-- == Parameters+--+-- [@getTimeNow@] Function that returns the current time as a `Double` (in seconds).+-- [@stmMapTVar@] STM container storing per-client timestamp lists.+-- [@ipZone@] A textual label identifying the group or IP zone.+-- [@userKey@] Unique identifier for the client (IP, token, etc).+-- [@windowSize@] Sliding window size in seconds.+-- [@limit@] Maximum allowed requests within the time window.+--+-- == Returns+--+-- A `Bool` indicating whether the request is allowed (`True`) or rate-limited (`False`).+--+-- == Example+--+-- > getTime <- realToFrac <$> getPOSIXTime+-- > allowed <- allowRequest getTime myStore "zone" "user" 10 5+-- > if allowed then serve else reject+allowRequest+ :: IO Double -- ^ Time retrieval function (returns time in seconds)+ -> TVar (StmMap.Map Text [Double]) -- ^ STM Map container (wrapped in a TVar)+ -> Text -- ^ Zone identifier (or grouping key)+ -> Text -- ^ User (or client) key+ -> Int -- ^ Sliding window size (in seconds)+ -> Int -- ^ Request limit within the window+ -> IO Bool -- ^ Returns True if the request is allowed, False if rate-limited+allowRequest getTimeNow stmMapTVar ipZone userKey windowSize limit = do+ now <- getTimeNow+ let key = makeCacheKey SlidingWindow ipZone userKey+ windowSizeD = fromIntegral windowSize++ atomically $ do+ stmMap <- readTVar stmMapTVar+ -- Use StmMap.focus for an atomic read-modify-write operation on the map entry+ StmMap.focus (updateTimestamps now windowSizeD limit) key stmMap++--------------------------------------------------------------------------------++-- | Internal helper that atomically updates the timestamp list using STM Focus.+--+-- This function removes timestamps that are older than the current window,+-- and determines if the current request is allowed. If so, the current time+-- is appended to the list. If the list becomes empty, the entry is removed+-- from the map.+--+-- This ensures memory-efficient cleanup of old clients.+--+-- == Parameters+--+-- [@now@] The current time in fractional seconds.+-- [@windowSize@] Duration of the sliding window.+-- [@limit@] Maximum allowed request count in the window.+--+-- == Behavior+--+-- - If the client has no prior entries, the request is allowed and a new list is created.+-- - If the client exists, the list is trimmed, and the request is allowed only if+-- the resulting list has fewer than @limit@ entries.+updateTimestamps+ :: Double -- ^ Current time+ -> Double -- ^ Sliding window size (seconds)+ -> Int -- ^ Request limit+ -> Focus.Focus [Double] STM Bool+updateTimestamps now windowSize limit = Focus.Focus+ -- Case 1: The key does not exist.+ -- This is the first request. It's allowed. Insert a new list with the current timestamp.+ (do+ let newList = [now]+ pure (True, Focus.Set newList)+ )+ -- Case 2: The key exists.+ -- Update the list and check the limit.+ (\currentTimestamps -> do+ -- Filter out timestamps older than the window+ let freshTimestamps = filter (\t -> now - t <= windowSize) currentTimestamps+ -- Check if the request is allowed+ let allowed = length freshTimestamps < limit+ -- If allowed, add the new timestamp. Otherwise, keep the old list.+ let updatedTimestamps = if allowed then now : freshTimestamps else freshTimestamps+ + -- If the list becomes empty after filtering, remove it from the map.+ if null updatedTimestamps+ then pure (allowed, Focus.Remove)+ else pure (allowed, Focus.Set updatedTimestamps)+ )
+ src/Keter/RateLimiter/TokenBucket.hs view
@@ -0,0 +1,168 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DataKinds #-}++-- |+-- Module : Keter.RateLimiter.TokenBucket+-- Description : Token bucket rate limiting algorithm implementation+-- Copyright : (c) 2025 Oleksandr Zhabenko+-- License : MIT+-- Maintainer : oleksandr.zhabenko@yahoo.com+-- Stability : stable+-- Portability : portable+--+-- This module provides a rate limiter based on the /Token Bucket/ algorithm.+-- It integrates with the `Keter.RateLimiter.Cache` infrastructure and uses STM+-- and worker threads to manage refill and request allowance.+--+-- The token bucket algorithm allows for a configurable burst size (`capacity`)+-- and replenishes tokens over time at a fixed rate. If a request is made and+-- a token is available, the request is allowed and a token is consumed.+-- Otherwise, the request is denied.+--+-- == Example usage+--+-- > import Keter.RateLimiter.TokenBucket (allowRequest)+-- > +-- > allowed <- allowRequest cache "zone1" "user123" 10 2.5 60+-- > when allowed $ doSomething+--+-- This call checks if a request by "user123" in "zone1" is allowed, given a+-- bucket with a capacity of 10, a refill rate of 2.5 tokens/second, and a TTL of 60 seconds.++module Keter.RateLimiter.TokenBucket+ ( -- * Request Evaluation+ allowRequest+ ) where++import Control.Concurrent.MVar+import Control.Concurrent.STM+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Text (Text)+import Data.Time.Clock.POSIX (getPOSIXTime)++import Keter.RateLimiter.Cache+import Keter.RateLimiter.Types (TokenBucketState (..))+import Keter.RateLimiter.AutoPurge (TokenBucketEntry (..))+import qualified Focus as F+import qualified StmContainers.Map as StmMap++------------------------------------------------------------------------------++-- | Minimum TTL allowed for a token bucket.+-- +-- Requests with TTLs less than this threshold are denied to avoid race conditions+-- or unbounded cleanup complexity.+minTTL :: Int+minTTL = 2++-- | Check whether a request may pass through the token-bucket limiter.+--+-- This function enforces rate-limiting per (IP zone, user key) combination.+-- Each request will either:+--+-- * Succeed immediately if the request belongs to a new bucket and capacity allows.+-- * Be enqueued and handled asynchronously if the bucket already exists.+-- * Be denied if no tokens are available or TTL is invalid.+--+-- The token bucket is defined by:+--+-- - @capacity@: maximum number of tokens in the bucket (i.e., max burst size).+-- - @refillRate@: tokens added per second (can be fractional).+-- - @expiresIn@: TTL in seconds; determines how long idle buckets live.+--+-- == Parameters+--+-- [@cache@] The configured cache for storing per-user token bucket state.+-- [@ipZone@] A label identifying the IP zone (e.g., region or tenant).+-- [@userKey@] A unique identifier for the user (e.g., IP address or token).+-- [@capacity@] Maximum tokens the bucket can hold.+-- [@refillRate@] Token refill rate (tokens per second).+-- [@expiresIn@] Time-to-live for idle entries in seconds.+--+-- == Returns+--+-- A boolean in an arbitrary `MonadIO` context indicating whether the request is allowed.+--+-- == Example+--+-- > allowed <- allowRequest cache "zoneA" "192.168.0.1" 5 1.0 60+-- > when allowed $ putStrLn "Proceeding with request..."+allowRequest+ :: MonadIO m+ => Cache (InMemoryStore 'TokenBucket)+ -- ^ Token bucket cache backend+ -> Text+ -- ^ IP zone (e.g. region or customer identifier)+ -> Text+ -- ^ User key (e.g. IP address or API token)+ -> Int+ -- ^ Bucket capacity (max number of tokens)+ -> Double+ -- ^ Refill rate (tokens per second)+ -> Int+ -- ^ TTL (seconds) for the bucket state+ -> m Bool+allowRequest cache ipZone userKey capacity refillRate expiresIn = liftIO $+ if expiresIn < minTTL+ then do+ putStrLn $+ "TokenBucket: Request denied due to invalid TTL: "+ ++ show expiresIn+ pure False+ else do+ now <- floor <$> getPOSIXTime+ let key = makeCacheKey (cacheAlgorithm cache) ipZone userKey+ TokenBucketStore tvBuckets = cacheStore cache+ replyVar <- newEmptyMVar++ ----------------------------------------------------------------------+ -- 1. Obtain (or create) bucket entry and enqueue the request.+ -- Create the entry in IO, then pass it into the STM transaction.+ newEntryInitialState <- createTokenBucketEntry (TokenBucketState (capacity - 1) now)+ + (wasNew, entry) <- atomically $ do+ buckets <- readTVar tvBuckets+ -- Use F.Focus directly to allow STM actions in the handler, bypassing F.cases.+ (wasNewEntry, ent) <-+ StmMap.focus+ (F.Focus+ -- Handler for when the key is NOT found (the "Nothing" case)+ (pure ((True, newEntryInitialState), F.Set newEntryInitialState))+ -- Handler for when the key IS found (the "Just" case)+ (\existingEnt -> do+ -- This handler can now perform STM actions.+ workerLockEmpty <- isEmptyTMVar (tbeWorkerLock existingEnt)+ if workerLockEmpty+ then pure ((True, newEntryInitialState), F.Set newEntryInitialState) -- Replace dead entry+ else pure ((False, existingEnt), F.Leave) -- Keep existing entry+ )+ )+ key buckets+ pure (wasNewEntry, ent)++ ----------------------------------------------------------------------+ -- 2. Spawn a worker once for a fresh bucket.+ if wasNew+ then+ -- For a new bucket, the first request is allowed only if there is capacity.+ if capacity > 0+ then do+ workerReadyVar <- atomically newEmptyTMVar+ atomically $ putTMVar (tbeWorkerLock entry) () -- Mark worker lock as taken+ -- Start the worker with ready synchronization+ startTokenBucketWorker (tbeState entry)+ (tbeQueue entry)+ capacity+ refillRate+ workerReadyVar+ -- Wait for the worker to signal it's ready before proceeding+ atomically $ takeTMVar workerReadyVar+ pure True+ else do+ -- If capacity is 0, no request can ever be allowed.+ pure False+ else do+ -- For existing buckets, enqueue the request and wait for response+ atomically $ writeTQueue (tbeQueue entry) replyVar+ result <- takeMVar replyVar+ pure result
+ src/Keter/RateLimiter/TokenBucketWorker.hs view
@@ -0,0 +1,194 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE NamedFieldPuns #-}++{-|+Module : Keter.RateLimiter.TokenBucketWorker+Description : Worker thread implementation for token bucket rate limiting+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : experimental+Portability : POSIX++This module provides a concurrent worker thread implementation for the token bucket+rate limiting algorithm. The worker processes incoming requests from a queue and+atomically updates the bucket state using Software Transactional Memory (STM).++== Algorithm Overview++The token bucket algorithm works as follows:++1. __Initialization__: A bucket starts with a certain number of tokens (up to capacity)+2. __Token Refill__: Tokens are added to the bucket at a constant rate over time+3. __Request Processing__: Each request attempts to consume one token+4. __Rate Limiting__: If no tokens are available, the request is denied++== Concurrency Model++The worker uses STM for atomic state updates and communicates via:++* 'TQueue' for receiving incoming requests +* 'MVar' for sending responses back to clients+* 'TVar' for maintaining bucket state+* 'TMVar' for signaling worker readiness++== Example Usage++@+import Control.Concurrent.STM+import Control.Concurrent.MVar+import Data.Text++-- Create initial bucket state (100 tokens, last updated now)+now <- floor \<$\> getPOSIXTime +initialState <- newTVarIO $ TokenBucketState 100 now++-- Create communication channels+requestQueue <- newTBroadcastTQueueIO+readySignal <- newEmptyTMVarIO++-- Start worker: 100 token capacity, 10 tokens/second refill rate+startTokenBucketWorker initialState requestQueue 100 10.0 readySignal++-- Wait for worker to be ready+atomically $ takeTMVar readySignal++-- Send a request and wait for response+replyVar <- newEmptyMVar+atomically $ writeTQueue requestQueue replyVar +allowed <- takeMVar replyVar -- True if request allowed, False if denied+@++== Performance Characteristics++* __Time Complexity__: O(1) per request (constant time token calculation)+* __Space Complexity__: O(1) (fixed bucket state size)+* __Concurrency__: Lock-free using STM, supports high throughput+* __Precision__: Uses POSIX timestamps for accurate time-based calculations++== Thread Safety++All operations are thread-safe through STM. Multiple clients can safely+send requests to the same worker concurrently.+-}+module Keter.RateLimiter.TokenBucketWorker+ ( -- * Worker Thread Management+ startTokenBucketWorker+ ) where++import Control.Concurrent.STM+import Control.Monad (void, forever)+import Control.Concurrent (forkIO)+import Control.Monad.IO.Class (liftIO)+import Data.Time.Clock.POSIX (getPOSIXTime)+import Keter.RateLimiter.Types (TokenBucketState(..))+import Control.Concurrent.MVar (MVar, putMVar)++-- | Start a dedicated worker thread for processing token bucket requests.+--+-- The worker runs in an infinite loop, processing requests from the provided queue.+-- Each request is handled atomically: the bucket state is read, tokens are refilled+-- based on elapsed time, a token is consumed if available, and the new state is written back.+--+-- ==== Worker Lifecycle+--+-- 1. __Startup__: Worker thread is forked and signals readiness via 'TMVar'+-- 2. __Processing Loop__: Worker waits for requests, processes them atomically+-- 3. __Response__: Results are sent back to clients via 'MVar'+--+-- ==== Token Refill Algorithm+--+-- Tokens are refilled using the formula:+--+-- @+-- newTokens = min capacity (currentTokens + refillRate * elapsedSeconds)+-- @+--+-- This ensures:+-- * Tokens are added proportionally to elapsed time+-- * Bucket capacity is never exceeded+-- * Sub-second precision for refill calculations+--+-- ==== Atomic Request Processing+--+-- Each request is processed in a single STM transaction that:+--+-- 1. Reads current bucket state ('tokens', 'lastUpdate')+-- 2. Calculates elapsed time since last update+-- 3. Computes available tokens after refill+-- 4. Attempts to consume one token if available+-- 5. Updates bucket state with new token count and timestamp+-- 6. Returns allow/deny decision+--+-- ==== Error Handling+--+-- The worker is designed to be resilient:+--+-- * Time calculation errors are handled by using 'floor' for integer conversion+-- * Negative elapsed time (clock adjustments) results in no refill+-- * Worker continues running even if individual requests fail+--+-- ==== Example+--+-- @+-- -- Create a bucket for API rate limiting: 1000 requests/hour = ~0.278 req/sec+-- let capacity = 100 -- Allow bursts up to 100 requests+-- refillRate = 1000.0 / 3600.0 -- 1000 requests per hour+--+-- initialState <- newTVarIO $ TokenBucketState capacity now+-- requestQueue <- newTBroadcastTQueueIO +-- readySignal <- newEmptyTMVarIO+--+-- startTokenBucketWorker initialState requestQueue capacity refillRate readySignal+-- @+--+-- __Thread Safety:__ All state updates are atomic via STM transactions.+--+-- __Resource Usage:__ Creates one background thread that runs indefinitely.+startTokenBucketWorker+ :: TVar TokenBucketState -- ^ Shared bucket state (tokens + last update time).+ -- This 'TVar' is read and updated atomically by the worker.+ -> TQueue (MVar Bool) -- ^ Request queue containing 'MVar's for client responses.+ -- Clients place their response 'MVar' in this queue and wait+ -- for the worker to write the allow/deny decision.+ -> Int -- ^ Maximum bucket capacity (maximum tokens that can be stored).+ -- This sets the upper limit for burst traffic handling.+ -- Must be positive.+ -> Double -- ^ Token refill rate in tokens per second.+ -- Determines the long-term sustainable request rate.+ -- Must be positive. Can be fractional (e.g., 0.5 = 1 token per 2 seconds).+ -> TMVar () -- ^ Synchronization variable to signal when worker is ready.+ -- The worker writes to this 'TMVar' once startup is complete.+ -- Clients can wait on this to ensure the worker is operational.+ -> IO () -- ^ Returns immediately after forking the worker thread.+ -- The actual worker runs in the background indefinitely.+startTokenBucketWorker stateVar queue capacity refillRate readyVar = void . forkIO $ do+ -- Signal that the worker is ready+ atomically $ putTMVar readyVar ()+ + forever $ do+ -- Wait for a request to arrive in the queue+ replyVar <- atomically $ readTQueue queue+ now <- liftIO $ floor <$> getPOSIXTime+ -- Atomically process the request: read state, calculate new tokens,+ -- consume a token if available, and write the new state back.+ allowed <- atomically $ do+ TokenBucketState { tokens, lastUpdate } <- readTVar stateVar+ + let elapsed = fromIntegral (now - lastUpdate)+ refilled = elapsed * refillRate+ -- Add refilled tokens, but don't exceed the capacity+ currentTokens = min (fromIntegral capacity) (fromIntegral tokens + refilled)+ if currentTokens >= 1+ then do+ -- Request is allowed. Consume one token and update the timestamp.+ let newTokens = currentTokens - 1+ writeTVar stateVar (TokenBucketState (floor newTokens) now)+ return True+ else do+ -- Request is denied. Don't consume a token, but update the timestamp+ -- to ensure the next refill calculation is correct.+ writeTVar stateVar (TokenBucketState (floor currentTokens) now)+ return False+ -- Send the response back to the waiting client.+ liftIO $ putMVar replyVar allowed
+ src/Keter/RateLimiter/Types.hs view
@@ -0,0 +1,233 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}++{-|+Module : Keter.RateLimiter.Types+Description : Core data types for rate limiting algorithms+Copyright : (c) Keter Project+License : MIT+Maintainer : maintainer@example.com+Stability : experimental+Portability : POSIX++This module provides the fundamental data types used in Keter's rate limiting system.+It defines state representations for two common rate limiting algorithms:++* __Token Bucket__: A rate limiting algorithm that maintains a bucket of tokens,+ where each request consumes a token. Tokens are replenished at a fixed rate.+* __Leaky Bucket__: A rate limiting algorithm that models a bucket with a hole,+ where requests fill the bucket and it drains at a constant rate.++Both types support JSON serialization for persistence and configuration purposes,+with validation to ensure state consistency.++== Example Usage++@+-- Token bucket example+let tokenState = TokenBucketState { tokens = 100, lastUpdate = 1640995200 }+print tokenState -- TokenBucketState {tokens = 100, lastUpdate = 1640995200}++-- Leaky bucket example +let leakyState = LeakyBucketState { level = 0.5, lastTime = 1640995200.123 }+print leakyState -- LeakyBucketState {level = 0.5, lastTime = 1640995200.123}+@+-}+module Keter.RateLimiter.Types+ ( -- * Token Bucket Algorithm+ TokenBucketState(..)+ -- * Leaky Bucket Algorithm + , LeakyBucketState(..)+ ) where++import Data.Aeson (ToJSON, FromJSON(..), withObject, (.:))+import Control.Monad (when)+import GHC.Generics (Generic)++-- | State representation for the Token Bucket rate limiting algorithm.+--+-- The token bucket algorithm maintains a bucket that holds tokens up to a maximum capacity.+-- Each incoming request consumes one or more tokens from the bucket. Tokens are replenished+-- at a fixed rate. If insufficient tokens are available, the request is either delayed or rejected.+--+-- ==== Token Bucket Properties+--+-- * __Bursty Traffic__: Allows bursts of traffic up to the bucket capacity+-- * __Rate Control__: Long-term average rate is controlled by token replenishment rate+-- * __Memory Efficient__: Only requires tracking token count and last update time+--+-- ==== Use Cases+--+-- * API rate limiting with burst allowance+-- * Network traffic shaping+-- * Resource allocation with temporary overages+--+-- ==== Example+--+-- @+-- -- Initial state with 50 tokens, last updated at Unix timestamp 1640995200+-- let initialState = TokenBucketState +-- { tokens = 50+-- , lastUpdate = 1640995200 +-- }+--+-- -- After consuming 10 tokens+-- let afterConsumption = initialState { tokens = 40 }+-- @+data TokenBucketState = TokenBucketState+ { tokens :: Int -- ^ Current number of available tokens in the bucket.+ -- Must be non-negative. Represents the instantaneous+ -- capacity available for processing requests.+ , lastUpdate :: Int -- ^ Unix timestamp (seconds since epoch) of the last+ -- bucket state update. Used to calculate how many+ -- tokens should be replenished based on elapsed time.+ } deriving (Show, Eq, Generic)++-- | 'ToJSON' instance for 'TokenBucketState'.+--+-- Serializes the token bucket state to JSON format for persistence or network transmission.+--+-- ==== JSON Format+--+-- @+-- {+-- "tokens": 42,+-- "lastUpdate": 1640995200+-- }+-- @+instance ToJSON TokenBucketState++-- | 'FromJSON' instance for 'TokenBucketState' with validation.+--+-- Deserializes JSON to 'TokenBucketState' with the following validation rules:+--+-- * @tokens@ field must be non-negative (>= 0)+-- * @lastUpdate@ field must be present and parseable as 'Int'+--+-- ==== Validation Examples+--+-- @+-- -- Valid JSON+-- decode "{\"tokens\": 10, \"lastUpdate\": 1640995200}" :: Maybe TokenBucketState+-- -- Just (TokenBucketState {tokens = 10, lastUpdate = 1640995200})+--+-- -- Invalid JSON (negative tokens)+-- decode "{\"tokens\": -5, \"lastUpdate\": 1640995200}" :: Maybe TokenBucketState +-- -- Nothing+-- @+--+-- __Throws:__ Parse error if @tokens@ is negative or required fields are missing.+instance FromJSON TokenBucketState where+ parseJSON = withObject "TokenBucketState" $ \o -> do+ tokens <- o .: "tokens"+ when (tokens < 0) $ fail "tokens must be non-negative"+ lastUpdate <- o .: "lastUpdate"+ return TokenBucketState { tokens, lastUpdate }++-- | State representation for the Leaky Bucket rate limiting algorithm.+--+-- The leaky bucket algorithm models a bucket with a hole in the bottom that drains+-- at a constant rate. Incoming requests add water to the bucket, and if the bucket+-- overflows, requests are rejected. This provides smooth rate limiting without bursts.+--+-- ==== Leaky Bucket Properties+--+-- * __Smooth Rate__: Enforces a consistent output rate regardless of input bursts+-- * __No Bursts__: Unlike token bucket, doesn't allow temporary rate exceedance +-- * __Queue Modeling__: Can model request queuing with bucket level representing queue depth+--+-- ==== Use Cases+--+-- * Smooth traffic shaping for network connections+-- * Audio/video streaming rate control+-- * Database connection throttling+-- * Prevention of thundering herd problems+--+-- ==== Mathematical Model+--+-- The bucket level changes according to:+--+-- @+-- newLevel = max(0, oldLevel + requestSize - drainRate * timeDelta)+-- @+--+-- Where:+-- * @requestSize@ is the size of the incoming request+-- * @drainRate@ is the constant drain rate (requests per second)+-- * @timeDelta@ is the elapsed time since last update+--+-- ==== Example+--+-- @+-- -- Initial state: half-full bucket at timestamp 1640995200.5+-- let initialState = LeakyBucketState +-- { level = 0.5+-- , lastTime = 1640995200.5 +-- }+--+-- -- After 1 second with drain rate 0.1/sec and no new requests+-- let afterDrain = initialState +-- { level = 0.4 -- 0.5 - 0.1*1.0+-- , lastTime = 1640995201.5 +-- }+-- @+data LeakyBucketState = LeakyBucketState+ { level :: Double -- ^ Current fill level of the bucket (0.0 to capacity).+ -- Represents the amount of "water" (pending requests)+ -- currently in the bucket. Higher values indicate+ -- more backpressure or pending work.+ , lastTime :: Double -- ^ Timestamp of last bucket update as Unix time with+ -- fractional seconds. Higher precision than 'TokenBucketState'+ -- to support sub-second drain rate calculations.+ } deriving (Show, Eq, Generic)++-- | 'ToJSON' instance for 'LeakyBucketState'.+--+-- Serializes the leaky bucket state to JSON format with full double precision.+--+-- ==== JSON Format+--+-- @+-- {+-- "level": 123.456,+-- "lastTime": 1640995200.789+-- }+-- @+instance ToJSON LeakyBucketState++-- | 'FromJSON' instance for 'LeakyBucketState' with validation.+--+-- Deserializes JSON to 'LeakyBucketState' with the following validation rules:+--+-- * @level@ must be non-negative (>= 0.0)+-- * @level@ must not exceed 1,000,000 (practical upper bound)+-- * @lastTime@ must be present and parseable as 'Double'+--+-- The upper bound on @level@ prevents potential overflow issues and ensures+-- reasonable memory usage for bucket state tracking.+--+-- ==== Validation Examples+--+-- @+-- -- Valid JSON+-- decode "{\"level\": 42.5, \"lastTime\": 1640995200.123}" :: Maybe LeakyBucketState+-- -- Just (LeakyBucketState {level = 42.5, lastTime = 1640995200.123})+--+-- -- Invalid JSON (negative level)+-- decode "{\"level\": -1.0, \"lastTime\": 1640995200.0}" :: Maybe LeakyBucketState+-- -- Nothing+--+-- -- Invalid JSON (level too high) +-- decode "{\"level\": 2000000.0, \"lastTime\": 1640995200.0}" :: Maybe LeakyBucketState+-- -- Nothing+-- @+--+-- __Throws:__ Parse error if @level@ is negative, exceeds 1,000,000, or required fields are missing.+instance FromJSON LeakyBucketState where+ parseJSON = withObject "LeakyBucketState" $ \o -> do+ level <- o .: "level"+ when (level < 0) $ fail "level must be non-negative"+ when (level > 1000000) $ fail "level must not exceed 1000000"+ lastTime <- o .: "lastTime"+ return LeakyBucketState { level, lastTime }
+ src/Keter/RateLimiter/WAI.hs view
@@ -0,0 +1,224 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE DataKinds #-}++{-|+Module : Keter.RateLimiter.WAI+Description : WAI-compatible rate limiting middleware with IP zone support+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This file is a ported to Haskell language code with some simplifications of rack-attack +https://github.com/rack/rack-attack/blob/main/lib/rack/attack.rb+and is based on the structure of the original code of +rack-attack, Copyright (c) 2016 by Kickstarter, PBC, under the MIT License.+Oleksandr Zhabenko added several implementations of the window algorithm: tinyLRU, sliding window, token bucket window, leaky bucket window alongside with the initial count algorithm using AI chatbots.+IP Zone functionality added to allow separate caches per IP zone.++This implementation is released under the MIT License.++This module provides WAI middleware that implements multiple IP-zone-specific+rate limiting strategies including:++- Fixed Window+- Sliding Window+- Token Bucket+- Leaky Bucket+- TinyLRU++It supports multiple throttles applied simultaneously (e.g., per endpoint or+user group), each with its own algorithm, rate, and identifier logic.++Rate limiting state is tracked separately per IP zone using the+'ZoneSpecificCaches' abstraction.++Use `attackMiddleware` to enforce throttling in your WAI application.++-}++module Keter.RateLimiter.WAI+ ( -- * Environment & Configuration+ Env+ , ThrottleConfig(..)+ , initConfig+ , addThrottle+ -- * Middleware+ , attackMiddleware+ -- * Manual Control & Inspection+ , instrument+ , cacheResetAll+ -- * Accessors+ , envZoneCachesMap+ ) where++import Data.Text (Text)+import qualified Data.Text.Encoding as TE+import qualified Data.Map.Strict as Map+import Network.Wai+import Network.HTTP.Types (status429)+import qualified Data.ByteString.Lazy as LBS+import Data.IORef+import Control.Concurrent.STM+import Keter.RateLimiter.Cache+import Keter.RateLimiter.IPZones (IPZoneIdentifier, defaultIPZone, ZoneSpecificCaches(..), createZoneCaches)+import qualified Keter.RateLimiter.SlidingWindow as SlidingWindow+import qualified Keter.RateLimiter.TokenBucket as TokenBucket+import qualified Keter.RateLimiter.LeakyBucket as LeakyBucket+import Keter.RateLimiter.CacheWithZone (allowFixedWindowRequest)+import Data.TinyLRU (allowRequestTinyLRU)+import System.Clock (Clock(Monotonic), getTime)+import Data.Maybe (fromMaybe)+import Data.Time.Clock.POSIX (getPOSIXTime)++--------------------------------------------------------------------------------+-- | Configuration for a single throttle rule.+data ThrottleConfig = ThrottleConfig+ { throttleLimit :: Int+ -- ^ Maximum number of requests allowed per period.+ , throttlePeriod :: Int+ -- ^ Period over which requests are counted (in seconds).+ , throttleAlgorithm :: Algorithm+ -- ^ Algorithm used for throttling (e.g., 'FixedWindow', 'LeakyBucket').+ , throttleIdentifier :: Request -> IO (Maybe Text)+ -- ^ Function to extract an identifier (e.g., user or IP) from the request.+ -- Returns 'Nothing' if throttling should be skipped.+ , throttleTokenBucketTTL :: Maybe Int+ -- ^ Optional TTL (seconds) for TokenBucket entries. If not provided, defaults to 2.+ }++-- | Global environment for throttling, including zone-specific cache maps and throttle configurations.+data Env = Env+ { envZoneCachesMap :: IORef (Map.Map IPZoneIdentifier ZoneSpecificCaches)+ -- ^ Map from IP zones to rate limiter caches.+ , envThrottles :: IORef (Map.Map Text ThrottleConfig)+ -- ^ Registered throttle rules by name.+ , envGetRequestIPZone :: Request -> IPZoneIdentifier+ -- ^ Function to derive an IP zone from the incoming WAI request.+ }++--------------------------------------------------------------------------------++-- | Initialize the rate-limiter environment with a default zone and no throttles.+initConfig+ :: (Request -> IPZoneIdentifier)+ -- ^ Function to extract an IP zone label from the request.+ -> IO Env+initConfig getIPZone = do+ defaultCaches <- createZoneCaches+ zoneCachesMap <- newIORef $ Map.singleton defaultIPZone defaultCaches+ throttles <- newIORef Map.empty+ return $ Env zoneCachesMap throttles getIPZone++-- | Register a new named throttle rule in the environment.+addThrottle+ :: Env+ -> Text -- ^ Throttle rule name (must be unique).+ -> ThrottleConfig -- ^ Throttle configuration.+ -> IO Env+addThrottle env name config = do+ modifyIORef' (envThrottles env) $ Map.insert name config+ return env++-- | WAI middleware that applies registered throttles from the environment.+--+-- If any throttle blocks the request, a 429 response is returned.+-- Otherwise, the request is forwarded to the application.+attackMiddleware+ :: Env+ -> Application+ -> Application+attackMiddleware env app req respond = do+ blocked <- instrument env req+ if blocked+ then respond $ responseLBS status429 [] (LBS.fromStrict $ TE.encodeUtf8 "Too Many Requests")+ else app req respond++-- | Inspect all throttle rules and apply them to the incoming request.+--+-- Returns 'True' if any rule blocks the request.+instrument+ :: Env+ -> Request+ -> IO Bool+instrument env req = do+ throttles <- readIORef (envThrottles env)+ let zone = envGetRequestIPZone env req+ zoneCaches <- getOrCreateZoneCaches env zone+ anyBlocked <- or <$> mapM (checkThrottle zoneCaches zone req) (Map.elems throttles)+ return anyBlocked+ where+ checkThrottle :: ZoneSpecificCaches -> Text -> Request -> ThrottleConfig -> IO Bool+ checkThrottle caches zone req config = do+ mIdentifier <- throttleIdentifier config req+ case mIdentifier of+ Nothing -> return False+ Just identifier -> case throttleAlgorithm config of+ FixedWindow ->+ not <$> allowFixedWindowRequest+ (zscCounterCache caches) zone identifier+ (throttleLimit config) (throttlePeriod config)++ SlidingWindow -> case zscTimestampCache caches of+ Cache { cacheStore = TimestampStore tvar } ->+ not <$> SlidingWindow.allowRequest+ (realToFrac <$> getPOSIXTime)+ tvar zone identifier+ (throttlePeriod config) (throttleLimit config)++ TokenBucket -> do+ let period = throttlePeriod config+ limit = throttleLimit config+ refillRate = if period > 0 then fromIntegral limit / fromIntegral period else 0.0+ ttl = fromMaybe 2 (throttleTokenBucketTTL config)+ not <$> TokenBucket.allowRequest+ (zscTokenBucketCache caches)+ zone identifier+ limit refillRate ttl++ LeakyBucket -> do+ let period = throttlePeriod config+ limit = throttleLimit config+ leakRate = if period > 0 then fromIntegral limit / fromIntegral period else 0.0+ not <$> LeakyBucket.allowRequest+ (zscLeakyBucketCache caches)+ zone identifier+ limit leakRate++ TinyLRU -> do+ now <- getTime Monotonic+ case cacheStore (zscTinyLRUCache caches) of+ TinyLRUStore tvar -> do+ cache <- readTVarIO tvar+ not <$> atomically (allowRequestTinyLRU now cache identifier (throttleLimit config) (throttlePeriod config))++-- | Reset all caches for all IP zones tracked in the environment.+cacheResetAll :: Env -> IO ()+cacheResetAll env = do+ zoneCachesMap <- readIORef (envZoneCachesMap env)+ mapM_ (resetZoneCaches . snd) (Map.toList zoneCachesMap)+ where+ resetZoneCaches :: ZoneSpecificCaches -> IO ()+ resetZoneCaches caches = do+ cacheReset (zscCounterCache caches)+ cacheReset (zscTimestampCache caches)+ cacheReset (zscTokenBucketCache caches)+ cacheReset (zscLeakyBucketCache caches)+ cacheReset (zscTinyLRUCache caches)++-- | Retrieve or create the set of caches for a specific IP zone.+getOrCreateZoneCaches+ :: Env+ -> IPZoneIdentifier+ -> IO ZoneSpecificCaches+getOrCreateZoneCaches env zone = do+ readIORef (envZoneCachesMap env) >>= \m ->+ case Map.lookup zone m of+ Just caches -> return caches+ Nothing -> do+ newCaches <- createZoneCaches+ atomicModifyIORef' (envZoneCachesMap env) $ \currentMap ->+ let updatedMap = Map.insertWith (\_ old -> old) zone newCaches currentMap+ in (updatedMap, updatedMap Map.! zone)
+ test/Keter/RateLimiter/Cache/PurgeTests.hs view
@@ -0,0 +1,55 @@+{-# LANGUAGE OverloadedStrings, NumericUnderscores #-}++{-|+Module : Keter.RateLimiter.Cache.PurgeTests+Description : Tests for the background cache purging mechanism.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module contains tests to verify that the automatic background purging+of expired keys in the cache works as expected.+-}+module Keter.RateLimiter.Cache.PurgeTests (+ -- * Test Suite+ testBackgroundPurge+) where++import Test.Tasty+import Test.Tasty.HUnit+import qualified Data.Cache as C+import Control.Concurrent (threadDelay)+import Data.Text (Text)+import System.Clock (TimeSpec(..))+import Keter.RateLimiter.Cache (startAutoPurge) -- Import your module++-- | A test suite for background cache purging.+testBackgroundPurge :: TestTree+testBackgroundPurge = testCase "Background purge removes expired keys" $ do+ -- Create cache with no default TTL+ cache <- C.newCache Nothing :: IO (C.Cache Text Text)++ -- Create a proper TimeSpec for 2 seconds (2 seconds = 2 * 10^9 nanoseconds)+ let ttlSeconds = 2+ let ttlTimeSpec = TimeSpec ttlSeconds 0 -- 2 seconds, 0 nanoseconds++ -- Insert a key with proper TTL+ C.insert' cache (Just ttlTimeSpec) "expired-key" "value"++ -- Confirm it's there using lookup' (non-deleting version)+ mval <- C.lookup' cache "expired-key"+ case mval of+ Nothing -> assertFailure "Expected key to exist immediately after insert"+ Just v -> v @?= "value"++ -- Start the auto-purge with 1-second interval+ startAutoPurge cache 1++ -- Wait for 3 seconds (TTL = 2s, purge interval = 1s, should be purged)+ threadDelay 3_000_000++ -- Now the key should be completely gone+ mval' <- C.lookup' cache "expired-key"+ mval' @?= Nothing
+ test/Keter/RateLimiter/IPZonesTests.hs view
@@ -0,0 +1,95 @@+{-# LANGUAGE OverloadedStrings, TypeApplications, DataKinds #-}++{-|+Module : Keter.RateLimiter.IPZonesTests+Description : Tests for IP-based zoning and cache management.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : experimental+Portability : POSIX++This module contains tests for the IP zone functionality within the rate-limiting system.+IP zones are used to segment the cache, allowing for different rate-limiting rules or counters+to be applied based on the client's IP address.++The tests verify:+ * Correct conversion of 'SockAddr' (both IPv4 and IPv6) to a textual IP zone identifier.+ * Graceful handling of non-IP 'SockAddr' types (e.g., 'SockAddrUnix').+ * The functionality of incrementing and reading counters within a specific IP zone.+ * The ability to reset all caches, effectively clearing all rate-limiting state.+ * Correct expiration of cache entries based on their TTL (Time To Live).+-}+module Keter.RateLimiter.IPZonesTests (+ -- * Test Suite+ tests+) where++import Test.Tasty+import Test.Tasty.HUnit+import Network.Socket (SockAddr(..), tupleToHostAddress)+import Keter.RateLimiter.IPZones+import Keter.RateLimiter.Cache+import Keter.RateLimiter.CacheWithZone+import Control.Monad.IO.Class (liftIO)+import Control.Concurrent (threadDelay)++-- | The main test suite for IP zone functionality.+-- It groups together all the individual test cases in this module.+tests :: TestTree+tests = testGroup "Keter.RateLimiter.IPZones Tests"+ [ testCase "sockAddrToIPZone produces correct IPv4 address" $ do+ let addr = SockAddrInet 0 (tupleToHostAddress (192, 168, 1, 100))+ result <- sockAddrToIPZone addr+ assertEqual "IPv4 address should be correct" "192.168.1.100" result++ , testCase "sockAddrToIPZone produces correct IPv6 address" $ do+ let addr = SockAddrInet6 0 0 (0, 0, 0, 1) 0+ result <- sockAddrToIPZone addr+ assertEqual "IPv6 address should be correct" "0000:0000:0000:0000:0000:0000:0000:0001" result++ , testCase "sockAddrToIPZone handles non-IP address" $ do+ let addr = SockAddrUnix "/tmp/socket"+ result <- sockAddrToIPZone addr+ assertEqual "Non-IP address should return default" "default" result++ , testCase "incStoreWithZone with IPv4 address" $ do+ store <- createInMemoryStore @'FixedWindow+ let cache = newCache FixedWindow store+ _ <- incStoreWithZone cache "192.168.1.100" "user123" 60+ result <- readCacheWithZone cache "192.168.1.100" "user123" :: IO (Maybe Int)+ assertEqual "readCacheWithZone should return Just 1" (Just 1) result++ , testCase "incStoreWithZone with IPv6 address" $ do+ store <- createInMemoryStore @'FixedWindow+ let cache = newCache FixedWindow store+ _ <- incStoreWithZone cache "2001:0db8:0000:0000:0000:0000:0000:0001" "user123" 60+ result <- readCacheWithZone cache "2001:0db8:0000:0000:0000:0000:0000:0001" "user123" :: IO (Maybe Int)+ assertEqual "readCacheWithZone should return Just 1" (Just 1) result++ , testCase "incStoreWithZone increments existing counter" $ do+ store <- createInMemoryStore @'FixedWindow+ let cache = newCache FixedWindow store+ _ <- incStoreWithZone cache "192.168.1.100" "user123" 60+ result <- incStoreWithZone cache "192.168.1.100" "user123" 60+ assertEqual "incStoreWithZone should increment to 2" 2 result++ , testCase "resetSingleZoneCaches clears all caches" $ do+ zoneCaches <- createZoneCaches+ _ <- incStoreWithZone (zscCounterCache zoneCaches) "192.168.1.100" "user123" 60+ _ <- resetSingleZoneCaches zoneCaches+ result <- readCacheWithZone (zscCounterCache zoneCaches) "192.168.1.100" "user123" :: IO (Maybe Int)+ assertEqual "Should not retrieve counter after reset" Nothing result++ , testCase "incStoreWithZone with expired entry" $ do+ store <- createInMemoryStore @'FixedWindow+ let cache = newCache FixedWindow store+ -- Set a short TTL of 1 second.+ _ <- incStoreWithZone cache "192.168.1.100" "user123" 1+ -- Wait for the entry to expire.+ liftIO $ threadDelay (2 * 1000000) -- Wait for 2 seconds+ -- Increment again, which should create a new entry.+ _ <- incStoreWithZone cache "192.168.1.100" "user123" 60+ result <- readCacheWithZone cache "192.168.1.100" "user123" :: IO (Maybe Int)+ assertEqual "Should retrieve new counter after expiration" (Just 1) result+ ]
+ test/Keter/RateLimiter/LeakyBucketStateTests.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Module : Keter.RateLimiter.LeakyBucketStateTests+Description : Tests for the LeakyBucketState data type and its JSON handling+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : experimental+Portability : POSIX++This module contains unit tests for the 'LeakyBucketState' data type, including+its construction, equality, JSON serialization/deserialization, and validation logic.+-}+module Keter.RateLimiter.LeakyBucketStateTests (tests) where++import Test.Tasty+import Test.Tasty.HUnit+import Data.Aeson (encode, decode)+import qualified Data.ByteString.Lazy.Char8 as LBS+import Keter.RateLimiter.Types (LeakyBucketState(..))++-- | Test suite for 'LeakyBucketState' including JSON behavior and validation.+--+-- > defaultMain Keter.RateLimiter.LeakyBucketStateTests.tests+tests :: TestTree+tests = testGroup "Keter.RateLimiter.LeakyBucketState Tests"+ [ testCase "Construct and compare LeakyBucketState" $ do+ let state = LeakyBucketState { level = 2.5, lastTime = 1625073600 }+ state @?= LeakyBucketState { level = 2.5, lastTime = 1625073600 }+ level state @?= 2.5+ lastTime state @?= 1625073600++ , testCase "JSON serialization and deserialization" $ do+ let state = LeakyBucketState { level = 2.5, lastTime = 1625073600 }+ let json = encode state+ case decode json :: Maybe LeakyBucketState of+ Nothing -> assertFailure "JSON deserialization failed"+ Just decoded -> decoded @?= state++ , testCase "Invalid JSON: negative level" $ do+ let invalidJson = LBS.pack "{\"level\":-1.0,\"lastTime\":1625073600}"+ case decode invalidJson :: Maybe LeakyBucketState of+ Just _ -> assertFailure "Should fail to deserialize negative level"+ Nothing -> return () -- Expected failure++ , testCase "Invalid JSON: level exceeds maximum" $ do+ let invalidJson = LBS.pack "{\"level\":1000001.0,\"lastTime\":1625073600}"+ case decode invalidJson :: Maybe LeakyBucketState of+ Just _ -> assertFailure "Should fail to deserialize level exceeding 1000000"+ Nothing -> return () -- Expected failure++ , testCase "Invalid JSON: malformed input" $ do+ let invalidJson = LBS.pack "{\"level\":2.5}" -- Missing lastTime+ case decode invalidJson :: Maybe LeakyBucketState of+ Just _ -> assertFailure "Should fail to deserialize malformed JSON"+ Nothing -> return () -- Expected failure++ , testCase "Edge case: zero level" $ do+ let state = LeakyBucketState { level = 0.0, lastTime = 1625073600 }+ let json = encode state+ case decode json :: Maybe LeakyBucketState of+ Nothing -> assertFailure "JSON deserialization failed for zero level"+ Just decoded -> decoded @?= state++ , testCase "Edge case: large valid lastTime" $ do+ let state = LeakyBucketState { level = 500.0, lastTime = 1755734400 } -- ≈ 2025-08-01+ let json = encode state+ case decode json :: Maybe LeakyBucketState of+ Nothing -> assertFailure "JSON deserialization failed for large lastTime"+ Just decoded -> decoded @?= state+ ]
+ test/Keter/RateLimiter/LeakyBucketTests.hs view
@@ -0,0 +1,316 @@+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DataKinds #-}++{-|+Module : Keter.RateLimiter.LeakyBucketTests+Description : Tests for the Leaky Bucket rate-limiting algorithm.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module provides integration and unit tests for the Leaky Bucket algorithm.+The Leaky Bucket algorithm is useful for smoothing out bursts of requests and maintaining a steady outflow rate.++It verifies the correctness of the implementation across several dimensions:+ * **IP Version Handling**: Ensures both IPv4 and IPv6 are handled correctly.+ * **Request Headers**: Tests identification of clients via @x-forwarded-for@ and @x-real-ip@ headers.+ * **Concurrency**: Checks for correct behavior under concurrent access.+ * **Timing and Capacity**: Verifies that the bucket "leaks" at the correct rate and respects its capacity.+ * **Direct Function Calls**: Includes tests that call the core algorithm functions directly for unit testing.+-}+module Keter.RateLimiter.LeakyBucketTests (+ -- * Test Suite+ tests,+ + -- * Test Helpers+ mockApp,+ mkIPv4Request,+ mkIPv6Request,+ mkRequestWithXFF,+ mkRequestWithRealIP+) where++import Test.Tasty+import Test.Tasty.HUnit+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.ByteString.Lazy as LBS+import Control.Concurrent (threadDelay, forkIO)+import Control.Concurrent.MVar (newMVar, modifyMVar_, withMVar)+import Network.Wai (Request, Application, defaultRequest, requestHeaders, responseLBS, remoteHost)+import Network.Wai.Test (runSession, srequest, SRequest(..), assertStatus, simpleStatus)+import Network.HTTP.Types (status200)+import Network.HTTP.Types.Status (statusCode)+import Network.Socket (SockAddr(..), tupleToHostAddress)+import Keter.RateLimiter.Cache+import Keter.RateLimiter.RequestUtils+import Keter.RateLimiter.WAI+import Keter.RateLimiter.IPZones+import Keter.RateLimiter.LeakyBucket (allowRequest)+import Control.Monad.IO.Class (liftIO)+import Data.CaseInsensitive (mk)+import qualified StmContainers.Map as StmMap+import Control.Concurrent.STM+import Data.Function (fix)++-- * Mock Request Generation++-- | Creates a mock IPv4 'Request' for testing purposes.+-- The address is set to @127.0.0.1@.+mkIPv4Request :: Request+mkIPv4Request = defaultRequest { remoteHost = SockAddrInet 0 (tupleToHostAddress (127, 0, 0, 1)) } -- 127.0.0.1++-- | Creates a mock IPv6 'Request' for testing purposes.+-- The address is set to @::1@.+mkIPv6Request :: Request+mkIPv6Request = defaultRequest { remoteHost = SockAddrInet6 0 0 (0, 0, 0, 1) 0 } -- ::1++-- | Creates a mock 'Request' with an @x-forwarded-for@ header.+-- This is essential for testing applications behind a reverse proxy.+--+-- ==== __Example__+-- > let req = mkRequestWithXFF "198.51.100.1"+mkRequestWithXFF+ :: Text -- ^ The IP address to use in the header.+ -> Request+mkRequestWithXFF ip = defaultRequest { requestHeaders = [(mk "x-forwarded-for", TE.encodeUtf8 ip)] }++-- | Creates a mock 'Request' with an @x-real-ip@ header.+-- Similar to @x-forwarded-for@, this is used for client IP identification.+--+-- ==== __Example__+-- > let req = mkRequestWithRealIP "2001:db8::a"+mkRequestWithRealIP+ :: Text -- ^ The IP address to use in the header.+ -> Request+mkRequestWithRealIP ip = defaultRequest { requestHeaders = [(mk "x-real-ip", TE.encodeUtf8 ip)] }++-- * Mock Application++-- | A mock WAI 'Application' that consistently returns a 200 OK response.+-- This allows tests to focus solely on the behavior of the rate-limiting middleware.+mockApp :: Application+mockApp _ respond = respond $ responseLBS status200 [] (LBS.fromStrict $ TE.encodeUtf8 "OK")++-- * Test Suite Definition++-- | The main test tree for the Leaky Bucket algorithm.+-- This 'TestTree' aggregates all the test cases defined in this module.+tests :: TestTree+tests = testGroup "Leaky Bucket Tests"+ [ testCase "Allows IPv4 requests below capacity" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result2+ runSession session app++ , testCase "Blocks IPv4 requests exceeding capacity" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 429 result3+ runSession session app++ , testCase "Allows IPv6 requests below capacity" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result2+ runSession session app++ , testCase "Blocks IPv6 requests exceeding capacity" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 429 result3+ runSession session app++ , testCase "Respects leaky bucket timing with IPv4" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2 -- capacity+ , throttlePeriod = 2 -- time to leak one request (leak rate is capacity / period)+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 429 result3+ -- Wait for the bucket to leak, allowing new requests+ liftIO $ threadDelay (3 * 1000000) -- Wait 3 seconds+ -- This request should now be allowed+ result4 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result4+ runSession session app++ , testCase "Handles x-forwarded-for header for IPv4" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let req = mkRequestWithXFF (T.pack "192.168.1.1")+ let session = do+ result1 <- srequest $ SRequest req LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest req LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest req LBS.empty+ assertStatus 429 result3+ runSession session app++ , testCase "Handles x-real-ip header for IPv6" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let req = mkRequestWithRealIP (T.pack "2001:db8::1")+ let session = do+ _ <- srequest $ SRequest req LBS.empty+ _ <- srequest $ SRequest req LBS.empty+ result3 <- srequest $ SRequest req LBS.empty+ assertStatus 429 result3+ runSession session app+ , testCase "Handles concurrent requests" $ do+ env <- initConfig (const defaultIPZone)+ cacheResetAll env -- Reset all caches+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ mvar <- newMVar []+ barrier <- newMVar 0+ let runRequest = do+ result <- srequest $ SRequest mkIPv4Request LBS.empty+ liftIO $ modifyMVar_ barrier $ \c -> return (c + 1)+ return $ statusCode $ simpleStatus result+ -- Fork three concurrent requests+ _ <- forkIO $ do+ status <- runSession runRequest app+ modifyMVar_ mvar $ \results -> return (status : results)+ _ <- forkIO $ do+ status <- runSession runRequest app+ modifyMVar_ mvar $ \results -> return (status : results)+ _ <- forkIO $ do+ status <- runSession runRequest app+ modifyMVar_ mvar $ \results -> return (status : results)+ -- Wait for all threads to complete+ fix $ \loop -> do+ count <- withMVar barrier return+ if count == 3 then return () else threadDelay 100000 >> loop+ results <- withMVar mvar return+ let successes = length $ filter (== 200) results+ failures = length $ filter (== 429) results+ assertEqual "Exactly two requests should succeed" 2 successes+ assertEqual "One request should fail" 1 failures+ , testCase "Direct allowRequest allows request below capacity" $ do+ leakyBucketMap <- atomically StmMap.new+ -- Corrected: LeakyBucketStore expects a TVar, not directly the StmMap+ leakyBucketTVar <- newTVarIO leakyBucketMap+ let cache = newCache LeakyBucket (LeakyBucketStore leakyBucketTVar)+ -- Start a purge thread for the LeakyBucketStore+ -- Removed the extra predicate argument, as startCustomPurgeLeakyBucket doesn't take it.+ _ <- startCustomPurgeLeakyBucket leakyBucketMap 60 7200+ let ipZone = defaultIPZone+ userKey = "test_user"+ capacity = 2+ leakRate = 1+ allowed1 <- allowRequest cache ipZone userKey capacity leakRate+ assertBool "First request should be allowed" allowed1+ allowed2 <- allowRequest cache ipZone userKey capacity leakRate+ assertBool "Second request should be allowed" allowed2+ , testCase "Direct allowRequest denies request at capacity" $ do+ leakyBucketMap <- atomically StmMap.new+ -- Corrected: LeakyBucketStore expects a TVar, not directly the StmMap+ leakyBucketTVar <- newTVarIO leakyBucketMap+ let cache = newCache LeakyBucket (LeakyBucketStore leakyBucketTVar)+ -- Start a purge thread for the LeakyBucketStore+ -- Removed the extra predicate argument, as startCustomPurgeLeakyBucket doesn't take it.+ _ <- startCustomPurgeLeakyBucket leakyBucketMap 60 7200+ let ipZone = defaultIPZone+ userKey = "test_user"+ capacity = 2+ leakRate = 1+ allowed1 <- allowRequest cache ipZone userKey capacity leakRate+ assertBool "First request should be allowed" allowed1+ allowed2 <- allowRequest cache ipZone userKey capacity leakRate+ assertBool "Second request should be allowed" allowed2+ allowed3 <- allowRequest cache ipZone userKey capacity leakRate+ assertBool "Third request should be denied" (not allowed3)+ ]
+ test/Keter/RateLimiter/NotificationTests.hs view
@@ -0,0 +1,197 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++{-|+Module : Keter.RateLimiter.NotificationTests+Description : Unit tests for throttling notification mechanisms in Keter+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : experimental+Portability : portable++This module contains tests for the notification infrastructure used in+the Keter rate limiter. It verifies that console-based and no-op notifiers,+along with WAI integration, produce expected log messages or suppress output appropriately.+-}+module Keter.RateLimiter.NotificationTests (tests) where++import Test.Tasty+import Test.Tasty.HUnit+import Data.Text (Text)+import qualified Data.Text as T+import Network.Wai (defaultRequest, requestMethod, rawPathInfo, rawQueryString, remoteHost)+import Network.Socket (SockAddr(..), tupleToHostAddress, tupleToHostAddress6)+import Data.IORef (IORef, newIORef, readIORef, modifyIORef')+import Keter.RateLimiter.Notifications+import Data.Time.Clock (getCurrentTime)+import Data.Time.Format (defaultTimeLocale, formatTime)++-- | Create a test notifier that captures output with real timestamps+createTestNotifier :: IORef [Text] -> Notifier+createTestNotifier outputRef = Notifier+ { notifierName = "test"+ , notifierAction = \throttle act item limit -> do+ now <- getCurrentTime+ let ts = T.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" now+ parts = [throttle, act, item, T.concat ["(limit: ", T.pack (show limit), ")"]]+ message = T.intercalate " " $ filter (not . T.null) parts+ fullMessage = T.concat [ts, " - ", message]+ modifyIORef' outputRef (fullMessage :)+ }++-- | Create a test WAI notifier that captures output with real timestamps+createTestWAINotifier :: IORef [Text] -> WAINotifier+createTestWAINotifier outputRef throttleName req limit = do+ now <- getCurrentTime+ let timestamp = T.pack $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" now+ requestInfo = convertWAIRequest req+ message = T.concat [timestamp, " - ", throttleName, " blocked ", + requestInfo, " (limit: ", T.pack (show limit), ")"]+ modifyIORef' outputRef (message :)++-- | Check if a timestamp is valid (format: YYYY-MM-DD HH:MM:SS)+isValidTimestamp :: Text -> Bool+isValidTimestamp ts = + T.length ts == 19 && + T.all (`T.elem` ("0123456789-: " :: Text)) ts &&+ T.index ts 4 == '-' &&+ T.index ts 7 == '-' &&+ T.index ts 10 == ' ' &&+ T.index ts 13 == ':' &&+ T.index ts 16 == ':'++-- | Parse and validate a log message format: "TIMESTAMP - CONTENT"+parseLogMessage :: Text -> Maybe (Text, Text)+parseLogMessage msg =+ case T.splitOn " - " msg of+ [timestamp, content] | isValidTimestamp timestamp -> Just (timestamp, content)+ _ -> Nothing++-- | Test suite for notification handlers in the rate limiter.+tests :: TestTree+tests = testGroup "Keter.RateLimiter.Notifications Tests"+ [ testCase "Generic notifier format" $ do+ outputRef <- newIORef []+ let notifier = createTestNotifier outputRef+ + notify notifier "loginAttempts" ("user123" :: Text) 5+ + output <- readIORef outputRef+ case output of+ [message] -> case parseLogMessage message of+ Just (_, content) -> + assertEqual "Generic notifier message format" + "loginAttempts blocked \"user123\" (limit: 5)" + content+ Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message+ _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)++ , testCase "WAI notifier format" $ do+ outputRef <- newIORef []+ let notifier = createTestWAINotifier outputRef+ req = defaultRequest+ { requestMethod = "GET"+ , rawPathInfo = "/api/users"+ , rawQueryString = "?limit=10"+ , remoteHost = SockAddrInet 8080 (tupleToHostAddress (192,168,1,100))+ }+ + notifier "apiRequests" req 50+ + output <- readIORef outputRef+ case output of+ [message] -> case parseLogMessage message of+ Just (_, content) ->+ assertEqual "WAI notifier message format"+ "apiRequests blocked GET /api/users?limit=10 from 192.168.1.100:8080 (limit: 50)"+ content+ Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message+ _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)++ , testCase "waiNotifier adapter format" $ do+ outputRef <- newIORef []+ let baseNotifier = createTestNotifier outputRef+ adapter = waiNotifier baseNotifier+ req = defaultRequest + { requestMethod = "POST"+ , rawPathInfo = "/login"+ , rawQueryString = ""+ , remoteHost = SockAddrInet 443 (tupleToHostAddress (10,0,0,1))+ }+ + adapter "auth" req 10+ + output <- readIORef outputRef+ case output of+ [message] -> case parseLogMessage message of+ Just (_, content) ->+ assertEqual "waiNotifier adapter format"+ "auth blocked POST /login from 10.0.0.1:443 (limit: 10)"+ content+ Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message+ _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)++ , testCase "convertWAIRequest IPv4 format" $ do+ let req = defaultRequest+ { requestMethod = "GET"+ , rawPathInfo = "/api/users"+ , rawQueryString = "?limit=10"+ , remoteHost = SockAddrInet 8080 (tupleToHostAddress (192,168,1,100))+ }+ assertEqual "IPv4 request conversion" + "GET /api/users?limit=10 from 192.168.1.100:8080"+ (convertWAIRequest req)++ , testCase "convertWAIRequest IPv6 format" $ do+ let req = defaultRequest + { requestMethod = "POST"+ , rawPathInfo = "/login"+ , rawQueryString = ""+ , remoteHost = SockAddrInet6 443 0 (tupleToHostAddress6 (0x2001, 0x0db8, 0, 0, 0, 0, 0, 1)) 0+ }+ assertEqual "IPv6 request conversion"+ "POST /login from 2001:0db8:0000:0000:0000:0000:0000:0001:443"+ (convertWAIRequest req)++ , testCase "Empty throttle name handling" $ do+ outputRef <- newIORef []+ let notifier = createTestNotifier outputRef+ + notify notifier "" ("item" :: Text) 100+ + output <- readIORef outputRef+ case output of+ [message] -> case parseLogMessage message of+ Just (_, content) ->+ assertEqual "Empty throttle name format"+ "blocked \"item\" (limit: 100)"+ content+ Nothing -> assertFailure $ "Invalid log format: " ++ T.unpack message+ _ -> assertFailure $ "Expected exactly one message, got: " ++ show (length output)++ , testCase "Empty query string handling" $ do+ let req = defaultRequest + { requestMethod = "DELETE"+ , rawPathInfo = "/resource/123"+ , rawQueryString = ""+ , remoteHost = SockAddrInet 9000 (tupleToHostAddress (127,0,0,1))+ }+ assertEqual "Empty query string conversion"+ "DELETE /resource/123 from 127.0.0.1:9000"+ (convertWAIRequest req)++ , testCase "No-op notifier silence" $ do+ -- Test that no-op notifiers complete without side effects+ notify noopNotifier "test" ("data" :: Text) 1+ noopWAINotifier "test" defaultRequest 1+ -- If we reach here, both completed successfully+ assertBool "No-op notifiers should complete silently" True++ , testCase "Console notifiers exist and are callable" $ do+ -- Test that console notifiers can be referenced without crashing+ -- We don't actually call them to avoid polluting test output+ let _ = consoleNotifier+ _ = consoleWAINotifier+ assertBool "Console notifiers should be accessible" True+ ]
+ test/Keter/RateLimiter/SlidingWindowTests.hs view
@@ -0,0 +1,295 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeApplications #-}++{-|+Module : Keter.RateLimiter.SlidingWindowTests+Description : Tests for the Sliding Window rate-limiting algorithm.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : experimental+Portability : POSIX++This module provides a suite of tests for the Sliding Window rate-limiting algorithm,+focusing on its integration with the WAI middleware. The Sliding Window algorithm offers+a more accurate rate limit over time compared to a Fixed Window by considering a rolling time+window.++The tests cover the following key areas:+ * **Basic Functionality**: Verifies that requests are allowed when under the limit and blocked when the limit is exceeded for both IPv4 and IPv6 clients.+ * **Proxy Header Support**: Ensures that the client's IP is correctly identified when using headers like @x-forwarded-for@ and @x-real-ip@.+ * **Concurrency**: Tests the algorithm's robustness under both moderate and high levels of concurrent requests to prevent race conditions and ensure stability.+ * **Timing Accuracy**: Includes a deterministic test using a fake clock to precisely simulate the sliding window's behavior over time, ensuring that expired requests are correctly discarded from the window.+-}+module Keter.RateLimiter.SlidingWindowTests (+ -- * Test Suite+ tests+) where++import Test.Tasty+import Test.Tasty.HUnit+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.ByteString.Lazy as LBS+import Control.Monad (replicateM_)+import Control.Concurrent (threadDelay, forkIO)+import Control.Concurrent.MVar (newMVar, modifyMVar_, withMVar)+import Network.Wai (Request, Application, defaultRequest, requestHeaders, responseLBS, remoteHost)+import Network.Wai.Test (runSession, srequest, SRequest(..), assertStatus, simpleStatus)+import Network.HTTP.Types (status200)+import Network.HTTP.Types.Status (statusCode)+import Network.Socket (SockAddr(..), tupleToHostAddress)+import Keter.RateLimiter.Cache+import Keter.RateLimiter.WAI+import Keter.RateLimiter.SlidingWindow (allowRequest)+import Keter.RateLimiter.RequestUtils+import Keter.RateLimiter.IPZones (defaultIPZone)+import Data.CaseInsensitive (mk)+import Data.IORef (newIORef, modifyIORef', readIORef)++-- * Test Helpers++-- | Creates a new in-memory store specifically for the 'SlidingWindow' algorithm.+newCacheStore :: IO (InMemoryStore 'SlidingWindow)+newCacheStore = createStore @'SlidingWindow++-- | Initializes a new 'Cache' instance with an in-memory store for testing purposes.+initTestCache :: IO (Cache (InMemoryStore 'SlidingWindow))+initTestCache = do+ store <- newCacheStore+ pure $ newCache SlidingWindow store++-- | Creates a mock 'Request' with a default IPv4 address (127.0.0.1).+mkIPv4Request :: Request+mkIPv4Request = defaultRequest { remoteHost = SockAddrInet 0 (tupleToHostAddress (127, 0, 0, 1)) } -- 127.0.0.1++-- | Creates a mock 'Request' with a default IPv6 address (::1).+mkIPv6Request :: Request+mkIPv6Request = defaultRequest { remoteHost = SockAddrInet6 0 0 (0, 0, 0, 1) 0 } -- ::1++-- | Creates a mock 'Request' containing an @x-forwarded-for@ header.+mkRequestWithXFF :: Text -> Request+mkRequestWithXFF ip = defaultRequest { requestHeaders = [(mk "x-forwarded-for", TE.encodeUtf8 ip)] }++-- | Creates a mock 'Request' containing an @x-real-ip@ header.+mkRequestWithRealIP :: Text -> Request+mkRequestWithRealIP ip = defaultRequest { requestHeaders = [(mk "x-real-ip", TE.encodeUtf8 ip)] }++-- | A mock WAI 'Application' that always returns a 200 OK response.+mockApp :: Application+mockApp _ respond = respond $ responseLBS status200 [] (LBS.fromStrict $ TE.encodeUtf8 "OK")+++-- * Test Suite Definition++-- | The main test tree for the Sliding Window algorithm.+-- This groups all the WAI integration tests for the algorithm.+tests :: TestTree+tests = testGroup "Sliding Window Tests"+ [ testCase "Allows IPv4 requests below limit" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result2+ runSession session app++ , testCase "Blocks IPv4 requests exceeding limit" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 429 result3+ runSession session app++ , testCase "Allows IPv6 requests below limit" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ session = do+ result1 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result2+ runSession session app++ , testCase "Blocks IPv6 requests exceeding limit" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ session = do+ result1 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest mkIPv6Request LBS.empty+ assertStatus 429 result3+ runSession session app++ , testCase "Handles x-forwarded-for header for IPv4" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ req = mkRequestWithXFF (T.pack "192.168.1.1")+ session = do+ result1 <- srequest $ SRequest req LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest req LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest req LBS.empty+ assertStatus 429 result3+ runSession session app++ , testCase "Handles x-real-ip header for IPv6" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ req = mkRequestWithRealIP (T.pack "2001:db8::1")+ session = do+ result1 <- srequest $ SRequest req LBS.empty+ assertStatus 200 result1+ result2 <- srequest $ SRequest req LBS.empty+ assertStatus 200 result2+ result3 <- srequest $ SRequest req LBS.empty+ assertStatus 429 result3+ runSession session app++ , testCase "Handles concurrent requests" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ mvar <- newMVar []+ let runRequest = do+ result <- srequest $ SRequest mkIPv4Request LBS.empty+ return $ statusCode $ simpleStatus result+ -- Fork three concurrent requests.+ replicateM_ 3 $ forkIO $ do+ status <- runSession runRequest app+ modifyMVar_ mvar $ \results -> return (status : results)+ threadDelay (1 * 1000000) -- Ensure threads complete.+ results <- withMVar mvar return+ let successes = length $ filter (== 200) results+ failures = length $ filter (== 429) results+ assertEqual "Exactly two requests should succeed" 2 successes+ assertEqual "One request should fail" 1 failures++ , testCase "Handles DoS-like concurrency" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 10+ , throttlePeriod = 60+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Nothing+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ mvar <- newMVar []+ let runRequest = do+ result <- srequest $ SRequest mkIPv4Request LBS.empty+ return $ statusCode $ simpleStatus result+ -- Fork 100 concurrent requests.+ replicateM_ 100 $ forkIO $ do+ status <- runSession runRequest app+ modifyMVar_ mvar $ \results -> return (status : results)+ threadDelay (3 * 1000000)+ results <- withMVar mvar return+ let successes = length $ filter (== 200) results+ failures = length $ filter (== 429) results+ -- With high concurrency, the exact number can fluctuate.+ -- We assert that the number of successes is at most the limit.+ assertBool "Number of successful requests should not exceed the limit" (successes <= 10)+ assertEqual "Total requests should be accounted for" 100 (successes + failures)++ , testCase "Respects sliding window timing with fake clock for IPv4" $ do+ -- Set up a fake time source using IORef for deterministic testing.+ fakeTimeRef <- newIORef 100.0 -- Start fake time at 100.0 seconds.+ let getFakeTime = readIORef fakeTimeRef+ advanceTime dt = modifyIORef' fakeTimeRef (+ dt)+ ipZone = "ipv4"+ userKey = "user-ipv4"+ window = 2 -- Sliding window of 2 seconds.+ limit = 2 -- Allow two requests within the window.++ cacheInstance <- initTestCache+ -- Extract the underlying STM store from the cache.+ let TimestampStore tvar = cacheStore cacheInstance++ -- First two calls should be allowed.+ allowed1 <- allowRequest getFakeTime tvar (T.pack ipZone) (T.pack userKey) window limit+ allowed2 <- allowRequest getFakeTime tvar (T.pack ipZone) (T.pack userKey) window limit+ -- Third call should be blocked as the window is full.+ allowed3 <- allowRequest getFakeTime tvar (T.pack ipZone) (T.pack userKey) window limit++ assertBool "First request should be allowed" allowed1+ assertBool "Second request should be allowed" allowed2+ assertBool "Third request should be blocked" (not allowed3)++ -- Advance fake time by 3 seconds, so the first two requests fall out of the window.+ advanceTime 3.0++ -- This new request should now be allowed.+ allowed4 <- allowRequest getFakeTime tvar (T.pack ipZone) (T.pack userKey) window limit+ assertBool "After advancing time, a new request should be allowed" allowed4+ ]
+ test/Keter/RateLimiter/TokenBucketStateTests.hs view
@@ -0,0 +1,68 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Module : Keter.RateLimiter.TokenBucketStateTests+Description : Tests for the TokenBucketState data type.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module provides unit tests for the 'TokenBucketState' data type,+ensuring its construction, JSON serialization/deserialization, and+validation logic work correctly.+-}+module Keter.RateLimiter.TokenBucketStateTests (+ -- * Test Suite+ tests+) where++import Test.Tasty+import Test.Tasty.HUnit+import Data.Aeson (encode, decode)+import qualified Data.ByteString.Lazy.Char8 as LBS+import Keter.RateLimiter.Types (TokenBucketState(..))++-- | The main test tree for 'TokenBucketState'.+tests :: TestTree+tests = testGroup "Keter.RateLimiter.TokenBucketState Tests"+ [ testCase "Construct and compare TokenBucketState" $ do+ let state = TokenBucketState { tokens = 5, lastUpdate = 1625073600 }+ state @?= TokenBucketState { tokens = 5, lastUpdate = 1625073600 }+ tokens state @?= 5+ lastUpdate state @?= 1625073600++ , testCase "JSON serialization and deserialization" $ do+ let state = TokenBucketState { tokens = 5, lastUpdate = 1625073600 }+ let json = encode state+ case decode json :: Maybe TokenBucketState of+ Nothing -> assertFailure "JSON deserialization failed"+ Just decoded -> decoded @?= state++ , testCase "Invalid JSON: negative tokens" $ do+ let invalidJson = LBS.pack "{\"tokens\":-1,\"lastUpdate\":1625073600}"+ case decode invalidJson :: Maybe TokenBucketState of+ Just _ -> assertFailure "Should fail to deserialize negative tokens"+ Nothing -> return () -- Expected failure++ , testCase "Invalid JSON: malformed input" $ do+ let invalidJson = LBS.pack "{\"tokens\":5}" -- Missing lastUpdate+ case decode invalidJson :: Maybe TokenBucketState of+ Just _ -> assertFailure "Should fail to deserialize malformed JSON"+ Nothing -> return () -- Expected failure++ , testCase "Edge case: zero tokens" $ do+ let state = TokenBucketState { tokens = 0, lastUpdate = 1625073600 }+ let json = encode state+ case decode json :: Maybe TokenBucketState of+ Nothing -> assertFailure "JSON deserialization failed for zero tokens"+ Just decoded -> decoded @?= state++ , testCase "Edge case: large valid lastUpdate" $ do+ let state = TokenBucketState { tokens = 10, lastUpdate = 1755734400 } -- Approx. 2025-08-01+ let json = encode state+ case decode json :: Maybe TokenBucketState of+ Nothing -> assertFailure "JSON deserialization failed for large lastUpdate"+ Just decoded -> decoded @?= state+ ]
+ test/Keter/RateLimiter/TokenBucketTests.hs view
@@ -0,0 +1,286 @@+{-|+Module : Keter.RateLimiter.TokenBucketTests+Description : Integration tests for the Token Bucket rate-limiting algorithm.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module contains integration tests for the Token Bucket algorithm,+verifying its behavior under various conditions such as rapid bursts,+long delays, high load, and edge cases like zero capacity.+-}+module Keter.RateLimiter.TokenBucketTests (+ -- * Main Entry+ main+, tests+ -- * Test Groups+, testPredictableTiming+, testScenarios+, testStress+, testEdgeCases+ -- * Helpers+, mockApp+, mkIPv4Request+, testGetRequestIPZone+, assertStatus+) where++import Test.Tasty+import Test.Tasty.HUnit+import Control.Concurrent (threadDelay)+import Control.Monad (replicateM)+import Control.Monad.IO.Class (liftIO)+import qualified Data.Text as Text+import qualified Data.ByteString.Lazy as LBS+import Network.Wai+import Network.Wai.Test (SRequest(..), SResponse, simpleStatus, runSession, srequest, Session)+import Network.HTTP.Types (status200, status429, statusCode)+import Network.Socket (SockAddr(..), tupleToHostAddress)+import Data.CaseInsensitive (mk)+import qualified Data.Text.Encoding as TE+import Keter.RateLimiter.RequestUtils+import Keter.RateLimiter.WAI (ThrottleConfig(..), attackMiddleware, addThrottle, initConfig)+import Keter.RateLimiter.Cache (Algorithm(..))+import Keter.RateLimiter.IPZones (defaultIPZone)++-- | A mock WAI 'Application' that always returns a 200 OK response.+mockApp :: Application+mockApp _ respond = respond $ responseLBS status200 [] (LBS.fromStrict $ TE.encodeUtf8 (Text.pack "OK"))++-- | Creates a mock IPv4 'Request' for testing.+-- The request includes an 'x-real-ip' header for consistent IP identification.+mkIPv4Request :: Request+mkIPv4Request = defaultRequest+ { remoteHost = SockAddrInet 0 (tupleToHostAddress (127, 0, 0, 1))+ , requestHeaders = [(mk (TE.encodeUtf8 (Text.pack "x-real-ip")), TE.encodeUtf8 (Text.pack "127.0.0.1"))]+ }++-- | A test-specific helper to determine the IP zone for a request.+-- For these tests, it always returns the 'defaultIPZone'.+testGetRequestIPZone :: Request -> Text.Text+testGetRequestIPZone _ = defaultIPZone++-- | Main entry point to run the Token Bucket tests independently.+main :: IO ()+main = defaultMain tests++-- | The main 'TestTree' for the Token Bucket algorithm.+tests :: TestTree+tests = testGroup "Token Bucket Tests"+ [ testPredictableTiming+ , testScenarios+ , testStress+ , testEdgeCases+ ]++-- | Tests focused on verifying the timing and basic functionality of the token bucket.+testPredictableTiming :: TestTree+testPredictableTiming = testGroup "Predictable Timing Tests"+ [ testCase "Basic token bucket test" $ do+ let ttlSeconds = 3+ env <- initConfig testGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 3+ , throttlePeriod = 10+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just ttlSeconds+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttleConfig+ let app = attackMiddleware env' mockApp+ result <- runSession (srequest $ SRequest mkIPv4Request LBS.empty) app+ assertEqual "First request should succeed" status200 (simpleStatus result)+ , testCase "Respects token bucket timing with IPv4" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 20+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 2+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ liftIO $ assertEqual "First two requests succeed, third fails"+ [status200, status200, status429]+ (map simpleStatus [result1, result2, result3])+ runSession session app+ ]++-- | Tests covering various real-world usage scenarios.+testScenarios :: TestTree+testScenarios = testGroup "Scenario Tests"+ [ testCase "Rapid burst of requests" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 3+ , throttlePeriod = 10+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ results <- replicateM 5 $ srequest $ SRequest mkIPv4Request LBS.empty+ let statuses = map (statusCode . simpleStatus) results+ liftIO $ assertEqual "First 3 allowed, last 2 blocked" [200, 200, 200, 429, 429] statuses+ runSession session app+ , testCase "Long delays between requests" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 5+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ assertStatus 200 result2+ liftIO $ threadDelay (6 * 1000000)+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result3+ runSession session app+ , testCase "Irregular request patterns" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 5+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ liftIO $ threadDelay (2 * 1000000)+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ liftIO $ threadDelay (4 * 1000000)+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ assertStatus 200 result2+ assertStatus 200 result3+ runSession session app+ ]++-- | Tests for behavior under high concurrent load.+testStress :: TestTree+testStress = testGroup "Stress Tests"+ [ testCase "High load stress test" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 10+ , throttlePeriod = 10+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ results <- replicateM 100 $ srequest $ SRequest mkIPv4Request LBS.empty+ let statuses = map (statusCode . simpleStatus) results+ let allowed = length $ filter (== 200) statuses+ let blocked = length $ filter (== 429) statuses+ liftIO $ assertBool "Should allow ~10 requests" (allowed >= 10 && allowed <= 15)+ liftIO $ assertEqual "Remaining requests blocked" (100 - allowed) blocked+ runSession session app+ ]++-- | Tests for edge cases in throttle configuration.+testEdgeCases :: TestTree+testEdgeCases = testGroup "Edge Case Tests"+ [ testCase "Bucket capacity of 0" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 0+ , throttlePeriod = 10+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 429 result+ runSession session app+ , testCase "Bucket capacity of 1" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 1+ , throttlePeriod = 10+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ assertStatus 429 result2+ runSession session app+ , testCase "Refill rate of 0" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 0+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ liftIO $ threadDelay (5 * 1000000)+ result4 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ assertStatus 200 result2+ assertStatus 429 result3+ assertStatus 429 result4+ runSession session app+ , testCase "Very high refill rate" $ do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 1 -- Changed from 0.1 to 1 (Int)+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = byIP+ , throttleTokenBucketTTL = Just 10+ }+ env' <- addThrottle env (Text.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request LBS.empty+ result2 <- srequest $ SRequest mkIPv4Request LBS.empty+ liftIO $ threadDelay 2000000 -- Changed from 200000 (0.2s) to 2000000 (2s)+ result3 <- srequest $ SRequest mkIPv4Request LBS.empty+ assertStatus 200 result1+ assertStatus 200 result2+ assertStatus 200 result3+ runSession session app+ ]++-- | A helper assertion to check the status code of a response.+assertStatus+ :: Int -- ^ The expected status code.+ -> SResponse -- ^ The response from the WAI test session.+ -> Session ()+assertStatus expected response = liftIO $ assertEqual ("Expected status " ++ show expected) expected (statusCode $ simpleStatus response)
+ test/Keter/RateLimiter/WAITests.hs view
@@ -0,0 +1,361 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DataKinds #-}++{-|+Module : Keter.RateLimiter.WAITests+Description : Comprehensive WAI middleware tests for various rate-limiting algorithms.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : experimental+Portability : POSIX++This module provides a comprehensive test suite for the WAI (Web Application Interface) middleware responsible for rate limiting. It uses the tasty and tasty-hunit frameworks to define and run tests.++The tests cover five distinct rate-limiting algorithms:+ * Fixed Window+ * Sliding Window+ * Token Bucket+ * Leaky Bucket+ * TinyLRU++For each algorithm, the following scenarios are tested:+ * Allowing requests that are under the defined limit.+ * Blocking requests that exceed the defined limit.+ * Correctly handling both IPv4 and IPv6 addresses.+ * Ensuring the rate-limiting window resets correctly over time.+ * Identifying clients using proxy headers like @x-forwarded-for@ and @x-real-ip@.+ * Managing concurrent requests to prevent race conditions.+ * Simulating a high-volume of concurrent requests to test DoS (Denial of Service) protection.++The module defines several helper functions to create mock requests and a mock application to isolate the middleware for testing.+-}+module Keter.RateLimiter.WAITests (+ -- * Test Suite+ tests+) where++import Test.Tasty+import Test.Tasty.HUnit+import Network.Wai+import Network.Wai.Test+import Network.HTTP.Types+import Network.Socket (SockAddr(..), tupleToHostAddress)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.ByteString.Lazy as LBS+import Control.Concurrent (threadDelay)+import Control.Concurrent.MVar (newMVar, modifyMVar_)+import Control.Monad (replicateM)+import Control.Monad.IO.Class (liftIO)+import Data.CaseInsensitive (mk)+import Keter.RateLimiter.IPZones (defaultIPZone)+import Keter.RateLimiter.WAI+import Keter.RateLimiter.RequestUtils+import Keter.RateLimiter.Cache (Algorithm(..))++-- * Request Helpers++-- | Creates a mock 'Request' with a default IPv4 address (127.0.0.1).+-- This is used to simulate a standard IPv4 client connection.+mkIPv4Request :: Request+mkIPv4Request = defaultRequest { remoteHost = SockAddrInet 0 (tupleToHostAddress (127, 0, 0, 1)) } -- 127.0.0.1++-- | Creates a mock 'Request' with a default IPv6 address (::1).+-- This is used to simulate a standard IPv6 client connection.+mkIPv6Request :: Request+mkIPv6Request = defaultRequest { remoteHost = SockAddrInet6 0 0 (0, 0, 0, 1) 0 } -- ::1++-- | Creates a mock 'Request' containing an @x-forwarded-for@ header.+-- This is used to test scenarios where the application is behind a proxy+-- and the client's IP address is forwarded in this header.+--+-- ==== __Example__+-- > mkRequestWithXFF "192.168.1.1"+mkRequestWithXFF+ :: Text -- ^ The IP address to set in the header.+ -> Request+mkRequestWithXFF ip = defaultRequest { requestHeaders = [(mk "x-forwarded-for", TE.encodeUtf8 ip)] }++-- | Creates a mock 'Request' containing an @x-real-ip@ header.+-- This is another common header used by proxies to forward the original client IP.+--+-- ==== __Example__+-- > mkRequestWithRealIP "2001:db8::1"+mkRequestWithRealIP+ :: Text -- ^ The IP address to set in the header.+ -> Request+mkRequestWithRealIP ip = defaultRequest { requestHeaders = [(mk "x-real-ip", TE.encodeUtf8 ip)] }+++-- * Mock Application++-- | A simple WAI 'Application' that always returns a 200 OK response.+-- This serves as the downstream application in tests, allowing focus to be+-- on the middleware's behavior.+mockApp :: Application+mockApp _ respond = respond $ responseLBS status200 [] (LBS.fromStrict $ TE.encodeUtf8 "OK")+++-- * Test Suite Definition++-- | The main entry point for all tests in this module.+-- It groups tests by the rate-limiting algorithm being tested.+tests :: TestTree+tests = testGroup "Rate Limiting Tests"+ [ testGroup "Fixed Window Algorithm"+ [ testCase "Allows IPv4 requests below limit" $ testBelowLimit FixedWindow byIP+ , testCase "Blocks IPv4 requests exceeding limit" $ testExceedLimit FixedWindow byIP+ , testCase "Allows IPv6 requests below limit" $ testBelowLimit FixedWindow byIP+ , testCase "Blocks IPv6 requests exceeding limit" $ testExceedLimit FixedWindow byIP+ , testCase "Respects fixed window timing with IPv4" $ testTiming FixedWindow byIP+ , testCase "Handles x-forwarded-for header for IPv4" $ testXFF FixedWindow byIP+ , testCase "Handles x-real-ip header for IPv6" $ testRealIP FixedWindow byIP+ , testCase "Handles concurrent requests" $ testConcurrent FixedWindow byIP+ , testCase "Handles DoS-like concurrency" $ testDoS FixedWindow byIP+ ]+ , testGroup "Sliding Window Algorithm"+ [ testCase "Allows IPv4 requests below limit" $ testBelowLimit SlidingWindow byIP+ , testCase "Blocks IPv4 requests exceeding limit" $ testExceedLimit SlidingWindow byIP+ , testCase "Allows IPv6 requests below limit" $ testBelowLimit SlidingWindow byIP+ , testCase "Blocks IPv6 requests exceeding limit" $ testExceedLimit SlidingWindow byIP+ , testCase "Respects sliding window timing with IPv4" $ testTiming SlidingWindow byIP+ , testCase "Handles x-forwarded-for header for IPv4" $ testXFF SlidingWindow byIP+ , testCase "Handles x-real-ip header for IPv6" $ testRealIP SlidingWindow byIP+ , testCase "Handles concurrent requests" $ testConcurrent SlidingWindow byIP+ , testCase "Handles DoS-like concurrency" $ testDoS SlidingWindow byIP+ ]+ , testGroup "Token Bucket Algorithm"+ [ testCase "Allows IPv4 requests below limit" $ testBelowLimit TokenBucket byIP+ , testCase "Blocks IPv4 requests exceeding limit" $ testExceedLimit TokenBucket byIP+ , testCase "Allows IPv6 requests below limit" $ testBelowLimit TokenBucket byIP+ , testCase "Blocks IPv6 requests exceeding limit" $ testExceedLimit TokenBucket byIP+ , testCase "Respects token bucket timing with IPv4" $ testTiming TokenBucket byIP+ , testCase "Handles x-forwarded-for header for IPv4" $ testXFF TokenBucket byIP+ , testCase "Handles x-real-ip header for IPv6" $ testRealIP TokenBucket byIP+ , testCase "Handles concurrent requests" $ testConcurrent TokenBucket byIP+ , testCase "Handles DoS-like concurrency" $ testDoS TokenBucket byIP+ ]+ , testGroup "Leaky Bucket Algorithm"+ [ testCase "Allows IPv4 requests below limit" $ testBelowLimit LeakyBucket byIP+ , testCase "Blocks IPv4 requests exceeding limit" $ testExceedLimit LeakyBucket byIP+ , testCase "Allows IPv6 requests below limit" $ testBelowLimit LeakyBucket byIP+ , testCase "Blocks IPv6 requests exceeding limit" $ testExceedLimit LeakyBucket byIP+ , testCase "Respects leaky bucket timing with IPv4" $ testTiming LeakyBucket byIP+ , testCase "Handles x-forwarded-for header for IPv4" $ testXFF LeakyBucket byIP+ , testCase "Handles x-real-ip header for IPv6" $ testRealIP LeakyBucket byIP+ , testCase "Handles concurrent requests" $ testConcurrent LeakyBucket byIP+ , testCase "Handles DoS-like concurrency" $ testDoS LeakyBucket byIP+ ]+ , testGroup "TinyLRU Algorithm"+ [ testCase "Allows IPv4 requests below limit" $ testBelowLimit TinyLRU byIP+ , testCase "Blocks IPv4 requests exceeding limit" $ testExceedLimit TinyLRU byIP+ , testCase "Allows IPv6 requests below limit" $ testBelowLimit TinyLRU byIP+ , testCase "Blocks IPv6 requests exceeding limit" $ testExceedLimit TinyLRU byIP+ , testCase "Respects TinyLRU timing with IPv4" $ testTiming TinyLRU byIP+ , testCase "Handles x-forwarded-for header for IPv4" $ testXFF TinyLRU byIP+ , testCase "Handles x-real-ip header for IPv6" $ testRealIP TinyLRU byIP+ , testCase "Handles concurrent requests" $ testConcurrent TinyLRU byIP+ , testCase "Handles DoS-like concurrency" $ testDoS TinyLRU byIP+ ]+ ]++-- * Test Case Implementations++-- | **Test Scenario**: Verifies that the middleware allows requests when the count is below the configured limit.+-- It sends two requests to an endpoint with a limit of 2 and asserts that both receive a 200 OK status.+testBelowLimit+ :: Algorithm -- ^ The rate-limiting algorithm to test.+ -> (Request -> IO (Maybe Text)) -- ^ The function to identify the client.+ -> Assertion+testBelowLimit algo identifier = do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = algo+ , throttleIdentifier = identifier+ , throttleTokenBucketTTL = Just 3600+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request ""+ result2 <- srequest $ SRequest mkIPv4Request ""+ return (result1, result2)+ (response1, response2) <- runSession session app+ assertEqual "First request status" status200 (simpleStatus response1)+ assertEqual "Second request status" status200 (simpleStatus response2)++-- | **Test Scenario**: Verifies that the middleware blocks requests once the limit is exceeded.+-- It sends three requests to an endpoint with a limit of 2. It asserts that the first two succeed (200 OK)+-- and the third is blocked (429 Too Many Requests).+testExceedLimit+ :: Algorithm -- ^ The rate-limiting algorithm to test.+ -> (Request -> IO (Maybe Text)) -- ^ The function to identify the client.+ -> Assertion+testExceedLimit algo identifier = do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 60+ , throttleAlgorithm = algo+ , throttleIdentifier = identifier+ , throttleTokenBucketTTL = Just 3600+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ _ <- srequest $ SRequest mkIPv4Request ""+ _ <- srequest $ SRequest mkIPv4Request ""+ result3 <- srequest $ SRequest mkIPv4Request ""+ return result3+ response3 <- runSession session app+ assertEqual "Third request status" status429 (simpleStatus response3)++-- | **Test Scenario**: Verifies that the rate limit counter resets after the configured window period.+-- It sets a limit of 1 request per 1-second window. It sends one request, waits for 2 seconds,+-- then sends a second request. It asserts that both requests are successful (200 OK).+testTiming+ :: Algorithm -- ^ The rate-limiting algorithm to test.+ -> (Request -> IO (Maybe Text)) -- ^ The function to identify the client.+ -> Assertion+testTiming algo identifier = do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 1+ , throttlePeriod = 1+ , throttleAlgorithm = algo+ , throttleIdentifier = identifier+ , throttleTokenBucketTTL = Just 3600+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ lock <- newMVar ()+ let session = do+ result1 <- srequest $ SRequest mkIPv4Request ""+ liftIO $ modifyMVar_ lock $ \_ -> threadDelay 2000000 >> return () -- Wait 2s+ result2 <- srequest $ SRequest mkIPv4Request ""+ return (result1, result2)+ (response1, response2) <- runSession session app+ assertEqual "First request status" status200 (simpleStatus response1)+ assertEqual "Second request status after reset" status200 (simpleStatus response2)++-- | **Test Scenario**: Verifies correct IP identification using the @x-forwarded-for@ header.+-- It sends two requests from the same forwarded IP address with a limit of 1.+-- It asserts that the first request succeeds (200 OK) and the second is blocked (429 Too Many Requests).+testXFF+ :: Algorithm -- ^ The rate-limiting algorithm to test.+ -> (Request -> IO (Maybe Text)) -- ^ The function to identify the client.+ -> Assertion+testXFF algo identifier = do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 1+ , throttlePeriod = 60+ , throttleAlgorithm = algo+ , throttleIdentifier = identifier+ , throttleTokenBucketTTL = Just 3600+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest (mkRequestWithXFF "192.168.1.1") ""+ result2 <- srequest $ SRequest (mkRequestWithXFF "192.168.1.1") ""+ return (result1, result2)+ (response1, response2) <- runSession session app+ assertEqual "First XFF request status" status200 (simpleStatus response1)+ assertEqual "Second XFF request status" status429 (simpleStatus response2)++-- | **Test Scenario**: Verifies correct IP identification using the @x-real-ip@ header.+-- It sends two requests from the same real IP address with a limit of 1.+-- It asserts that the first request succeeds (200 OK) and the second is blocked (429 Too Many Requests).+testRealIP+ :: Algorithm -- ^ The rate-limiting algorithm to test.+ -> (Request -> IO (Maybe Text)) -- ^ The function to identify the client.+ -> Assertion+testRealIP algo identifier = do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 1+ , throttlePeriod = 60+ , throttleAlgorithm = algo+ , throttleIdentifier = identifier+ , throttleTokenBucketTTL = Just 3600+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ let session = do+ result1 <- srequest $ SRequest (mkRequestWithRealIP "::1") ""+ result2 <- srequest $ SRequest (mkRequestWithRealIP "::1") ""+ return (result1, result2)+ (response1, response2) <- runSession session app+ assertEqual "First Real-IP request status" status200 (simpleStatus response1)+ assertEqual "Second Real-IP request status" status429 (simpleStatus response2)++-- | **Test Scenario**: Verifies the middleware's behavior under moderate concurrent load.+-- It sends 5 concurrent requests to an endpoint with a limit of 5. It then sends a sixth request.+-- It asserts that the first 5 requests succeed (200 OK) and the sixth is blocked (429 Too Many Requests).+testConcurrent+ :: Algorithm -- ^ The rate-limiting algorithm to test.+ -> (Request -> IO (Maybe Text)) -- ^ The function to identify the client.+ -> Assertion+testConcurrent algo identifier = do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 5+ , throttlePeriod = 60+ , throttleAlgorithm = algo+ , throttleIdentifier = identifier+ , throttleTokenBucketTTL = Just 3600+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ + -- Execute concurrent requests sequentially within the session+ let session = do+ responses <- replicateM 5 (srequest $ SRequest mkIPv4Request "")+ result6 <- srequest $ SRequest mkIPv4Request ""+ return (responses, result6)+ (responses, response6) <- runSession session app+ + -- Check that first 5 requests succeeded+ mapM_ (\(i, resp) -> assertEqual ("Request " ++ show i ++ " status") status200 (simpleStatus resp))+ (zip [1..5] responses)+ -- Check that 6th request was throttled+ assertEqual "Sixth request status after limit" status429 (simpleStatus response6)++-- | **Test Scenario**: Simulates a Denial-of-Service (DoS) attack with high concurrency.+-- It sends 15 concurrent requests to an endpoint with a limit of 10.+-- It asserts that some requests were successful, some were blocked, and the total processed+-- matches the number sent, ensuring the server remains stable.+testDoS+ :: Algorithm -- ^ The rate-limiting algorithm to test.+ -> (Request -> IO (Maybe Text)) -- ^ The function to identify the client.+ -> Assertion+testDoS algo identifier = do+ env <- initConfig (const defaultIPZone)+ let throttle = ThrottleConfig+ { throttleLimit = 10+ , throttlePeriod = 60+ , throttleAlgorithm = algo+ , throttleIdentifier = identifier+ , throttleTokenBucketTTL = Just 3600+ }+ env' <- addThrottle env (T.pack "test_throttle") throttle+ let app = attackMiddleware env' mockApp+ + -- Execute many requests sequentially to simulate DoS scenario+ let session = do+ responses <- replicateM 15 (srequest $ SRequest mkIPv4Request "")+ return responses+ responses <- runSession session app+ + -- Count how many succeeded (should be <= 10)+ let successCount = length $ filter (\resp -> simpleStatus resp == status200) responses+ let throttledCount = length $ filter (\resp -> simpleStatus resp == status429) responses+ + assertBool "Some requests should succeed" (successCount > 0)+ assertBool "Some requests should be throttled" (throttledCount > 0)+ assertEqual "Total requests processed" 15 (successCount + throttledCount)
+ test/Main.hs view
@@ -0,0 +1,569 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE NumericUnderscores #-}++{-|+Module : Main+Description : Main entry point for the rate limiter test suite.+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : stable+Portability : portable++This module aggregates all test suites for the Keter Rate Limiter library+and provides the main execution entry point. It includes general rate limiter tests,+IP zone functionality tests, and specific cache API tests.+-}+module Main (+ -- * Main Entry+ main+, mainTests+ -- * Test Suites+, rateLimiterTests+, ipZoneTests+, cacheApiTests+ -- * Test Cases+ -- ** General Rate Limiter Tests+, testFixedWindow+, testSlidingWindow+, testTokenBucket+, testLeakyBucket+, testPathSpecificThrottle+, testMultipleThrottles+, testOriginalResetAfterPeriod+, testTimeBasedReset+ -- ** IP Zone Tests+, testIPZoneIsolation+, testIPZoneDefaultFallback+, testIPZoneCacheResetAll+ -- ** Cache API Tests+, testIncStoreWithZone+, testIncStoreManual+, testReadWriteCacheWithZone+, testReadWriteCacheManual+ -- * Helpers+, mainTestGetRequestIPZone+, getRequestPath+, makeRequest+, mockSockAddr+, mockApp+, getResponseBody+, isRateLimited+, executeRequests+) where++import Control.Concurrent.STM (atomically, readTVar)+import Control.Concurrent (threadDelay)+import Control.Exception (try, SomeException)+import Control.Monad (when)+import Data.Maybe (isJust)+import Data.IORef (readIORef)+import Data.Cache (purgeExpired)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (testCase, assertEqual, assertFailure)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as TE+import Network.Wai (Request, Response, Application, requestMethod, rawPathInfo, requestHeaderHost, remoteHost, responseLBS, requestHeaders, defaultRequest)+import Network.HTTP.Types (methodGet)+import Network.Socket (SockAddr(..))+import Keter.RateLimiter.Cache ( Cache(..), InMemoryStore(..), Algorithm(..), makeCacheKey, readCache, createInMemoryStore, newCache, incrementCache, writeCache, deleteCache )+import Keter.RateLimiter.CacheWithZone ( incStoreWithZone, writeCacheWithZone, readCacheWithZone )+import Keter.RateLimiter.IPZones (ZoneSpecificCaches(..), IPZoneIdentifier, defaultIPZone)+import Keter.RateLimiter.WAI ( Env, ThrottleConfig(..), initConfig, addThrottle, instrument, envZoneCachesMap, cacheResetAll )+import qualified Keter.RateLimiter.RequestUtils as RequestUtils+import System.IO.Unsafe (unsafePerformIO)++-- Import test modules with qualified names to avoid ambiguity+import qualified TinyLRUTests+import Keter.RateLimiter.Cache.PurgeTests+import qualified Keter.RateLimiter.IPZonesTests+import qualified Keter.RateLimiter.LeakyBucketStateTests+import qualified Keter.RateLimiter.LeakyBucketTests+import qualified Keter.RateLimiter.NotificationTests+import qualified Keter.RateLimiter.SlidingWindowTests+import qualified Keter.RateLimiter.TokenBucketTests+import qualified Keter.RateLimiter.WAITests+import qualified Keter.RateLimiter.TokenBucketStateTests++-- IPs and Zone Identifiers for testing+testIPZoneA, testIPZoneB :: IPZoneIdentifier+testIPZoneA = Text.pack "testZoneA"+testIPZoneB = Text.pack "testZoneB"+ipForZoneA, ipForZoneB, ipForDefaultZone, genericTestIP, genericTestIP2 :: Text+ipForZoneA = "10.0.0.1"+ipForZoneB = "20.0.0.1"+ipForDefaultZone = "30.0.0.1"+genericTestIP = "192.168.1.1"+genericTestIP2 = "192.168.1.2"++-- | A test-specific helper to determine the 'IPZoneIdentifier' for a given request.+-- This function maps specific IP addresses to predefined zones for testing purposes.+-- It uses 'unsafePerformIO' for simplicity within the test context.+--+-- Note: Uses 'RequestUtils.getClientIP' which respects 'x-real-ip' and 'x-forwarded-for' headers.+mainTestGetRequestIPZone :: Request -> IPZoneIdentifier+mainTestGetRequestIPZone req =+ let ip = unsafePerformIO $ RequestUtils.getClientIP req+ in case ip of+ _ | ip == ipForZoneA -> testIPZoneA+ | ip == ipForZoneB -> testIPZoneB+ | ip == genericTestIP -> defaultIPZone+ | ip == genericTestIP2 -> defaultIPZone+ | ip == ipForDefaultZone -> defaultIPZone+ | otherwise -> defaultIPZone++-- | A simple helper to extract the request path as 'Text'.+getRequestPath :: Request -> Text+getRequestPath req = Text.pack $ show $ rawPathInfo req++-- | Creates a mock 'Request' for testing purposes.+-- The request is configured with a specified IP address (via 'x-real-ip' header) and path.+makeRequest+ :: Text -- ^ The client IP address.+ -> Text -- ^ The request path.+ -> Request+makeRequest ip path = defaultRequest+ { requestMethod = methodGet+ , rawPathInfo = TE.encodeUtf8 path+ , requestHeaderHost = Just "example.com"+ , remoteHost = mockSockAddr ip+ , requestHeaders = [("x-real-ip", TE.encodeUtf8 ip)]+ }++-- | Creates a mock 'SockAddr' for a given IP address.+-- This is a simplified implementation for testing and always returns 'SockAddrInet'.+mockSockAddr+ :: Text -- ^ The IP address (not used in this simplified version).+ -> SockAddr+mockSockAddr _ = SockAddrInet 80 0 -- Simplified for testing++-- | A mock WAI 'Application' that always returns a 200 OK response with a "Success" body.+mockApp :: Application+mockApp _ respond = respond $ responseLBS+ (toEnum 200)+ [("Content-Type", "text/plain")]+ "Success"++-- | A simplified helper to extract the response body.+-- In this test suite, it always returns "Success".+getResponseBody+ :: Response+ -> IO Text+getResponseBody _ = return "Success" -- Simplified++-- | A simplified helper to check if a response was rate-limited.+-- In this test suite, it is not implemented and always returns 'False'.+isRateLimited+ :: Response+ -> Bool+isRateLimited _ = False -- Simplified++-- | Executes a series of requests against the rate-limiting middleware and asserts that the responses match expectations.+executeRequests+ :: Env -- ^ The rate limiter environment.+ -> [Request] -- ^ A list of requests to execute.+ -> [Text] -- ^ A list of expected response bodies ("Success" or "Too Many Requests").+ -> IO ()+executeRequests env requests expectedResponses = do+ actualResponses <- mapM (processRequest env) requests+ assertEqual "Responses should match expected" expectedResponses actualResponses+ where+ processRequest :: Env -> Request -> IO Text+ processRequest testEnv req = do+ blocked <- instrument testEnv req+ return $ if blocked then "Too Many Requests" else "Success"++-- | The main entry point for running the test suite.+-- It aggregates all tests and runs them using Tasty.+main :: IO ()+main = do+ result <- try @SomeException $ defaultMain mainTests+ case result of+ Left ex -> putStrLn $ "Test suite failed with exception: " ++ show ex+ Right () -> putStrLn "Test suite completed successfully"++-- | The root 'TestTree' that combines all test groups from the library.+mainTests :: TestTree+mainTests = testGroup "Rate Limiter and IP Zones Tests"+ [ TinyLRUTests.tests+ , Keter.RateLimiter.NotificationTests.tests+ , testBackgroundPurge+ , rateLimiterTests+ , ipZoneTests+ , cacheApiTests+ , Keter.RateLimiter.IPZonesTests.tests+ , Keter.RateLimiter.LeakyBucketStateTests.tests+ , Keter.RateLimiter.TokenBucketStateTests.tests+ , Keter.RateLimiter.LeakyBucketTests.tests+ , Keter.RateLimiter.SlidingWindowTests.tests+ , Keter.RateLimiter.TokenBucketTests.tests+ , Keter.RateLimiter.WAITests.tests+ ]++-- | Tests for general rate-limiting functionality using the default IP zone.+rateLimiterTests :: TestTree+rateLimiterTests = testGroup "General Rate Limiter Tests (with Default IP Zone)"+ [ testCase "Fixed Window Rate Limiting" testFixedWindow+ , testCase "Sliding Window Rate Limiting" testSlidingWindow+ , testCase "Token Bucket Rate Limiting" testTokenBucket+ , testCase "Leaky Bucket Rate Limiting" testLeakyBucket+ , testCase "Path-specific Rate Limiting" testPathSpecificThrottle+ , testCase "Multiple Throttles" testMultipleThrottles+ , testCase "Original Reset After Period (New Env)" testOriginalResetAfterPeriod+ , testCase "Time-based Reset (Same Env)" testTimeBasedReset+ ]++-- | Tests the Fixed Window algorithm. It expects the first 3 requests to succeed and the next 2 to be blocked.+testFixedWindow :: IO ()+testFixedWindow = do+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 3+ , throttlePeriod = 10+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "test-throttle" throttleConfig+ let requests = replicate 5 $ makeRequest genericTestIP "/test"+ expectedResponses = replicate 3 "Success" ++ replicate 2 "Too Many Requests"+ executeRequests envWithThrottle requests expectedResponses++-- | Tests the Sliding Window algorithm. It expects the first 3 requests to succeed and subsequent requests within the window to be blocked.+-- It also verifies that requests are allowed again after the window has passed.+testSlidingWindow :: IO ()+testSlidingWindow = do+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 3+ , throttlePeriod = 10+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "test-throttle" throttleConfig+ let requests = replicate 5 $ makeRequest genericTestIP "/test"+ expectedResponses = replicate 3 "Success" ++ replicate 2 "Too Many Requests"+ executeRequests envWithThrottle requests expectedResponses+ -- Test partial window overlap+ threadDelay (5_000_000) -- Wait 5s (half period)+ blocked <- instrument envWithThrottle (makeRequest genericTestIP "/test")+ assertEqual "Request in overlapping window should be blocked" True blocked+ threadDelay (6_500_000) -- Wait 5.5s (past period)+ blocked' <- instrument envWithThrottle (makeRequest genericTestIP "/test")+ assertEqual "Request after full period should succeed" False blocked'++-- | Tests the Token Bucket algorithm, including TTL-based bucket expiry and refill.+testTokenBucket :: IO ()+testTokenBucket = do+ let ttlSeconds = 3+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 3+ , throttlePeriod = 1+ , throttleAlgorithm = TokenBucket+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Just ttlSeconds+ }+ envWithThrottle <- addThrottle env "test-throttle" throttleConfig+ let requests = replicate 5 $ makeRequest genericTestIP "/test"+ expectedResponses = replicate 3 "Success" ++ replicate 2 "Too Many Requests"+ executeRequests envWithThrottle requests expectedResponses+ threadDelay ((ttlSeconds + 1) * 1_000_000)+ blocked <- instrument envWithThrottle (makeRequest genericTestIP "/test")+ assertEqual "Request should succeed after TTL expiry and refill" False blocked+ b1 <- instrument envWithThrottle (makeRequest genericTestIP "/test")+ b2 <- instrument envWithThrottle (makeRequest genericTestIP "/test")+ b3 <- instrument envWithThrottle (makeRequest genericTestIP "/test")+ assertEqual "Next requests after refill" [False, False, True] [b1, b2, b3]++-- | Tests the Leaky Bucket algorithm, verifying burst tolerance and the steady leak rate.+testLeakyBucket :: IO ()+testLeakyBucket = do+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 3+ , throttlePeriod = 10+ , throttleAlgorithm = LeakyBucket+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "test-throttle" throttleConfig+ let req = makeRequest genericTestIP "/test"+ -- Test burst behavior+ blocked1 <- instrument envWithThrottle req+ blocked2 <- instrument envWithThrottle req+ blocked3 <- instrument envWithThrottle req+ assertEqual "First 3 requests succeed" [False, False, False] [blocked1, blocked2, blocked3]+ blocked4 <- instrument envWithThrottle req+ assertEqual "Fourth request blocked" True blocked4+ -- Test steady leak rate (1 token every ~3.33s for limit=3, period=10)+ threadDelay (3_500_000) -- Wait ~3.5s for 1 token to leak+ blocked5 <- instrument envWithThrottle req+ assertEqual "Request succeeds after leak" False blocked5+ -- Verify subsequent request is blocked+ blocked6 <- instrument envWithThrottle req+ assertEqual "Next request blocked" True blocked6++-- | Tests that rate limiting can be applied to specific request paths.+-- In this case, only requests to "/login" are throttled.+testPathSpecificThrottle :: IO ()+testPathSpecificThrottle = do+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 10+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> do+ ip <- RequestUtils.getClientIP req+ return $ if getRequestPath req == "\"/login\""+ then Just (ip <> Text.pack ":" <> getRequestPath req)+ else Nothing+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "login-throttle" throttleConfig+ let loginRequests = replicate 3 $ makeRequest genericTestIP "/login"+ homeRequests = replicate 3 $ makeRequest genericTestIP "/home"+ expectedLoginResponses = replicate 2 "Success" ++ ["Too Many Requests"]+ expectedHomeResponses = replicate 3 "Success"+ executeRequests envWithThrottle loginRequests expectedLoginResponses+ executeRequests envWithThrottle homeRequests expectedHomeResponses++-- | Tests the application of multiple, independent throttles.+-- An IP-based throttle and a path-specific throttle are applied simultaneously.+testMultipleThrottles :: IO ()+testMultipleThrottles = do+ env <- initConfig mainTestGetRequestIPZone+ let ipThrottleConfig = ThrottleConfig+ { throttleLimit = 5+ , throttlePeriod = 10+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ loginThrottleConfig = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 10+ , throttleAlgorithm = SlidingWindow+ , throttleIdentifier = \req -> do+ ip <- RequestUtils.getClientIP req+ return $ if getRequestPath req == "\"/login\""+ then Just (ip <> Text.pack ":" <> getRequestPath req)+ else Nothing+ , throttleTokenBucketTTL = Nothing+ }+ envWithIpThrottle <- addThrottle env "ip-throttle" ipThrottleConfig+ envWithBothThrottles <- addThrottle envWithIpThrottle "login-throttle" loginThrottleConfig+ let requests = [ makeRequest genericTestIP "/login"+ , makeRequest genericTestIP "/login"+ , makeRequest genericTestIP "/login" -- Blocked by login throttle+ , makeRequest genericTestIP "/home"+ , makeRequest genericTestIP "/about"+ , makeRequest genericTestIP "/contact" -- Blocked by IP throttle (6th req total)+ ]+ expectedResponses = [ "Success", "Success", "Too Many Requests"+ , "Success", "Success", "Too Many Requests"+ ]+ executeRequests envWithBothThrottles requests expectedResponses++-- | Verifies that creating a new environment effectively resets rate-limiting state.+testOriginalResetAfterPeriod :: IO ()+testOriginalResetAfterPeriod = do+ env1 <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 2+ , throttlePeriod = 1+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle1 <- addThrottle env1 "test-throttle" throttleConfig+ let requests1 = replicate 3 $ makeRequest genericTestIP "/test"+ expectedResponses1 = replicate 2 "Success" ++ ["Too Many Requests"]+ executeRequests envWithThrottle1 requests1 expectedResponses1+ threadDelay (1_100_000)+ env2 <- initConfig mainTestGetRequestIPZone+ envWithThrottle2 <- addThrottle env2 "test-throttle" throttleConfig+ let requests2 = replicate 3 $ makeRequest genericTestIP2 "/test"+ expectedResponses2 = replicate 2 "Success" ++ ["Too Many Requests"]+ executeRequests envWithThrottle2 requests2 expectedResponses2++-- | Verifies that counters for Fixed Window algorithm expire correctly over time within the same environment.+testTimeBasedReset :: IO ()+testTimeBasedReset = do+ let periodSec = 1+ limit = 1+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = limit+ , throttlePeriod = periodSec+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "reset-throttle" throttleConfig+ let req = makeRequest ipForZoneA "/reset_test"+ -- First request+ blocked1 <- instrument envWithThrottle req+ assertEqual "First request to zone A should succeed" False blocked1+ -- Second request (should be blocked)+ blocked2 <- instrument envWithThrottle req+ assertEqual "Second request to zone A should be blocked" True blocked2+ -- Wait for period + buffer+ threadDelay 1_500_000 -- 1.5 seconds to ensure expiration+ -- Debug cache state+ cachesMap <- readIORef (envZoneCachesMap envWithThrottle)+ zoneCaches <- case Map.lookup testIPZoneA cachesMap of+ Just caches -> return caches+ Nothing -> assertFailure "Zone caches not found" >> return undefined+ let cache :: Cache (InMemoryStore 'FixedWindow)+ cache = zscCounterCache zoneCaches+ key = makeCacheKey FixedWindow testIPZoneA (unsafePerformIO $ RequestUtils.getClientIP req)+ mVal <- readCache cache key :: IO (Maybe Int)+ when (isJust mVal) $ putStrLn $ "Cache value before third request: " ++ show mVal+ -- Try manually deleting if it exists+ when (isJust mVal) $ do+ putStrLn "Manually deleting expired key"+ deleteCache cache key+ -- Explicitly purge expired entries+ cache' <- atomically $ readTVar (case cacheStore cache of CounterStore ref -> ref)+ purgeExpired cache' -- Now in IO, not STM+ -- Third request (should succeed)+ blocked3 <- instrument envWithThrottle req+ assertEqual "Request after period to zone A should succeed" False blocked3++-- | Tests for verifying the functionality of IP Zones.+ipZoneTests :: TestTree+ipZoneTests = testGroup "IP Zone Functionality Tests"+ [ testCase "IP Zone Isolation" testIPZoneIsolation+ , testCase "IP Zone Default Fallback" testIPZoneDefaultFallback+ , testCase "Cache Reset All Across Zones" testIPZoneCacheResetAll+ ]++-- | Verifies that different IP zones are rate-limited independently.+testIPZoneIsolation :: IO ()+testIPZoneIsolation = do+ let limit = 1+ period = 10+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = limit+ , throttlePeriod = period+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "test-throttle" throttleConfig+ let reqA1 = makeRequest ipForZoneA "/test"+ reqB1 = makeRequest ipForZoneB "/test"+ b1 <- instrument envWithThrottle reqA1+ assertEqual "Zone A first request allowed" False b1+ b2 <- instrument envWithThrottle reqA1+ assertEqual "Zone A second request blocked" True b2+ b3 <- instrument envWithThrottle reqB1+ assertEqual "Zone B first request allowed" False b3++-- | Verifies that IPs not matching a specific zone fall back to the default zone and are tracked together.+testIPZoneDefaultFallback :: IO ()+testIPZoneDefaultFallback = do+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 1+ , throttlePeriod = 10+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "test-throttle" throttleConfig+ let reqDefault = makeRequest ipForDefaultZone "/test"+ reqGeneric = makeRequest genericTestIP "/test"+ b1 <- instrument envWithThrottle reqDefault+ assertEqual "Default zone first request allowed" False b1+ b2 <- instrument envWithThrottle reqGeneric+ assertEqual "Generic IP default zone first request allowed" False b2++-- | Verifies that 'cacheResetAll' clears the state for all defined IP zones.+testIPZoneCacheResetAll :: IO ()+testIPZoneCacheResetAll = do+ env <- initConfig mainTestGetRequestIPZone+ let throttleConfig = ThrottleConfig+ { throttleLimit = 1+ , throttlePeriod = 10+ , throttleAlgorithm = FixedWindow+ , throttleIdentifier = \req -> Just <$> RequestUtils.getClientIP req+ , throttleTokenBucketTTL = Nothing+ }+ envWithThrottle <- addThrottle env "test-throttle" throttleConfig+ let reqA = makeRequest ipForZoneA "/test"+ reqB = makeRequest ipForZoneB "/test"+ _ <- instrument envWithThrottle reqA+ _ <- instrument envWithThrottle reqB+ cacheResetAll envWithThrottle+ b1 <- instrument envWithThrottle reqA+ b2 <- instrument envWithThrottle reqB+ assertEqual "Zone A after reset" False b1+ assertEqual "Zone B after reset" False b2++-- | Tests for the cache API, including both the zone-aware wrappers and direct cache access.+cacheApiTests :: TestTree+cacheApiTests = testGroup "Cache API (wrappers and customisable)"+ [ testCase "incStoreWithZone wrapper increments independently" testIncStoreWithZone+ , testCase "incStore manual key increments independently" testIncStoreManual+ , testCase "readCacheWithZone and writeCacheWithZone" testReadWriteCacheWithZone+ , testCase "readCache and writeCache manual" testReadWriteCacheManual+ ]++-- | Tests that the 'incStoreWithZone' wrapper correctly increments a counter.+testIncStoreWithZone :: IO ()+testIncStoreWithZone = do+ store <- createInMemoryStore @'FixedWindow+ let cache :: Cache (InMemoryStore 'FixedWindow)+ cache = newCache FixedWindow store+ ipZone = "zoneX"+ userKey = "userX"+ v1 <- incStoreWithZone cache ipZone userKey 10 :: IO Int+ v2 <- incStoreWithZone cache ipZone userKey 10 :: IO Int+ assertEqual "First increment (wrapper)" 1 v1+ assertEqual "Second increment (wrapper)" 2 v2++-- | Tests that the lower-level 'incrementCache' function works correctly with a manually constructed key.+testIncStoreManual :: IO ()+testIncStoreManual = do+ store <- createInMemoryStore @'FixedWindow+ let cache :: Cache (InMemoryStore 'FixedWindow)+ cache = newCache FixedWindow store+ customKey = "zoneY:userY:extra"+ v1 <- incrementCache cache customKey 10 :: IO Int+ v2 <- incrementCache cache customKey 10 :: IO Int+ assertEqual "First increment (manual)" 1 v1+ assertEqual "Second increment (manual)" 2 v2++-- | Tests the 'writeCacheWithZone' and 'readCacheWithZone' wrapper functions.+testReadWriteCacheWithZone :: IO ()+testReadWriteCacheWithZone = do+ store <- createInMemoryStore @'FixedWindow+ let cache :: Cache (InMemoryStore 'FixedWindow)+ cache = newCache FixedWindow store+ ipZone = "zoneZ"+ userKey = "userZ"+ val = 42 :: Int+ writeCacheWithZone cache ipZone userKey val 10+ mVal <- readCacheWithZone cache ipZone userKey :: IO (Maybe Int)+ assertEqual "Read after write (wrapper)" (Just val) mVal++-- | Tests the lower-level 'writeCache' and 'readCache' functions with a manually constructed key.+testReadWriteCacheManual :: IO ()+testReadWriteCacheManual = do+ store <- createInMemoryStore @'FixedWindow+ let cache :: Cache (InMemoryStore 'FixedWindow)+ cache = newCache FixedWindow store+ customKey = "zoneW:userW:custom"+ val = 99 :: Int+ writeCache cache customKey val 10+ mVal <- readCache cache customKey :: IO (Maybe Int)+ assertEqual "Read after write (manual)" (Just val) mVal
+ test/TinyLRUTests.hs view
@@ -0,0 +1,416 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE ScopedTypeVariables #-}++{-|+Module : TinyLRUTests+Description : Test suite for the TinyLRU cache implementation using tasty and tasty-hunit+Copyright : (c) 2025 Oleksandr Zhabenko+License : MIT+Maintainer : oleksandr.zhabenko@yahoo.com+Stability : experimental+Portability : POSIX++This module provides a comprehensive suite of unit tests for the 'TinyLRUCache' data structure.+It verifies correctness, eviction policies, TTL behavior, and integration with the rate-limiter's+unified cache interface. It also includes concurrency tests to validate thread safety.+-}+module TinyLRUTests where++import Test.Tasty+import Test.Tasty.HUnit+import Control.Concurrent.STM+import Control.Concurrent.Async (replicateConcurrently)+import Data.Text (pack)+import qualified Data.ByteString as BS+import Data.Aeson (decodeStrict)+import System.Clock (TimeSpec(..), Clock(Monotonic), getTime)+import Data.TinyLRU+import Keter.RateLimiter.Cache+ ( Algorithm(..)+ , Cache+ , InMemoryStore(..)+ , newCache+ , createInMemoryStore+ , readCache+ , writeCache+ , deleteCache+ , cacheStore+ )+import Keter.RateLimiter.CacheWithZone+ ( readCacheWithZone+ , writeCacheWithZone+ , deleteCacheWithZone+ )+import Control.Monad+import Control.Concurrent (threadDelay)+import qualified StmContainers.Map as Map+import System.Random (randomRIO)+import Data.Maybe (isJust)++-- | Initializes a new 'TinyLRUCache' with the given capacity.+createTinyLRU :: Int -> IO (TinyLRUCache s)+createTinyLRU capacity = atomically $ initTinyLRU capacity++-- | Creates an in-memory store for the 'TinyLRU' algorithm.+createTinyLRUStore :: IO (InMemoryStore 'TinyLRU)+createTinyLRUStore = TinyLRUStore <$> (atomically $ newTVar =<< initTinyLRU 3)++-- | Constructs a typed 'Cache' with the 'TinyLRU' algorithm.+newTinyLRUCache :: IO (Cache (InMemoryStore 'TinyLRU))+newTinyLRUCache = do+ store <- createInMemoryStore @'TinyLRU+ return $ newCache TinyLRU store++-- | Safely accesses the internal 'TVar' from an 'InMemoryStore' without unwrapping it directly.+withTinyLRUStore+ :: InMemoryStore 'TinyLRU+ -> (forall s. TVar (TinyLRUCache s) -> STM a)+ -> STM a+withTinyLRUStore (TinyLRUStore tvarCache) action = action tvarCache++-- | Simulates an incoming request using the TinyLRU-based rate limiter.+-- Returns whether the request is allowed under the given limit and period.+allowRequest+ :: Cache (InMemoryStore 'TinyLRU)+ -> String -- ^ Key (e.g. user identifier)+ -> Int -- ^ Limit+ -> Int -- ^ Period (seconds)+ -> IO Bool+allowRequest cache key limit period = do+ now <- getTime Monotonic+ let textKey = pack key+ store = cacheStore cache+ atomically $ withTinyLRUStore store $ \tvarCache -> do+ actualCache <- readTVar tvarCache+ allowRequestTinyLRU now actualCache textKey limit period++-- | Advances a 'TimeSpec' by a given number of seconds.+advanceTime :: TimeSpec -> Int -> TimeSpec+advanceTime (TimeSpec secs nsecs) seconds =+ TimeSpec (secs + fromIntegral seconds) nsecs++-- | Validates the internal consistency of the LRU list (double-linked list).+-- Ensures that pointers are bidirectionally correct and size matches map.+checkListConsistency :: TinyLRUCache s -> STM ()+checkListConsistency cache = do+ list <- readTVar (lruList cache)+ cacheSize <- Map.size (lruCache cache)+ let checkNodes Nothing count = return count+ checkNodes (Just nodeRef) count = do+ node <- readTVar nodeRef+ case nodePrev node of+ Just prevRef -> do+ prev <- readTVar prevRef+ unless (nodeNext prev == Just nodeRef) $+ error "Inconsistent nodePrev -> nodeNext link"+ Nothing -> unless (lruHead list == Just nodeRef) $+ error "Head node has no prev but is not lruHead"+ checkNodes (nodeNext node) (count + 1)+ count <- checkNodes (lruHead list) 0+ unless (count == cacheSize) $+ error $ "Cache size (" ++ show cacheSize ++ ") does not match list length (" ++ show count ++ ")"+ case lruTail list of+ Just tailRef -> do+ tail <- readTVar tailRef+ unless (nodeNext tail == Nothing) $+ error "Tail node has a next node"+ Nothing -> unless (cacheSize == 0) $+ error "Empty tail with non-empty cache"++-- | Convenience wrapper to validate consistency of a wrapped 'TinyLRUCache'.+checkWrappedListConsistency :: Cache (InMemoryStore 'TinyLRU) -> STM ()+checkWrappedListConsistency cache = do+ let store = cacheStore cache+ withTinyLRUStore store $ \tvarCache -> do+ actualCache <- readTVar tvarCache+ checkListConsistency actualCache++-- | Safe placeholder for printing a 'TVar' without leaking memory content.+showTVar :: TVar a -> String+showTVar _ = "<TVar>"++-- | Pretty-print representation of optional 'TVar' references for assertions.+showMaybeTVar :: Maybe (TVar (LRUNode s)) -> String+showMaybeTVar Nothing = "Nothing"+showMaybeTVar (Just _) = "Just <TVar>"++-- | The full test suite for TinyLRU-based rate limiting and cache behavior.+--+-- Includes:+--+-- * Cache initialization+-- * TTL expiration+-- * LRU eviction+-- * Edge cases (empty keys, long keys, 0/negative TTL)+-- * Concurrent access and reset+-- * Integration with generic cache interfaces (read/write/delete)+-- * Zone-aware key operations+-- * Inconsistent or corrupted JSON data handling+--+-- Use this test tree in your project's test runner:+--+-- > defaultMain TinyLRUTests.tests+tests :: TestTree+tests = testGroup "TinyLRU Tests"+ [ testCase "Initialize TinyLRU" $ do+ cache <- createTinyLRU 3+ size <- atomically $ Map.size (lruCache cache)+ list <- atomically $ readTVar (lruList cache)+ assertEqual "Cache should be empty" 0 size+ -- Fixed: Use custom assertion instead of assertEqual for TVar+ let headStr = showMaybeTVar (lruHead list)+ assertEqual "Head should be Nothing" "Nothing" headStr+ let tailStr = showMaybeTVar (lruTail list)+ assertEqual "Tail should be Nothing" "Nothing" tailStr++ , testCase "Access: Cache hit and miss" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ -- Cache miss+ result1 <- atomically $ access now "key1" (42 :: Int) 60 cache+ assertEqual "Cache miss returns Just value" (Just 42) result1+ size1 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 1" 1 size1+ -- Cache hit+ result2 <- atomically $ access now "key1" (99 :: Int) 60 cache+ assertEqual "Cache hit returns original value" (Just 42) result2+ atomically $ checkListConsistency cache++ , testCase "Access: TTL expiration" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ _ <- atomically $ access now "key1" (42 :: Int) 5 cache+ size1 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 1" 1 size1+ -- Advance time beyond TTL (5 seconds)+ let now' = advanceTime now 6+ result <- atomically $ access now' "key1" (99 :: Int) 5 cache+ assertEqual "Expired key returns Just new value" (Just 99) result+ size2 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 1 after expiration" 1 size2+ atomically $ checkListConsistency cache++ , testCase "Access: LRU eviction" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ _ <- atomically $ access now "key1" (1 :: Int) 60 cache+ _ <- atomically $ access now "key2" (2 :: Int) 60 cache+ _ <- atomically $ access now "key3" (3 :: Int) 60 cache+ size1 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 3" 3 size1+ -- Add a fourth key, should evict key1+ _ <- atomically $ access now "key4" (4 :: Int) 60 cache+ size2 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 3 after eviction" 3 size2+ result <- atomically $ access now "key1" (99 :: Int) 60 cache+ assertEqual "Evicted key returns Just new value" (Just 99) result+ atomically $ checkListConsistency cache++ , testCase "Access: Invalid key" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ result <- atomically $ access now "" (42 :: Int) 60 cache+ assertEqual "Empty key returns Nothing" Nothing result+ let longKey = pack $ replicate 257 'a'+ result' <- atomically $ access now longKey (42 :: Int) 60 cache+ assertEqual "Long key returns Nothing" Nothing result'+ atomically $ checkListConsistency cache++ , testCase "AllowRequestTinyLRU: Rate limiting" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ allowed1 <- atomically $ allowRequestTinyLRU now cache "key1" 2 5+ assertBool "First request allowed" allowed1+ allowed2 <- atomically $ allowRequestTinyLRU now cache "key1" 2 5+ assertBool "Second request allowed" allowed2+ allowed3 <- atomically $ allowRequestTinyLRU now cache "key1" 2 5+ assertBool "Third request denied" (not allowed3)+ -- Advance time beyond period (5 seconds)+ let now' = advanceTime now 6+ allowed4 <- atomically $ allowRequestTinyLRU now' cache "key1" 2 5+ assertBool "Request allowed after period" allowed4+ atomically $ checkListConsistency cache++ , testCase "AllowRequestTinyLRU: High load" $ do+ cache <- createTinyLRU 100+ now <- getTime Monotonic+ forM_ [1..100] $ \i -> do+ let key = pack $ "key" ++ show i+ allowed <- atomically $ allowRequestTinyLRU now cache key 2 5+ assertBool ("First request for " ++ show key) allowed+ allowed <- atomically $ allowRequestTinyLRU now cache "key1" 2 5+ assertBool "Second request for key1 allowed" allowed+ allowed' <- atomically $ allowRequestTinyLRU now cache "key1" 2 5+ assertBool "Third request for key1 denied" (not allowed')+ atomically $ checkListConsistency cache++ , testCase "ResetTinyLRU" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ _ <- atomically $ access now "key1" (1 :: Int) 60 cache+ _ <- atomically $ access now "key2" (2 :: Int) 60 cache+ size1 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 2" 2 size1+ atomically $ resetTinyLRU cache+ size2 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 0 after reset" 0 size2+ list <- atomically $ readTVar (lruList cache)+ -- Fixed: Use custom assertion instead of assertEqual for TVar+ let headStr = showMaybeTVar (lruHead list)+ assertEqual "Head is Nothing after reset" "Nothing" headStr+ let tailStr = showMaybeTVar (lruTail list)+ assertEqual "Tail is Nothing after reset" "Nothing" tailStr+ atomically $ checkListConsistency cache++ , testCase "Integration with Cache" $ do+ cache <- newTinyLRUCache+ now <- getTime Monotonic+ allowed1 <- allowRequest cache "key1" 2 5+ assertBool "First request allowed" allowed1+ allowed2 <- allowRequest cache "key1" 2 5+ assertBool "Second request allowed" allowed2+ allowed3 <- allowRequest cache "key1" 2 5+ assertBool "Third request denied" (not allowed3)+ threadDelay 6000000 -- Wait 6 seconds (> period of 5)+ now' <- getTime Monotonic+ allowed4 <- allowRequest cache "key1" 2 5+ assertBool "Request allowed after expiration" allowed4+ atomically $ checkWrappedListConsistency cache++ , testCase "Concurrent Access" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ let keys = ["key1", "key2", "key3"]+ results <- replicateConcurrently 100 $ do+ key <- (keys !!) <$> randomRIO (0, 2)+ atomically $ access now key (42 :: Int) 60 cache+ let successes = length $ filter isJust results+ assertEqual "All concurrent accesses succeed" 100 successes+ size <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is at most 3" 3 size+ atomically $ checkListConsistency cache++ , testCase "Concurrent AllowRequestTinyLRU" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ results <- replicateConcurrently 100 $ atomically $ allowRequestTinyLRU now cache "key1" 10 60+ let allowed = length $ filter id results+ assertEqual "Exactly 10 requests allowed" 10 allowed+ count <- atomically $ do+ maybeNodeRef <- Map.lookup "key1" (lruCache cache)+ case maybeNodeRef of+ Just nodeRef -> do+ node <- readTVar nodeRef+ return $ maybe 0 id (decodeStrict (nodeValue node) :: Maybe Int)+ Nothing -> return 0+ assertEqual "Count is 10" 10 count+ atomically $ checkListConsistency cache++ , testCase "Concurrent Reset and Access" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ results <- replicateConcurrently 50 $ do+ -- Fixed: Add explicit type annotation for randomRIO+ action <- randomRIO (0, 1 :: Int)+ if action == 0+ then atomically $ access now "key1" (42 :: Int) 60 cache+ else do+ atomically $ resetTinyLRU cache+ return Nothing+ size <- atomically $ Map.size (lruCache cache)+ assertBool "Cache size is 0 or 1" (size <= 1)+ atomically $ checkListConsistency cache++ , testCase "Access: Zero and Negative TTL" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ result1 <- atomically $ access now "key1" (42 :: Int) 0 cache+ assertEqual "Zero TTL returns Just value" (Just 42) result1+ result2 <- atomically $ access now "key1" (99 :: Int) 0 cache+ assertEqual "Zero TTL hit returns original value" (Just 42) result2+ result3 <- atomically $ access now "key2" (43 :: Int) (-1) cache+ assertEqual "Negative TTL returns Just value" (Just 43) result3+ atomically $ checkListConsistency cache++ -- FIXED: Use deleteKey instead of Map.delete to properly clean up both Map and List+ , testCase "Delete Operation" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ _ <- atomically $ access now "key1" (42 :: Int) 60 cache+ size1 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 1" 1 size1+ -- FIXED: Use deleteKey instead of Map.delete+ atomically $ deleteKey "key1" cache+ size2 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 0 after delete" 0 size2+ list <- atomically $ readTVar (lruList cache)+ -- Fixed: Use custom assertion instead of assertEqual for TVar+ let headStr = showMaybeTVar (lruHead list)+ assertEqual "Head is Nothing after delete" "Nothing" headStr+ let tailStr = showMaybeTVar (lruTail list)+ assertEqual "Tail is Nothing after delete" "Nothing" tailStr+ atomically $ checkListConsistency cache++ , testCase "Integration with Cache: Read, Write, Delete" $ do+ cache <- newTinyLRUCache+ writeCache cache "key1" (42 :: Int) 60+ result1 <- readCache cache "key1"+ assertEqual "Read returns Just 42" (Just 42) result1+ writeCache cache "key1" (99 :: Int) 60+ result2 <- readCache cache "key1"+ assertEqual "Write updates to 99" (Just 99) result2+ deleteCache cache "key1"+ result3 <- readCache cache "key1"+ assertEqual "Delete removes key" Nothing result3+ atomically $ checkWrappedListConsistency cache++ , testCase "Integration with Cache: Zone-Based Operations" $ do+ cache <- newTinyLRUCache+ writeCacheWithZone cache "192.168.1.1" "user1" (42 :: Int) 60+ result1 <- readCacheWithZone cache "192.168.1.1" "user1"+ assertEqual "Zone-based read returns Just 42" (Just 42) result1+ deleteCacheWithZone cache "192.168.1.1" "user1"+ result2 <- readCacheWithZone cache "192.168.1.1" "user1"+ assertEqual "Zone-based delete removes key" Nothing result2+ atomically $ checkWrappedListConsistency cache++ , testCase "LRU List: Single Node Eviction" $ do+ cache <- createTinyLRU 1+ now <- getTime Monotonic+ _ <- atomically $ access now "key1" (1 :: Int) 60 cache+ size1 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 1" 1 size1+ _ <- atomically $ access now "key2" (2 :: Int) 60 cache+ size2 <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 1 after eviction" 1 size2+ result <- atomically $ access now "key1" (99 :: Int) 60 cache+ assertEqual "Evicted key returns Just new value" (Just 99) result+ atomically $ checkListConsistency cache++ , testCase "LRU List: All Expired Nodes" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ _ <- atomically $ access now "key1" (1 :: Int) 5 cache+ _ <- atomically $ access now "key2" (2 :: Int) 5 cache+ let now' = advanceTime now 6+ _ <- atomically $ access now' "key3" (3 :: Int) 5 cache+ size <- atomically $ Map.size (lruCache cache)+ assertEqual "Cache size is 1 after all expire" 1 size+ result <- atomically $ access now' "key1" (99 :: Int) 5 cache+ assertEqual "Expired key returns Just new value" (Just 99) result+ atomically $ checkListConsistency cache++ , testCase "Error Handling: JSON Deserialization Failure" $ do+ cache <- createTinyLRU 3+ now <- getTime Monotonic+ nodeRef <- atomically $ addToFront now 60 cache "key1" (BS.pack [0xff, 0xff]) -- Invalid JSON+ atomically $ Map.insert nodeRef "key1" (lruCache cache)+ result <- atomically $ access now "key1" (42 :: Int) 60 cache+ assertEqual "Invalid JSON returns Just new value" (Just 42) result+ atomically $ checkListConsistency cache+ ]