March Docs

RateLimit

lib/rate_limit.march — Sliding window rate limiter for Bastion.

Protects sensitive endpoints (login, register, password reset) from brute force and abuse using a sliding window counter stored in a Vault table.

Algorithm: Each (endpoint_key, client_key) pair maps to a list of timestamps (ms). On each request:

  1. Read the list from Vault; filter out timestamps older than the window.
  2. Append the current timestamp.
  3. Write back with a TTL of window_secs + 1.
  4. If length > limit → 429 Too Many Requests.

The filtered list serves as its own cleanup — old hits fall off naturally. Because the write always updates the TTL, entries for active callers remain in Vault; entries for quiet callers expire automatically.

Usage

-- Open a dedicated table at startup (in your supervisor or app.march):
let rate_table = Vault.new("rate_limits")

-- In a router handler:
match RateLimit.check(conn, rate_table, RateLimit.ip_key, 5, 60) do
Ok(conn)    -> AuthController.login(conn)
Error(conn) -> conn
end

-- Or use the middleware helper for clean pipeline composition:
conn
|> RateLimit.limit(rate_table, RateLimit.ip_key, 5, 60)
|> AuthController.login()

Headers set on every response (success and 429)

x-ratelimit-limit     — configured maximum
x-ratelimit-remaining — remaining requests in the current window
x-ratelimit-reset     — Unix timestamp (seconds) when the window resets

On 429 responses, retry-after is also set to the number of seconds until the oldest request in the window expires.

Functions

fncheckcheck(conn, table, key_fn, limit : Int, window_secs : Int)#

Check whether the request is within the rate limit.

  • table — Vault table for counter storage (see Vault.new)
  • key_fn — function from Conn to the rate limit key (e.g. RateLimit.ip_key)
  • limit — maximum number of requests allowed per window_secs
  • window_secs — rolling window duration in seconds
Returns `Ok(conn)` with rate limit headers on success, or `Error(conn)`
with a halted 429 response when the limit is exceeded.

    match RateLimit.check(conn, rate_table, RateLimit.ip_key, 5, 60) do
    Ok(conn)    -> handle(conn)
    Error(conn) -> conn
    end
fnip_keyip_key(conn) : String#

Built-in key function: use the client IP address as the rate limit key.

Uses the transport-level peer address of the socket (`HttpServer.peer_addr`),
which cannot be spoofed by request headers.  Falls back to `"unknown"` when
no live socket backs the conn (e.g. in tests).

`X-Forwarded-For` is deliberately NOT consulted here: any direct-to-internet
client can send that header and would otherwise mint a fresh rate-limit
bucket per request, bypassing the limiter entirely.  If your app runs behind
a reverse proxy you trust, use `ip_key_forwarded/2` instead and list the
proxy addresses explicitly.
fnip_key_forwardedip_key_forwarded(conn, trusted_proxies : List(String)) : String#

Key function for deployments behind trusted reverse proxies.

When (and only when) the socket peer is one of `trusted_proxies`, the
`X-Forwarded-For` chain is walked from the rightmost entry, skipping any
address in `trusted_proxies`; the first untrusted hop is the real client.
When every hop is trusted, the leftmost entry is used.  When the peer is
not a trusted proxy, or no `X-Forwarded-For` header is present, the peer
address itself is used — exactly like `ip_key/1`.

    -- nginx on localhost terminates TLS and proxies to us:
    let key_fn = fn conn -> RateLimit.ip_key_forwarded(conn, ["127.0.0.1"])
    RateLimit.check(conn, rate_table, key_fn, 5, 60)
fnlimitlimit(conn, table, key_fn, max_requests : Int, window_secs : Int)#

Middleware variant of check/5 for use in pipeline composition.

When the rate limit is exceeded, halts the pipeline and returns the 429
response conn directly.  When within the limit, passes the conn with headers
set to the next step.

Equivalent to pattern matching on `check/5` but more concise:

    conn
    |> RateLimit.limit(rate_table, RateLimit.ip_key, 5, 60)
    |> AuthController.login()