# 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 with HashMap-based lookups and IP zone isolation. This library is inspired by [rack-attack](https://github.com/rack/rack-attack) 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 with efficient HashMap-based zone lookups. - **Declarative Configuration**: Define throttling rules using JSON/YAML configuration with automatic serialization support. - **Flexible Client Identification**: Multiple strategies for identifying clients (IP, headers, cookies, combinations). - **Configurable Zone Derivation**: Flexible strategies for deriving IP zones from requests. - **WAI Middleware**: Integrates seamlessly as a middleware into any WAI application. - **Convenient and Customizable API**: - Use declarative configuration for common scenarios with automatic setup. - 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 and HashMap-based O(1) average-case lookups. - **Easy Integration**: Minimal code changes are required to get started. ## Why Use This Plugin? - **Scalability**: Per-zone caches with HashMap-based storage 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 with HashMap optimizations for high-concurrency workloads. - **Security**: Protects your application from abusive clients and denial-of-service attacks. - **Flexibility**: Choose between declarative configuration and full programmatic customization. - **Production-Ready**: Inspired by industry-standard tools, thoroughly documented, and designed for reliability with efficient data structures. - **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 ### Declarative Configuration (Recommended) The recommended approach uses declarative configuration that can be loaded from JSON or YAML files: ```haskell {-# LANGUAGE OverloadedStrings #-} import Keter.RateLimiter.WAI import Keter.RateLimiter.Cache (Algorithm(..)) import Network.Wai (responseLBS, Application) import Network.HTTP.Types (status200) import Network.Wai.Handler.Warp (run) -- 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. Define declarative configuration let config = RateLimiterConfig { rlZoneBy = ZoneIP -- Separate zones by client IP , rlThrottles = [ RLThrottle "api" 100 3600 FixedWindow IdIP Nothing -- 100 requests/hour by IP , RLThrottle "login" 5 300 TokenBucket IdIP (Just 600) -- 5 login attempts/5min by IP with 10min idle timeout ] } -- 2. Build middleware from configuration middleware <- buildRateLimiter config -- 3. Apply middleware to your application let appWithMiddleware = middleware myApp putStrLn "Server starting on port 8080..." run 8080 appWithMiddleware ``` ### JSON Configuration You can also load configuration from JSON files: ```json { "zone_by": "ip", "throttles": [ { "name": "api", "limit": 100, "period": 3600, "algorithm": "fixed_window", "identifier_by": "ip" }, { "name": "login", "limit": 5, "period": 300, "algorithm": "token_bucket", "identifier_by": "ip", "token_bucket_ttl": 600 } ] } ``` ### Advanced Programmatic Configuration For more control, you can build the environment programmatically: ```haskell import Keter.RateLimiter.WAI import Keter.RateLimiter.Cache (Algorithm(..)) import Keter.RateLimiter.IPZones (defaultIPZone) import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Types (hHost) main :: IO () main = do -- 1. Initialize environment with custom zone logic env <- initConfig $ \req -> case lookup hHost (requestHeaders req) of Just "api.example.com" -> "api_zone" Just "admin.example.com" -> "admin_zone" _ -> defaultIPZone -- 2. Add throttle configurations let apiThrottle = ThrottleConfig { throttleLimit = 1000 , throttlePeriod = 3600 , throttleAlgorithm = FixedWindow , throttleIdentifierBy = IdIP , throttleTokenBucketTTL = Nothing } let loginThrottle = ThrottleConfig { throttleLimit = 5 , throttlePeriod = 300 , throttleAlgorithm = TokenBucket , throttleIdentifierBy = IdIP , throttleTokenBucketTTL = Just 600 } env' <- addThrottle env "api" apiThrottle env'' <- addThrottle env' "login" loginThrottle -- 3. Create middleware let middleware = buildRateLimiterWithEnv env'' appWithMiddleware = middleware myApp putStrLn "Server starting on port 8080..." run 8080 appWithMiddleware ``` ## Configuration Reference ### Client Identification Strategies (`IdentifierBy`) - `IdIP` - Identify by client IP address - `IdIPAndPath` - Identify by IP address and request path - `IdIPAndUA` - Identify by IP address and User-Agent header - `IdHeader headerName` - Identify by custom header value - `IdCookie "session_id"` - Identify by cookie value - `IdHeaderAndIP headerName` - Identify by header value combined with IP ### Zone Derivation Strategies (`ZoneBy`) - `ZoneDefault` - All requests use the same cache (no zone separation) - `ZoneIP` - Separate zones by client IP address - `ZoneHeader headerName` - Separate zones by custom header value ### Rate Limiting Algorithms - **`FixedWindow`** - Traditional fixed-window counting - **`SlidingWindow`** - Precise sliding-window with timestamp tracking - **`TokenBucket`** - Allow bursts up to capacity, refill over time - **`LeakyBucket`** - Smooth rate limiting with configurable leak rate - **`TinyLRU`** - Least-recently-used eviction for memory efficiency ## Example Usage ## For the Keter Users (is expected to be introduced in keter-2.3.0, see the README there and / or Changelog file) ### Important notes Configure middleware in app bundles (config/keter.yaml), not in the global Keter daemon config. The global keter-config.yaml remains for listeners, TLS, ip-from-header, healthcheck-path, etc. Requests to healthcheck-path are never rate-limited. ### Quick Start Attach a rate-limiter to any stanza via a middleware list. Example bundle config (config/keter.yaml): ```yaml stanzas: - type: webapp exec: ./my-app hosts: ["www.example.com"] middleware: - rate-limiter: zone_by: default throttles: - name: "ip-basic" limit: 100 period: 60 algorithm: FixedWindow identifier_by: ip - type: reverse-proxy hosts: ["api.example.com"] to: "http://127.0.0.1:9000" middleware: - rate-limiter: zone_by: { header: "X-Tenant-ID" } throttles: - name: "tenant-api" limit: 1000 period: 3600 algorithm: SlidingWindow identifier_by: { header: "X-Api-Key" } - type: static-files hosts: ["static.example.com"] root: ./static middleware: - rate-limiter: zone_by: ip throttles: - name: "static-ip" limit: 300 period: 60 algorithm: LeakyBucket identifier_by: ip ``` Tip: You can stack multiple middleware blocks if you need different protections. They run in order. ### Field Reference * `rate-limiter`: top-level middleware key. * `zone_by`: 1. `"default"`: counters are isolated per vhost (Host header). Good per-domain isolation. 2. `"ip"`: counters are isolated per client IP zone. Good for IP fairness. 3. `{ "header": "X-Header" }`: per-tenant/customer isolation via a header value. * `throttles`: list of rules. Each rule: 1. `name`: a label for logs/metrics. 2. `limit`: integer capacity or max requests. 3. `period`: seconds (window or refill/leak interval depending on algorithm). 4. `algorithm`: one of `FixedWindow | SlidingWindow | TokenBucket | LeakyBucket | TinyLRU`. 5. `identifier_by`: * `"ip"`: identify by client IP (honors global ip-from-header). * `"ip+path"`: combine IP and path for path-specific throttles (e.g., /login). * `"ip+ua"`: combine IP and User-Agent. * `{ "header": "X-User" }`: identify by a header value. * `{ "cookie": "session" }`: identify by a cookie value. * `{ "header+ip": "X-Key" }`: combine header and IP. 6. `token_bucket_ttl`: optional seconds; TokenBucket only (evicts idle buckets). ### Choosing Algorithms Rule of thumb for common scenarios: * **FixedWindow** 1. When: Simple quotas (e.g., 100 req/min per IP). 2. Pros: Simple, low overhead. 3. Cons: Window boundary bursts possible. 4. Use for: Public pages, basic protections. * **SlidingWindow** 1. When: Smoother enforcement over time; avoid boundary spikes. 2. Pros: More accurate rolling rate. 3. Cons: More state churn than FixedWindow. 4. Use for: API endpoints where fairness matters. * **TokenBucket** 1. When: Allow short bursts but control average rate. 2. Pros: Classic API limiter; bursty but bounded. 3. Cons: Requires sensible period; consider TTL for idle buckets. 4. Use for: Developer APIs, webhook receivers. 5. Tip: Set token_bucket_ttl (e.g., 1800s) to evict idle buckets. * **LeakyBucket** 1. When: Smooth out bursts to a steady outflow. 2. Pros: Predictable, backpressure-like effect. 3. Cons: Tuning capacity vs leak rate. 4. Use for: Form submissions, login attempts. * **TinyLRU** 1. When: Lightweight micro-throttling with tiny memory footprint. 2. Pros: Very small, simple. 3. Cons: Coarser control than others. 4. Use for: Edge micro-protection, complementary limits. ### Practical Patterns * Path-specific throttles (e.g., login): ```yaml middleware: - rate-limiter: zone_by: default throttles: - name: "login" limit: 5 period: 60 algorithm: SlidingWindow identifier_by: ip+path ``` * API key quotas per tenant: ```yaml middleware: - rate-limiter: zone_by: { header: "X-Tenant-ID" } throttles: - name: "tenant-quota" limit: 1000 period: 3600 algorithm: TokenBucket identifier_by: { header: "X-Api-Key" } token_bucket_ttl: 1800 ``` * Mixed protections on the same host: ```yaml middleware: - rate-limiter: zone_by: default throttles: - { name: "global-ip", limit: 600, period: 600, algorithm: FixedWindow, identifier_by: ip } - rate-limiter: zone_by: default throttles: - { name: "login", limit: 5, period: 60, algorithm: SlidingWindow, identifier_by: ip+path } ``` * Static assets fairness: ```yaml - type: static-files hosts: ["cdn.example.com"] root: ./public middleware: - rate-limiter: zone_by: ip throttles: - { name: "cdn-ip", limit: 300, period: 60, algorithm: LeakyBucket, identifier_by: ip } ``` ### Global keter daemon settings impacting behavior (keter-config.yaml): * `ip-from-header`: influences throttles with `identifier_by: ip`. * `healthcheck-path`: this path is always allowed and never rate-limited. ### Operational Tips * Start with SlidingWindow or TokenBucket for APIs; FixedWindow for simple pages; add a strict path-specific rule for sensitive endpoints (/login, /password-reset). * Tune limit/period to real traffic; prefer longer periods with proportionally larger limits for smoother behavior. * If behind a load balancer/proxy, set ip-from-header: true in keter-config.yaml to honor X-Forwarded-For. * Keep healthcheck-path simple (e.g., /keter-health); it's always bypassed by the limiter. * For multi-tenant apps, use zone_by: { header: "X-Tenant-ID" } so each tenant's counters are isolated; pair with header/cookie identifiers that match your auth. * Use token_bucket_ttl to bound memory for TokenBucket. * Stacking throttles is common; the most restrictive one effectively governs. * Consider integrating limiter notifications with your logging/metrics. ### FAQ * **Should I configure middleware in the global Keter config?** No. Middleware is per-app in bundles (config/keter.yaml). The global file configures listeners, TLS, ip-from-header, and healthcheck-path. * **Does it work with HTTPS and multiple listeners?** Yes. The middleware is applied uniformly; rate limiting is agnostic to scheme. * **How do vhosts interact with rate limits?** With zone_by: default, counters are isolated per Host. Different hosts pointing to the same backend port don't share counters. If you'd like help choosing safe defaults for your workloads, open an issue with a brief description of your traffic patterns and endpoints. ## 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 while leveraging efficient HashMap-based zone lookups. ```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. -- Zone lookup uses HashMap for O(1) average performance. 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 while still benefiting from HashMap-optimized storage. ```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) ``` ### Multi-Algorithm Configuration Example ```haskell let config = RateLimiterConfig { rlZoneBy = ZoneHeader (hdr "X-Tenant-ID") -- Separate by tenant , rlThrottles = [ RLThrottle "api_burst" 100 60 TokenBucket IdIP (Just 300) , RLThrottle "api_sustained" 1000 3600 FixedWindow IdIP Nothing , RLThrottle "login" 5 300 LeakyBucket IdIP Nothing , RLThrottle "admin" 50 3600 SlidingWindow (IdHeader (hdr "X-Admin-Key")) Nothing , RLThrottle "lru_cache" 1000 60 TinyLRU IdIPAndPath Nothing ] } ``` ## Performance Characteristics This library is optimized for high-performance scenarios: - **HashMap-based zone caches**: O(1) average-case lookup for IP zone cache resolution - **HashMap-based throttle storage**: O(1) average-case retrieval of throttle configurations - **STM-based concurrent access**: Thread-safe operations with minimal contention - **Memory-efficient algorithms**: Automatic cleanup of expired entries across all rate limiting algorithms - **Scalable architecture**: Designed to handle thousands of concurrent requests with minimal overhead ## Testing This package includes an extensive test suite covering all supported rate-limiting algorithms, IP zone isolation, cache management, and HashMap-based performance optimizations. 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 with efficient lookups. - You value both declarative configuration and the ability to customize behavior as needed. - You need high-performance rate limiting that can scale to handle large numbers of concurrent requests and zones. ## Migration from Earlier Versions If you're upgrading from an earlier version that used the programmatic API, the declarative configuration approach is now recommended: **Old approach:** ```haskell env <- initConfig getZoneFunction env' <- addThrottle env "api" throttleConfig let middleware = attackMiddleware env' ``` **New recommended approach:** ```haskell let config = RateLimiterConfig { ... } middleware <- buildRateLimiter config ``` The old programmatic API is still fully supported for advanced use cases via `buildRateLimiterWithEnv` and related functions. ## License MIT License © 2025 Oleksandr Zhabenko ## References - [rack-attack (Ruby)](https://github.com/rack/rack-attack) - [keter (Haskell)](https://github.com/snoyberg/keter)