Skip to content

Rate-limit requests per client

Allow at most N requests per client per fixed time window, strongly consistent per client, returning the remaining budget and reset time — or a 429 once the window is exhausted.

commons window
---
The outcome of a fixed-window decision, as plain numbers and booleans: the next
window to store (`windowStart`, `count`) and the caller's verdict (`allowed`,
`remaining`, `resetAt`). Every field is a base type, so a `Decision` never has to
cross a context boundary as a record — the agent reads these fields and builds
its own `RateView` from them. Background:
[fixed window rate limiting](https://en.wikipedia.org/wiki/Rate_limiting).
---
type Decision = {
windowStart: Int,
count: Int,
allowed: Bool,
remaining: Int,
resetAt: Int,
}
---
The whole rate-limiting policy, as a pure function of the stored window and a
clock reading. No agent, no `Clock`, no capabilities — just arithmetic, which is
why it lives in `commons` and is exhaustively unit-tested (see `tests/window.bynk`).
`prevStart` / `prevCount` are the agent's current state; `now` is the caller's
clock reading. If the current window has lapsed the request opens a fresh one; a
request over the limit is *not* counted, so a client cannot deepen its own hole.
---
fn decide(prevStart: Int, prevCount: Int, now: Int, windowMs: Int, limit: Int) -> Decision {
let fresh: Bool = now - prevStart >= windowMs
let windowStart: Int = if (fresh) { now } else { prevStart }
let used: Int = if (fresh) { 0 } else { prevCount }
let allowed: Bool = used < limit
let count: Int = if (allowed) { used + 1 } else { used }
Decision {
windowStart: windowStart,
count: count,
allowed: allowed,
remaining: limit - count,
resetAt: windowStart + windowMs,
}
}
Open the full project ↗

This example reaches Workers-only shapes (storage bindings, agents, or cron), so it runs with bynk dev rather than in the browser playground. See Install to get started.

agent Limiter is keyed by ClientId (a non-empty String), so two calls with the same id address the same instance and different ids are independent — one Durable Object per client, so limits never bleed across them. State is two zeroable Cell[Int]s, windowStart and count, so a never-seen client starts clean at 0/0 with no constructor.

The policy is factored out. decide(prevStart, prevCount, now, windowMs, limit) in commons window is a pure function of plain numbers: it decides whether the window has lapsed (opening a fresh one if so), whether the request is allowed, and the new count — crucially, an over-budget request is not counted, so a client cannot deepen its own hole. It returns a Decision of base-type fields only. Because it touches no agent and no clock, it is exhaustively unit-tested directly. The agent’s hit handler is a thin shell: it calls decide, commits both cells with :=, and returns a RateView.

The agent has no ambient time. The HTTP route GET /check/:client declares given Clock, reads Clock.now(), and passes it into hit — keeping the limiter a pure function of its inputs. Within budget it returns Ok(view) (a 200 with allowed, remaining, resetAt); once exhausted it returns TooManyRequests with the reset time in the message. The wiring fixes the policy at 10 requests per 60000 ms.