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.

src/window.bynk
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.
---
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,
}
}
src/ratelimit.bynk
context ratelimit
uses window
consumes bynk { Clock }
---
Who is being limited — a non-empty client/API-key identifier. Each distinct
`ClientId` keys its own `Limiter` instance (a Cloudflare Durable Object), so
limits never bleed across clients.
---
type ClientId = String where NonEmpty
---
The verdict returned to the caller. On the allow path it is the `200` body:
`allowed` is the decision, `remaining` the budget left in the window, and
`resetAt` when the window rolls over. When the window is exhausted the handler
returns `429 TooManyRequests` instead, with the reset time in the message.
---
type RateView = {
allowed: Bool,
remaining: Int,
resetAt: Int,
}
---
A fixed-window counter. State is the start of the current window and the count
of requests in it — both zeroable, so a never-seen client starts clean.
The agent holds no policy of its own: the windowing decision is the pure
`decide` function from `commons window`, so this handler is a thin shell that
reads state, applies the decision, commits the new window, and returns the view.
The policy is unit-tested in `tests/window.bynk`; the agent just wires it to
persistent state.
---
agent Limiter {
key client: ClientId
store windowStart: Cell[Int]
store count: Cell[Int]
on call hit(now: Int, windowMs: Int, limit: Int) -> Effect[RateView] {
let d = decide(windowStart, count, now, windowMs, limit)
windowStart := d.windowStart
count := d.count
RateView { allowed: d.allowed, remaining: d.remaining, resetAt: d.resetAt }
}
}
service api from http {
-- 10 requests per 60s per client. The agent has no clock of its own, so the
-- handler reads `Clock.now()` and passes it in — keeping the limiter a pure
-- function of its inputs.
on GET("/check/:client") by Visitor (client: ClientId) -> Effect[HttpResult[RateView]] given Clock {
let now <- Clock.now()
let view <- Limiter(client).hit(now, 60000, 10)
if view.allowed {
Ok(view)
} else {
TooManyRequests("rate limit exceeded; window resets at \(view.resetAt)")
}
}
}
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.