keter-rate-limiting-plugin
keter-rate-limiting-plugin is a modern, high-performance, and highly customizable rate-limiting plugin for Keter. It addresses issue #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 and Ruby on 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:
build-depends:
, keter-rate-limiting-plugin
For Stack (package.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:
{-# 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:
{
"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:
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):
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
:
"default"
: counters are isolated per vhost (Host header). Good per-domain isolation.
"ip"
: counters are isolated per client IP zone. Good for IP fairness.
{ "header": "X-Header" }
: per-tenant/customer isolation via a header value.
throttles
: list of rules. Each rule:
name
: a label for logs/metrics.
limit
: integer capacity or max requests.
period
: seconds (window or refill/leak interval depending on algorithm).
algorithm
: one of FixedWindow | SlidingWindow | TokenBucket | LeakyBucket | TinyLRU
.
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.
token_bucket_ttl
: optional seconds; TokenBucket only (evicts idle buckets).
Choosing Algorithms
Rule of thumb for common scenarios:
-
FixedWindow
- When: Simple quotas (e.g., 100 req/min per IP).
- Pros: Simple, low overhead.
- Cons: Window boundary bursts possible.
- Use for: Public pages, basic protections.
-
SlidingWindow
- When: Smoother enforcement over time; avoid boundary spikes.
- Pros: More accurate rolling rate.
- Cons: More state churn than FixedWindow.
- Use for: API endpoints where fairness matters.
-
TokenBucket
- When: Allow short bursts but control average rate.
- Pros: Classic API limiter; bursty but bounded.
- Cons: Requires sensible period; consider TTL for idle buckets.
- Use for: Developer APIs, webhook receivers.
- Tip: Set token_bucket_ttl (e.g., 1800s) to evict idle buckets.
-
LeakyBucket
- When: Smooth out bursts to a steady outflow.
- Pros: Predictable, backpressure-like effect.
- Cons: Tuning capacity vs leak rate.
- Use for: Form submissions, login attempts.
-
TinyLRU
- When: Lightweight micro-throttling with tiny memory footprint.
- Pros: Very small, simple.
- Cons: Coarser control than others.
- Use for: Edge micro-protection, complementary limits.
Practical Patterns
- Path-specific throttles (e.g., login):
middleware:
- rate-limiter:
zone_by: default
throttles:
- name: "login"
limit: 5
period: 60
algorithm: SlidingWindow
identifier_by: ip+path
- API key quotas per tenant:
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:
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 }
- 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.
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.
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
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
]
}
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:
cabal test
or
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:
env <- initConfig getZoneFunction
env' <- addThrottle env "api" throttleConfig
let middleware = attackMiddleware env'
New recommended approach:
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