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 nextwindow to store (`windowStart`, `count`) and the caller's verdict (`allowed`,`remaining`, `resetAt`). Every field is a base type, so a `Decision` never has tocross a context boundary as a record — the agent reads these fields and buildsits 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 aclock reading. No agent, no `Clock`, no capabilities — just arithmetic, which iswhy 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'sclock reading. If the current window has lapsed the request opens a fresh one; arequest 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, }}context ratelimit
uses windowconsumes 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), solimits 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 handlerreturns `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 countof 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 thatreads 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 topersistent 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)") } }}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.
How it works
Section titled “How it works”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.