refrakt/rate_limit
Rate limiting middleware using a sliding window counter.
Experimental. This module is lightly used and may change or move to its own package; see https://github.com/raskell-io/refrakt/issues/28.
A single OTP actor keeps the request timestamps per client. Clients whose requests have all left their window are dropped by a periodic cleanup, so memory stays bounded by the number of recently active clients.
Usage
import refrakt/rate_limit
// Start the rate limiter (once at app startup)
let assert Ok(limiter) = rate_limit.start()
fn middleware(req, next) {
use <- rate_limit.check(
req,
limiter,
max: 100, // max requests
window_ms: 60_000, // per minute
key: rate_limit.by_forwarded_for(trusted_proxies: 1),
)
next(req)
}
Choosing the client key
Requests are counted per key, so the key must be something a client cannot change at will. Wisp does not expose the socket peer address, so you say where the client address comes from:
- Behind a reverse proxy (nginx, Caddy, a load balancer), use
by_forwarded_for(trusted_proxies: n)wherenis the number of proxies in front of the app. Each proxy appends the address it saw toX-Forwarded-For, so the client is thenth entry from the right. Anything further left was sent by the client and is ignored. Setncorrectly: too high and clients can pick their own key, too low and every client shares the proxy’s key. - Behind a proxy that sets a single header it always overwrites
(
CF-Connecting-IPon Cloudflare,Fly-Client-IPon Fly.io,X-Real-IPwith nginx’sproxy_set_header), useby_header("cf-connecting-ip"). Only do this when every request passes through that proxy; otherwise clients can send the header themselves. - Directly exposed (no proxy), forwarding headers come from the client and must not be trusted. Read the peer address from Mist before handing the request to Wisp and pass a key function that returns it:
// In main()
let handler = fn(req: request.Request(mist.Connection)) {
let client_ip = case mist.get_connection_info(req.body) {
Ok(info) -> mist.ip_address_to_string(info.ip_address)
Error(_) -> "unknown"
}
wisp_mist.handler(
router.handle_request(_, ctx, client_ip),
cfg.secret_key_base,
)(req)
}
// In the router
use <- rate_limit.check(req, limiter, max: 100, window_ms: 60_000, key: fn(_) {
client_ip
})
Any fn(Request) -> String works as a key, so you can also limit per
user or per API token.
Types
pub opaque type RateLimitMessage
A running rate limiter instance.
pub type RateLimiter =
process.Subject(RateLimitMessage)
Values
pub fn by_forwarded_for(
trusted_proxies trusted_proxies: Int,
) -> fn(request.Request(wisp.Connection)) -> String
Key requests by the client address that the app’s own reverse proxies
recorded in X-Forwarded-For: the trusted_proxiesth entry from the
right. Entries further left are client-supplied and ignored. Requests
without the header share the "unknown" key.
pub fn by_header(
name: String,
) -> fn(request.Request(wisp.Connection)) -> String
Key requests by a single-valued header that a trusted proxy always
sets, such as cf-connecting-ip. Header names are lowercase in Wisp.
Requests without the header share the "unknown" key.
pub fn check(
req: request.Request(wisp.Connection),
limiter: process.Subject(RateLimitMessage),
max max: Int,
window_ms window_ms: Int,
key key: fn(request.Request(wisp.Connection)) -> String,
next next: fn() -> response.Response(wisp.Body),
) -> response.Response(wisp.Body)
Check if a request is within the rate limit for its key. Returns 429 Too Many Requests if exceeded.
pub fn start() -> Result(
process.Subject(RateLimitMessage),
actor.StartError,
)
Start the rate limiter actor. Idle clients are forgotten once a minute.
pub fn start_with_cleanup_interval(
every interval_ms: Int,
) -> Result(process.Subject(RateLimitMessage), actor.StartError)
Start the rate limiter actor, dropping clients with no requests left in
their window every interval_ms milliseconds.
pub fn tracked_keys(
limiter: process.Subject(RateLimitMessage),
) -> Int
The number of clients the limiter currently remembers.