--- title: "Rate limiting" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Rate limiting} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(eval = FALSE, comment = "#>") ``` `dr_rate_limit()` adds a per-route or per-app cap on how many requests are allowed in a rolling time window. Over-budget requests are rejected with HTTP `429 Too Many Requests` and a `Retry-After` header, **before** the request is dispatched to R — so a flood of clients can't saturate the dispatcher or your handler. ## Quick start ```r library(drogonR) app <- dr_app() |> dr_get("/health", function(req) "ok") |> dr_get("/api/users", function(req) "users") |> # 100 requests per 60 s, applied to every route under /api/ dr_rate_limit(capacity = 100L, window = 60, routes = "/api/") dr_serve(app, port = 8080L) ``` `/health` is unaffected (it is outside the `/api/` prefix). `/api/users` is allowed up to 100 hits per 60 s; subsequent hits get 429 until the window slides forward. ## How it works The check runs on Drogon's I/O thread, immediately after route matching and **before** the request enters the R-side dispatch pipeline. That means: * No queueing cost when over the limit — the 429 is built and sent from C++. * R-side [dr_use()] middleware does **not** run for rejected requests. Conversely, allowed requests pass through middleware as normal. * Native ([dr_get_cpp()]) and streaming-native ([dr_get_cpp_stream()]) routes are limited too — the gate is in front of the worker pool hand-off. * Static mounts ([dr_static()]) are *not* gated by `dr_rate_limit()` in the current release; throttle them with a reverse proxy. The `Retry-After` header carries the rule's `window` (rounded up to seconds) — a conservative upper bound on how long the client should back off. ## Algorithms (`type =`) ```r dr_rate_limit(app, capacity = 10L, window = 1, type = "sliding_window") dr_rate_limit(app, capacity = 10L, window = 1, type = "fixed_window") dr_rate_limit(app, capacity = 10L, window = 1, type = "token_bucket") ``` * **`"sliding_window"`** (default) — counts requests in the trailing `window` seconds. Smooth: no clock-edge bursts, but slightly more bookkeeping than a fixed window. * **`"fixed_window"`** — `capacity` per fixed wall-clock interval of `window` seconds. Cheapest, but allows a burst of `2 * capacity` across a window boundary. * **`"token_bucket"`** — refills at `capacity / window` tokens per second, with `capacity` being the maximum burst. Use this when steady throughput matters more than tight per-window limits. All three are implemented by Drogon's `RateLimiter` class; drogonR just wraps each instance in a small mutex so the I/O threads can call `isAllowed()` concurrently without racing. ## Scope (`scope =`) ```r dr_rate_limit(app, capacity = 10L, window = 1, routes = "/api/", scope = "per_route") # default dr_rate_limit(app, capacity = 10L, window = 1, routes = "/api/", scope = "global") ``` * **`"per_route"`** — each matched route gets its own bucket. `/api/a` and `/api/b` are throttled independently. * **`"global"`** — one bucket shared across every matched route. 10 hits to `/api/a` plus 0 hits to `/api/b` already exhausts the bucket; both routes start returning 429 until the window opens up. A route may match several rules at once (`dr_rate_limit()` is additive). To pass through, the request must satisfy **every** matching rule. ## Prefix matching (`routes =`) `routes` is a character vector of path **prefixes**: ```r # all of /api/ AND /admin/, with separate budgets per route dr_rate_limit(app, capacity = 100L, window = 60, routes = c("/api/", "/admin/")) ``` `NULL` (the default) matches every registered route — useful for an app-wide cap layered on top of per-area rules. ## Per-IP limiting Not provided by `dr_rate_limit()`. The bucket is shared across all clients of the matched routes; if you need a per-client cap, do it in front of drogonR (nginx `limit_req`, Caddy `rate_limit`, Cloudflare, an API gateway, etc.). Doing per-IP enforcement at the application layer would require maintaining a hash table of clients keyed by source IP and pruning it under contention — a lot of overhead for something a reverse proxy already does well. ## Operational notes * Rules are frozen at `dr_serve()` time. Add all `dr_rate_limit()` calls before starting the server; they cannot be modified live. * Call `dr_rate_limit()` **after** registering routes so prefix matches resolve correctly. * The Drogon event loop cannot be restarted in the same R session, so changing rate-limit rules means restarting the R session.