@bakidev/durable-rate-limiter
v0.10.0
Published
A shared rate limiter and concurrency gate for Cloudflare Workers, backed by a Durable Object.
Maintainers
Readme
@bakidev/durable-rate-limiter
A shared rate limiter and concurrency gate for Cloudflare Workers, backed by a Durable Object.
const value = await limiter.for(env).call(() => fetch(url, init), {
read: (res) => res.json(),
});One shared window, shared by every isolate, every Workflow instance, every cron
tick and every application bound to it. call() takes a function, not a
request — so the object decides when your work runs while the work itself runs
in your isolate.
The problem
Rate limiting is a per-process concern in most libraries: a token bucket lives in memory, the code that calls the API asks it for permission, and everything works as long as there is exactly one process.
On Workers there is never exactly one process. A Worker runs in many isolates across many locations; a Workflow spawns instances that neither know nor can reach each other; a cron tick and a user request fire simultaneously. Each constructs its own in-memory bucket, each politely paces itself to the configured rate, and collectively they exceed the upstream quota by however many isolates happen to be warm. The limiter is locally correct and globally useless.
The same problem appears one level up: several applications sharing one API key have no way to coordinate at all.
Porting an in-process limiter does not fix it, for two independent reasons:
- Timers are not allowed where the state would have to live. Workers forbids
setTimeout/setIntervalat module scope, which is the only place a shared singleton could sit — and the failure appears only at deploy, never locally. - In-memory window state is destroyed constantly. An isolate is discarded between requests; a Durable Object is evicted after 70–140 seconds idle. An in-memory count resets to an empty window on every cold start, handing out a fresh full allowance against someone else's quota precisely when traffic has just resumed.
So the window has to be state-driven rather than event-driven — its usage
read from a persisted { grants, forcedUntil } log, one grant per take, pruned
against the wall clock at read time — and it has to live somewhere with identity
and durable storage. On Cloudflare that means a Durable Object: the
only primitive that guarantees a single instance serialising all callers against
one piece of state.
Why a function and not a request
A conventional gateway proxies: you hand over a URL, headers and a body, it performs the request when the limit allows. That forces every byte through a single-threaded object, requires the gateway to hold your credentials, and makes it specific to each upstream's auth and error conventions.
This package sends a function. Workers RPC does not serialise functions — it passes a handle, and invoking that handle calls back into the isolate the function came from. Everything else follows:
- Payloads never transit the object. A multi-megabyte download happens in your isolate. The object sees only a small summary.
- No credentials cross. You build your own headers; the limiter holds no
secrets and parses nothing but
{ status, retryAfter }. - It is enforcement, not cooperation. The object controls the moment of execution, so a caller cannot fire early no matter what it intends.
- Concurrency is genuinely measured. The object awaits your callback, so it knows when work finishes — which a design handing out permits or timestamps can never know.
- One caller's 429 throttles all of them, in every isolate and every application, with no reporting protocol for anyone to forget to implement.
Install
npm install @bakidev/durable-rate-limiterFour subpath exports; the first two are the ones an application uses.
| Import | Who uses it |
| ----------------------------------------- | --------------------------------------------------------------------------------------- |
| @bakidev/durable-rate-limiter/do | the limiter Worker you deploy once |
| @bakidev/durable-rate-limiter/client | every consuming application |
| @bakidev/durable-rate-limiter/do-worker | the same /do half as a Worker entry module — for harnesses that load it directly |
| @bakidev/durable-rate-limiter/testing | test doubles and harness wiring: the fake namespace, the miniflare auxiliary worker |
/do and /client are built from one shared envelope definition, so the halves
cannot drift. /do-worker exists because workerd validates every top-level
export of a module it loads as an entry — a miniflare auxiliary worker's script,
a @cloudflare/vite-plugin auxiliary worker — and /do exports plain values
(ENVELOPE_VERSION, REGISTRY_NAME, the error classes) alongside its classes,
so workerd rejects it with Incorrect type for map entry 'ENVELOPE_VERSION': the
provided value is not of type 'function or ExportedHandler'. /do-worker
exports LimiterDO, LimiterEntrypoint and a default fetch handler and nothing
else. Your deployed limiter Worker can simply re-export from it; the reason it
exists is testing, where the entry module is
loaded from node_modules with no bundler in front of it.
Requirements
- Workers types. The published type surface references ambient Workers types
(
DurableObjectNamespace,DurableObjectStub,DurableObjectId). They are declared as an optionalpeerDependencyon@cloudflare/workers-types(>=4): a project that types its runtime through wrangler's generatedworker-configuration.d.tsalready has them and needs nothing, and a project that does not gets a clear install hint instead of bare "cannot find name" errors. Install it if you see those errors:npm i -D @cloudflare/workers-types. - Module resolution.
moduleResolution: "bundler"(or"nodenext") is recommended and resolves the subpath exports directly. For older TS configs on classicmoduleResolution: "node", atypesVersionsmap is shipped as a fallback so all four subpaths still resolve their declarations. The package is subpath-only by design — there is no root.import. compatibility_date. The floor is2024-04-03— the date Workers RPC (WorkerEntrypoint) became available, which is the only platform feature the limiter depends on. SQLite-backed Durable Objects are enabled by thenew_sqlite_classesmigration, not by a compatibility date. Any date at or after the floor works; the CLI scaffolds at the floor for maximum compatibility, and you may raise it.
Stability — this is a 0.x release
The logic is covered at 100% and the production claims below were measured
against a real deployment, not emulation. What has not happened is a release
running someone else's traffic. Version 0.x says that, and buys the room to fix
an interface the first real integration proves wrong.
Two things are deliberately unsettled until 1.0, both because they are the ones
that would need a migration story rather than a patch:
- The RPC envelope.
ENVELOPE_VERSIONmay be bumped in a0.xminor if the wire shape turns out to be wrong. It stays at1unless that happens. - The persisted
{ grants, forcedUntil }log. A shape change means existing buckets need converting, and the conversion path is worth designing against real data.
Versioning follows semver's 0.x rules, which are what protects you here:
^0.10.0 resolves to >=0.10.0 <0.11.0, so a minor bump carries anything
breaking and a patch carries fixes only. A caret range cannot pull a breaking
change into your build. Pin exactly if you want even that decision to be yours.
TL;DR — setup in five steps
Minimal configuration, start to first paced call. This uses the one-hop cross-script binding, which is the shortest correct path; the service-binding topology is described below.
Or let the CLI do it.
npx @bakidev/durable-rate-limiter init, run from your application's root, walks these five steps and writes every file below — including your upstream's real limit, written verbatim aslimitPerWindow. It shows each file and command before it acts. What it does, exactly.
1. Create the limiter Worker
The object must live in a Worker of its own — see anti-pattern 6. It contains no logic; it re-exports the package.
// limiter/src/index.ts
export { LimiterDO, LimiterEntrypoint } from '@bakidev/durable-rate-limiter/do';
// A Worker exporting a WorkerEntrypoint still needs its own default export.
export default {
fetch: () => new Response('limiter ok'),
};// limiter/wrangler.jsonc
{
"name": "my-limiter",
"main": "src/index.ts",
"compatibility_date": "2024-04-03", // floor; any later date works too
"durable_objects": {
"bindings": [{ "name": "RATE_LIMITER", "class_name": "LimiterDO" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["LimiterDO"] }],
}npx wrangler deploy --config limiter/wrangler.jsoncDeploy this first. A consumer's binding names the Worker, and the binding cannot be created before the Worker it names exists.
2. Bind it from your application
Your application binds the class cross-script. Note there is deliberately no
migrations entry here: this Worker binds the class, it does not
implement it — which is also what keeps your preview URLs.
// app/wrangler.jsonc
{
"durable_objects": {
"bindings": [
{
"name": "RATE_LIMITER",
"class_name": "LimiterDO",
"script_name": "my-limiter", // the Worker from step 1
},
],
},
}npx wrangler types # so defineBinder can typecheck the binding name3. Define the limiter at module scope
// app/src/limiter.ts
import {
defineBinder,
defineLimiter,
} from '@bakidev/durable-rate-limiter/client';
const binder = defineBinder('RATE_LIMITER'); // WHICH binding — typechecked
export const api = defineLimiter({ binder, name: 'example-api' }); // WHICH bucketDefine at module scope. Bind and call wherever you have
env.
defineBinder and defineLimiter perform no I/O and start no timers, so a
configured limiter is safe as a module-scope singleton. Binding is a separate
step because it needs env — a fetch or scheduled handler, a queue consumer, a
Workflow step, a Durable Object method all qualify. Module scope is the one
place that does not, because env does not exist there.
name is the instance name (idFromName's argument): example-api,
billing-api and search-api are independent buckets on the same class and the
same binding.
4. Call
// app/src/index.ts
import { api } from './limiter.js';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const limiter = api.for(env); // here, because `env` exists here
const file = await limiter.call(
() =>
fetch('https://api.example.com/v1/files', {
headers: { authorization: `Bearer ${env.TOKEN}` },
}),
{ read: (res) => res.json<{ id: string }>() }
);
return Response.json(file);
},
};That call may await for minutes. That is the design, and it is cheap — see
what waiting costs.
5. Set your limits — this is what creates the bucket
A rate limit is never assumed. There is no default configuration. A bucket
that has never been configured does not exist: it holds zero bytes of
storage, and execute and stats on it throw LimiterNotConfiguredError
rather than pacing it at some invented rate.
That is deliberate, and it is the difference between a typo you find in development and one you find when your API key is banned. A mistyped instance name is not an error anywhere else in the system — it is simply a different bucket — so a fallback default would turn it into a second limiter running at a plausible-looking rate against the same upstream quota, invisibly, possibly taking down every other application sharing that quota with you.
It also makes listNames() trustworthy: a name enters the registry when it is
configured, and nothing that was not configured can run, so nothing live is
missing from the list.
configure is a setup call, not a per-request one; it is persisted, and it
rejects anyone currently queued.
// Run once — from a deploy script, an admin route, or a guarded first-run path.
const stub = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName('example-api'));
// The name goes in twice on purpose: once to address the object, once so the
// object knows what it is called. It cannot work that out for itself —
// `ctx.id.name` is undefined inside a Durable Object — and it needs a name to
// enter the registry that makes `listNames()` possible.
//
// The config is COMPLETE, not a patch. There is nothing to merge a fragment
// onto, and a half-specified bucket is exactly what this refuses to create.
await stub.configure('example-api', {
bucket: { limitPerWindow: 60, windowInMs: 60_000 },
concurrency: 5,
retry: { maxRetries: 3, maxDelayInMs: 30_000 },
});
console.log(await stub.stats()); // name, remaining, resetAt, penalty state, in-flight
// To change one knob later, patch it. No name, because an existing bucket is
// already registered — and it throws if there is nothing there to patch.
await stub.reconfigure({ concurrency: 8 });Creation is all-or-nothing. Registering and configuring are writes to two
different objects, so there is no transaction to hold them together — the
registration goes first, so the only survivable failure is a name with no bucket
behind it, never a live bucket nobody can see. If the config write then fails,
the registration is undone and the object erases itself; and any name that slips
through anyway is pruned the next time stats walks the list.
The setup CLI
Every step above is mechanical, and every one of them is a chance to get a name wrong — a mistyped instance name does not error, it silently creates a second bucket. The CLI asks instead.
cd my-application
npx @bakidev/durable-rate-limiter initIt walks the five steps in the order they must happen — the limiter Worker first, because a consumer's binding names it — and for each one:
| Step | What init does |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| limiter Worker | scaffolds src/index.ts and wrangler.jsonc |
| binding | asks which topology, then inserts the binding into your existing config |
| types | offers to run wrangler types, so defineBinder typechecks the binding name |
| limiter module | writes src/limiter.ts with the binder and the instance name — the name written once — plus commented-out rateLimit/error hook skeletons to fill in |
| limits | asks your upstream's real limit, writes it verbatim as limitPerWindow, and writes an editable limits file |
| deploy | deploys the limiter Worker, sets its guard secret, and applies those limits — the bucket is live before init exits |
It opens by telling you to commit first, and reports whether your working tree is
clean, because everything after that is easiest to read as a diff. Nothing is
written or run before it is shown, existing files are never overwritten without
asking, and your wrangler config is edited by insertion rather than reserialised
— a round-trip through a JSON parser would delete every comment in a file whose
format exists to have them. If the relevant key is already present, init prints
the fragment for you to merge instead of guessing.
Anything it could not do ends up in a "still to do" list with the exact command.
--yes takes every default without asking, and never deploys.
Configuring without writing a deploy script
configure is a method on the Durable Object, so only a deployed Worker can
call it — no wrangler command reaches a DO method. init therefore offers to
give the limiter Worker a key-guarded route, and to put your limits in a file
beside it:
// durable-rate-limiter/durable-rate-limiter.limits.jsonc
// One entry per upstream limit. NOT read at runtime — see below.
{
"limits": {
"read-api": {
"bucket": { "limitPerWindow": 60, "windowInMs": 60000 },
"concurrency": 5,
},
"write-api": {
"bucket": { "limitPerWindow": 30, "windowInMs": 60000 },
"concurrency": 2,
},
},
}This file is never deployed and the limiter Worker never imports it. The
limits are durable state inside the Durable Object; this is the copy you keep in
version control, and configure is what carries one to the other. That is the
whole reason it is JSONC rather than TypeScript — a TypeScript file would have to
be imported by the Worker, which would make every limit change a code change and
every code change a deploy.
So the limits still live in version control and still change in a reviewable diff, but retuning one costs a single command:
npx @bakidev/durable-rate-limiter configure # upload the file — no deploy
npx @bakidev/durable-rate-limiter stats # read every bucket's live state back
npx @bakidev/durable-rate-limiter stats --save # ...and overwrite the file with it
npx @bakidev/durable-rate-limiter sample # write an example file to start fromBoth commands need to know where the limiter answers. They remember it from
the deploy init performed, and ask once if they do not know — but a limiter you
deployed yourself, one that moved behind a custom route, or one running locally
was never recorded, and a prompt in CI is a hang rather than a question. --url
(or --url=) is the answer, on either command:
npx @bakidev/durable-rate-limiter configure --url https://my-limiter.example.workers.devIt takes precedence over the remembered origin and is written back, so it is
needed once rather than every run. Together with DRL_CONFIG_KEY in the
environment and --yes, that makes both commands fully non-interactive. And
when there is genuinely nobody to ask — stdin is not a TTY — they now fail
immediately, naming the flag or variable that would have answered the question,
instead of blocking on a pipe.
stats needs no list of names: the object keeps a registry of every bucket that
has been configured, because a Durable Object namespace cannot be enumerated —
there is no list(), and idFromName does not run backwards. stats --save is
therefore the way to get an accurate limits file for a limiter you inherited, or
to recover one you lost.
Redeploy the limiter Worker only when its own code changes — a package
upgrade, an edit to its index.ts. Never for a limit. If you point a current CLI
at a Worker deployed before this arrangement existed, configure notices it
ignored the upload and tells you to redeploy once.
Both routes are guarded by a DRL_CONFIG_KEY secret and deny everything while it
is unset — an unset secret means denied, never open. init generates a key,
sets it with wrangler secret put, and applies your limits before it exits, so
the bucket is configured by the time anything calls it. The Worker's secret and
the environment variable the CLI reads share that one name deliberately — seeing
DRL_CONFIG_KEY in the Cloudflare dashboard should tell you what it belongs to.
Export it to skip the prompt in CI.
A secret cannot be read back, so a key you have lost and a key you have typed wrong have the same remedy: set a new one, which replaces it. Both commands say so at the point it matters.
npx wrangler secret put DRL_CONFIG_KEY --config durable-rate-limiter/wrangler.jsonc
# or: Workers & Pages → your limiter Worker → Settings → Variables and Secretsinit leaves a .durable-rate-limiter.jsonc inside the limiter's own folder
— your project root gains one directory and nothing else. It records the Worker
name, its config, where the limits file is and the deployed URL; no secrets, so
commit it. Every path in it is relative to that folder, and configure/stats
find it from anywhere in the project, so they work from a subdirectory too.
The two files are deliberately separate. That one is written by the CLI and rewritten without warning; the limits file is written once and never touched again, so a comment explaining why a limit is what it is survives.
Decline the route and init writes a configureLimiter(env) module instead, for
you to call from a deploy script, an admin route, or a guarded first-run path.
configurerebuilds each bucket and rejects anyone currently queued — their wait can never be satisfied under limits that no longer exist. It is a setup call, not a per-request one. Prefer a quiet moment.
🚨 The sizing rule
Set
limitPerWindowto the upstream limit, verbatim."100 per minute" is
{ limitPerWindow: 100, windowInMs: 60_000 }. A rested caller may spend all of it at once — that is the point, and what the surveyed upstreams themselves enforce.
And that is the whole of it — the guarantee is exact. The pacing is a
sliding log: every take is recorded and counts against the allowance until it
is windowInMs old, so the allowance is measured continuously and no rolling
window ever holds more than limitPerWindow. A rested caller may still spend the
whole limit at once, and the peak in any window is bounded by limitPerWindow
with nothing to add on top:
| Upstream limit | Config | Steady state | Any rolling window |
| -------------- | -------------------------------------------- | ------------ | ------------------ |
| 60 / minute | { limitPerWindow: 60, windowInMs: 60_000 } | 60 / minute | never above 60 |
| 30 / minute | { limitPerWindow: 30, windowInMs: 60_000 } | 30 / minute | never above 30 |
On top of that guarantee, the same pause() feedback a real 429 drives
throttles every caller and reopens the recovering window at half its
allowance, so a limit tripped upstream damps the next call rather than
compounding it.
Anti-patterns
Each of these compiles, and several of them work in local development.
1. Don't hand it a request — hand it a function
// ❌ The fetch has already fired. The limiter paced nothing.
await limiter.call(fetch(url), { read });
// ❌ Same problem in disguise: `fetch` unbound, and no way to build a fresh
// request per attempt.
await limiter.call(fetch, { read });
// ✅ A thunk. The object decides when this runs.
await limiter.call(() => fetch(url), { read });The whole design rests on the limiter receiving something it can invoke later.
Anything already in flight is outside its control, and passing fetch itself
gives it nothing to send.
2. Don't close over a built Request
fn is re-invoked from scratch on every retry, so it must construct its own
request each time.
// ❌ Body already consumed on attempt two.
const req = new Request(url, { method: 'POST', body });
await limiter.call(() => fetch(req), { read });
// ✅ Fresh request per attempt.
await limiter.call(
() => fetch(url, { method: 'POST', body: JSON.stringify(payload) }),
{ read }
);The wrong form fails on retry with a "body already used" error that looks nothing like a retry problem.
3. Don't throw on a non-2xx — report it
Workers RPC reconstructs a thrown error from name, message and stack
only. Every custom property is stripped crossing the boundary — a status,
a code, a retryable flag. Retryability therefore cannot be signalled by
throwing: the object sees something indistinguishable from a network blip and
retries your 404 to exhaustion.
// ❌ `retryable` and `status` do not survive the hop.
await limiter.call(
async () => {
const res = await fetch(url);
if (!res.ok) throw Object.assign(new Error('bad'), { status: res.status });
return res;
},
{ read }
);
// ✅ The failure travels as data, and the object honours the decision.
await limiter.call(() => fetch(url), {
read: (res) => res.json(),
error: (res) =>
res.ok ? null : { message: 'bad', retryable: res.status >= 500 },
});The plain HTTP status is carried for you automatically; the error hook is for
failures your upstream hides in a 200 body.
4. Don't return the response (or a big payload) from read
read exists to extract the small thing you actually need while the body stays
local. Returning something large drags it back through a single-threaded object
to reach the caller that already had it.
// ❌ Defeats the entire point.
{
read: (res) => res.blob();
}
// ✅ Do the whole job caller-side, return an identifier.
{
read: async (res) => (await uploadToStorage(res.body!, folderId)).id;
}read also runs on error responses, so read defensively if your upstream
returns a different shape on failure — a throw from read propagates and is
retried as an unknown error.
5. Don't do work at module scope, and don't bind there
// ❌ `env` does not exist at module scope, and a timer there fails at DEPLOY —
// never locally, never in tests.
const bound = api.for(env);
// ✅ Define at module scope; bind and call wherever `env` reaches you.
export const api = defineLimiter({ binder, name: 'example-api' });This is exactly what sank the predecessor package: its constructor started an interval, so a module-scope singleton could not be constructed at all.
6. Don't put the Durable Object in your application Worker
Two platform constraints, both verified:
- A Worker implementing a Durable Object gets no preview URLs. This applies to the whole Worker, not just the object.
- A new Durable Object migration cannot be uploaded as a version. A version containing both a migration and a binding referencing the new class is rejected with 403. Migrations apply only on deployment, and are atomic.
A Worker that merely binds an object defined elsewhere (via script_name) does
not implement one, and is unaffected. Giving the limiter its own Worker is
required anyway for multi-application use.
7. Don't mistype the instance name
A mistyped name does not error. It silently creates a second bucket, and
each paces at the full configured rate against one upstream quota. The failure
surfaces later as unexplained 429s from an upstream nobody was over-calling.
This is why name lives on the limiter definition — written exactly once.
8. Don't export the entrypoint as default
// ❌ Typechecks, then fails at startup: "has no such named entrypoint".
export default class LimiterEntrypoint { ... }
// ✅ A service binding with "entrypoint" resolves against NAMED exports only.
export { LimiterEntrypoint } from '@bakidev/durable-rate-limiter/do';9. Don't let a raw stub type reach a call site
RPC erases generics — completely, and silently.
DurableObjectStub<LimiterDO>['execute'] resolves to never, including for
methods whose type parameter is inferred from an argument. never is assignable
to everything, so nothing errors; type checking simply stops at the stub
boundary and a completely wrong call still compiles.
Type a service binding as LimiterService, and assert a direct stub to
LimiterRpc exactly once, where the stub is obtained. defineBinder already
does this for you.
10. Don't use rateLimit for one endpoint's failure
The two hooks mean different things, and blurring them turns one endpoint's 500 into a stall for every other caller of the same upstream.
| Hook | Non-null result means | Scope |
| ----------- | -------------------------------------------- | ---------------------------------- |
| rateLimit | treat exactly as an HTTP 429 with this delay | global — pauses every caller |
| error | the call failed; retryable decides | local — retries this call only |
11. Don't block or throw inside onDrop
It runs on the caller's path between attempts. Anything slow there is added directly to the latency of a call that has already been unlucky once.
Features
Global rate limiting that actually holds
One sliding log in a Durable Object, shared by every isolate, every Workflow instance, every cron tick, and every application bound to it. Not a window per isolate. A rested caller may spend the whole limit at once, and no rolling window ever holds more than it — the allowance is bounded continuously. See the sizing rule.
Real concurrency limiting
A cap on calls actually in flight, not an approximation derived from rate. Because the object awaits the callback, it knows when work finishes. Measured holding at exactly the configured value under variable production latency.
Cross-caller backpressure
A rate-limit response seen by one caller pauses the shared bucket for all of them — including callers already queued, in other isolates, in other applications. No reporting protocol, nothing for a consumer to forget to implement.
Retry-After is honoured in both documented forms (integer seconds and HTTP
date) and from both header shapes (a Headers object and a plain object). Where
a delay must be inferred instead, exponential backoff clamped to a maximum.
Concurrent penalties do not stack: the deadline is the maximum of existing and
new, so three simultaneous 5s / 60s / 5s responses wait 60 seconds, not 5. A
penalty does not reopen a full window either — it sets a floor on the spend the
recovering window carries, leaving at most penaltyRefillFraction of the limit
available (default 0.5, so half), because a full window aimed at an API that
just asked for backoff re-trips it immediately. It is a floor, not a reset: real
takes from before the penalty that are still inside their window keep counting,
so the window can open with less than that available but never more.
Rate limits and errors hidden in response bodies
Not every API says 429. Two optional hooks handle the ones that don't (their scopes differ):
export const api = defineLimiter({
binder,
name: 'example-api',
rateLimit: (res, body) =>
(body as ApiError)?.error?.status === 'RATE_LIMIT_EXCEEDED'
? { retryAfterMs: 60_000 }
: null,
error: (res, body) =>
(body as ApiError)?.error
? {
message: (body as ApiError).error.message,
retryable: res.status >= 500,
}
: null,
});Both resolve in three chained layers — call site, then limiter default, then
built-in HTTP — each falling through on null. The HTTP layer is
unconditional, so overriding a hook for one odd endpoint never silently
disables genuine 429 handling there. An explicit null at a call site opts out
of both hook layers; the HTTP layer still applies.
Defaults live on the limiter definition, so an API's convention is written once and reused across every call site.
Retries with correct 4xx handling
Client errors are not retried; 429 is. Exponential backoff with a configurable floor, ceiling and factor, and partial options merged with the defaults rather than replacing them wholesale.
Dropped callers are retried for you
A caller parked in the object's memory-only queue can be dropped — measured at 2.4% of calls under load. The client retries that automatically, and safely even for non-idempotent work. Full detail below.
Survives eviction without a burst
Bucket state is a persisted { grants, forcedUntil } log — one grant per take —
pruned against the wall clock when read. An object evicted after 70–140 seconds
idle resumes with its outstanding grants intact — not a fresh full window,
which is what an in-memory counter opens, precisely when traffic resumes.
Idle limiters cost nothing
No timer exists unless a caller is waiting; one timer serves the whole queue, sized to the exact deficit, cleared when the queue drains. An idle object hibernates.
Many limiters, one deployment
Instances are addressed by name, so example-api, billing-api and search-api are
independent buckets on one class and one binding. defineBinder is declared once
and reused.
Typechecked bindings
defineBinder is constrained to keys of your generated Env that are actually
Durable Object namespaces. A typo fails to compile; so does pointing at a KV or
D1 binding. Zero runtime cost. A runtime presence check at bind time covers
consumers who haven't run wrangler types, naming the bindings it did find.
Without a generated Env there is nothing to match against and every argument is
rejected — that is what defineBinder.unchecked('RATE_LIMITER') is for.
Explicit, so the absence of checking is visible at the call site rather than
inferred from a mysteriously permissive signature.
Testable without magic
Under @cloudflare/vitest-pool-workers the real binding exists and is backed by
a local Durable Object, so most tests need no seam at all. For unit tests outside
workerd, defineTestBinder injects a namespace directly and returns the same
Binder type, so the module under test is unchanged — and
createFakeLimiterNamespace is a namespace built to plug straight into it.
import {
defineTestBinder,
defineLimiter,
} from '@bakidev/durable-rate-limiter/client';
import { createFakeLimiterNamespace } from '@bakidev/durable-rate-limiter/testing';
const fake = createFakeLimiterNamespace();
const api = defineLimiter({
binder: defineTestBinder(fake.namespace),
name: 'example-api',
error: (res) =>
res.ok ? null : { message: 'upstream failed', retryable: false },
});
// A success resolves with whatever `read` extracted.
await expect(
api.for({}).call(() => new Response('{}', { status: 200 }), {
read: (res) => res.text(),
})
).resolves.toBe('{}');
// A reported failure REJECTS, exactly as the real object does once it is final.
await expect(
api.for({}).call(() => new Response('nope', { status: 400 }), {
read: (res) => res.text(),
})
).rejects.toThrow('upstream failed (status 400)');⚠️ Do not hand defineTestBinder the tempting one-liner
{ execute: async (fn) => (await fn()).value }. It resolves even when the report
carries a failure, which the real object never does — so a wrapper that parses
the returned value gets fed error bodies in tests, a behaviour production never
shows. The fake keeps the outcome honest: a report with failure rejects with
the same CallFailedError the object would build; anything else resolves with
value.
It is terminal-only on purpose. There is no waiting, no pacing and no
re-invoking of fn — retry scheduling is the object's concern, and a fake that
slept through backoffs would slow your tests without proving anything about it.
A retryable failure therefore also rejects immediately, as if its retries
were already spent.
fake.calls captures { name, report } for every call in order, which is how
hook wiring is asserted: the hooks run client-side, so the CallReport is the
only place their effect is visible.
const quota = createFakeLimiterNamespace();
const limited = defineLimiter({
binder: defineTestBinder(quota.namespace),
name: 'example-api',
// This upstream signals a quota breach as 403 with a body-derived delay.
rateLimit: (res) => (res.status === 403 ? { retryAfterMs: 60_000 } : null),
});
// Resolves: a rate limit is the object's business, not a failed call.
await limited.for({}).call(() => new Response('{}', { status: 403 }), {
read: (res) => res.text(),
});
const [{ name, report }] = quota.calls;
expect(name).toBe('example-api'); // which bucket it addressed
// It crossed the boundary as a 429 — the one vocabulary the object understands —
// with the body-derived delay attached.
expect(report.status).toBe(429);
expect(report.retryAfterMs).toBe(60_000);A failure is visible the same way, and travels as data rather than a throw —
retryable is a custom property, and custom properties do not survive the RPC
boundary on an Error. Using the first fake above, after its 400:
expect(fake.calls.at(-1)?.report.failure).toEqual({
message: 'upstream failed',
retryable: false,
});CallReport is exported as a type from both /client and /do.
No allowlisted magic names. The injection point is explicit and discoverable.
For the binding that a script_name consumer needs under vitest, see
Testing and local development.
Safe at module scope
defineBinder and defineLimiter perform no I/O and start no timers, so a
configured limiter can be exported as a module-scope singleton.
Observable
stats() returns the remaining window allowance, when it next resets, penalty
state, in-flight count and the raw persisted log. A shared limiter nobody can
inspect is a shared limiter nobody will trust.
Version skew is loud
ping() reports the limiter Worker's ENVELOPE_VERSION. The two halves deploy
on independent schedules, so a consumer can compare it against its own at startup
instead of discovering the mismatch as silent mis-limiting.
Dropped callers, and why the package retries them
The object's wait queue is memory-only — an RPC function handle cannot be persisted, so a queue of them cannot be written to storage. A caller parked in that queue holds an open RPC connection, and if the object is evicted, reset or redeployed while it waits, the connection breaks and the call rejects with a transport error.
Measured against a real deployment across four runs of ten Workflow instances stampeding one bucket: 7 of 290 calls, 2.4% (95% CI 1.2–4.9%). Every one was dropped while parked; none died once its callback had started. The waits at the moment of the drop ran from 47 s to 8.8 min and did not cluster at the long end, so this is not a duration ceiling — it reads as eviction or restart landing on whoever happens to be queued.
At that rate, "callers must retry" is not a footnote. The client retries it, up to five times by default, so a call has to be dropped six separate times to fail. In the run made after this shipped, both drops recovered on their first retry and the run finished with zero failures.
export const api = defineLimiter({
binder,
name: 'example-api',
dropRetries: 5, // the default; 0 opts out
onDrop: ({ limiter, attempt, willRetry, error }) =>
metrics.increment('limiter.drop', { limiter, willRetry }),
});Why this is safe even for non-idempotent work. The retry fires on exactly one condition: the callback never ran. That is knowable rather than guessed, because the callback runs in your isolate — if it never fired, no request reached the upstream and there is nothing to duplicate. A connection lost after the callback started is never retried for you; that case is genuinely ambiguous, and deciding it on your behalf could send a payment twice. It propagates unchanged, for you to make idempotent or reconcile.
The retry takes a fresh stub, because the handle whose connection just broke
would otherwise repeat the failure instantly. It adds no backoff — a retry must
take from the bucket again before it runs, so the bucket's own pacing is already the
wait, which also spreads the attempts out rather than firing them into one bad
window. When the attempts are spent, the call rejects with CallDroppedError,
carrying attempts, limiter and the original transport message as cause.
Unlike the errors thrown inside the object, this one never crosses an RPC
boundary, so its properties actually survive to be read.
A limiter that does not exist is not a drop. Both failures arrive the same
way — a rejection from the object before the callback ever fired — so the client
has to tell them apart, and it does: a bucket that was never configured rejects
with NoSuchLimiterError on the first attempt, with no retries and no onDrop
events. Retrying could not make it exist, and counting it as a drop would poison
the one number you have for sizing real drops. It is almost always a mistyped
instance name; error.limiter says which.
The two are distinguished by a marker in the error message rather than its type,
because nothing else survives: an error thrown inside a Durable Object reaches
the caller as a plain Error with name === 'Error' and every custom property
stripped. instanceof does not work across that boundary.
onDrop fires on every drop, retried or not. The drop rate is a property of
your deployment — object churn, redeploy cadence, how long your callers park —
not of the measurements above, so it has to be observable in production rather
than assumed.
Why this page quotes no failure probability
Six attempts at a 2.4% drop rate multiplies out to roughly one in ten billion, and that figure would be worth very little.
- The base rate is uncertain. 7 of 290 is a small sample. The real rate is somewhere in 1.2–4.9%, and compounding an uncertain number amplifies the uncertainty — across that interval the six-attempt result spans a factor of 5 000. A single number would be false precision presented as a guarantee.
- The events are not independent. Drops come from eviction, reset and redeploy, and a redeploy drops every parked caller at once — then their retries re-queue into the same window. No run has yet produced a call dropped twice, so the probability of a second drop given a first is unmeasured, and that is precisely the quantity the exponent assumes.
Beyond about five retries the residual risk is dominated by correlated failures
that more attempts cannot fix, while the costs stay real — every attempt takes
from the bucket before it runs, so a retry storm spends upstream quota doing nothing.
Measure your own rate with onDrop; that is the number that should inform your
dropRetries.
Production numbers
Everything below was measured against a real Cloudflare deployment — two Workers, Durable Objects, Workflows. Local emulation does not reproduce the platform limits: Miniflare does not appear to enforce invocation accounting, so local tests are for logic, not for limits.
"Production" here means the real platform, not real users: the load came from the
verify/ harness, which is a generator built to stress the package.
Nothing below is a report from an application that adopted it.
A parked callback survives 23 minutes
The platform documents that a passed function "only lasts until the end of the Workers' execution contexts". In practice, while the caller remains awaiting, this is not a practical constraint. Across 100 calls from 10 independent Workflow instances, all synchronised to stampede at one instant:
longest successful park 23.25 min (1 395 136 ms)
median park 4.40 min
succeeded 100 / 100
failed 0No drops, no timeouts, no partial results. Two further runs without induced backpressure completed 100/100 with a longest park of 4.99 minutes — bounded by the test's own batching, not by any platform limit. "Hold the caller until its turn" is viable for waits measured in tens of minutes.
Concurrency is enforced exactly
Configured concurrency 5; peak observed overlap of caller-side work was exactly 5 across every production run, under genuinely variable network latency. This is the condition under which an in-process cap is most likely to be silently broken.
Backpressure works, and compounds
Ten rate-limit responses carrying Retry-After: 30 stretched a workload that
drains in ~10 minutes out to ~28 minutes. Every call still succeeded. The
recovery curve after repeated rate limiting is steeper than the sum of the
individual delays suggests.
The two topologies are indistinguishable
| Path | Cold | Warm | | -------------------------- | ------ | ----- | | service binding (two hops) | 541 ms | 45 ms | | direct DO (one hop) | 412 ms | 43 ms |
Cold-start cost dominates and is unrelated to hop count. Choose the topology on API-surface grounds, not performance.
The 32-invocation limit did not manifest
Documented as "a single request has a maximum of 32 Worker invocations, and each call to a Service binding counts towards this limit". Neither topology failed at 64 sequential calls in one request — 64 is where the probe stopped, not where anything broke. Per-request call volume is not a practical design constraint at realistic scale.
What waiting costs
Workers bills CPU, and a request awaiting I/O consumes none. Durable Object duration is billed per object and shared across all requests active on it at once, so a hundred parked callers cost what one costs. There is no hard wall-clock limit while the caller stays connected.
The one real cost: an object with a request in flight cannot hibernate, so a lone caller waiting against an otherwise-idle object pays for that wall time. Under bursty traffic the object is active regardless.
The other topology: a service binding
The package also ships LimiterEntrypoint, a WorkerEntrypoint in front of the
object. It buys a declared interface that can evolve independently of the
object's class name, one place for the instance-name convention, and somewhere
for metrics, auth and per-consumer policy to live later. It costs ~2 ms warm,
which is noise. The cross-script binding in the quickstart remains fully
supported, but it couples every consumer to the object's class name.
// app/wrangler.jsonc — instead of the durable_objects binding
{
"services": [
{
"binding": "LIMITER",
"service": "my-limiter",
"entrypoint": "LimiterEntrypoint", // NAMED export; omitting this
}, // resolves to the default export
],
}import type { LimiterService } from '@bakidev/durable-rate-limiter/do';
// Type the binding as LimiterService — see anti-pattern 9.
declare global {
interface Env {
LIMITER: LimiterService;
}
}
await env.LIMITER.configure('example-api', {
bucket: { limitPerWindow: 60, windowInMs: 60_000 },
concurrency: 5,
});
const stats = await env.LIMITER.stats('example-api');defineBinder only speaks Durable Object namespaces, which is correct — a
service binding is not one. To run the client stack over this topology, supply a
namespace-shaped adapter through defineTestBinder;
verify/consumer/src/limiter-client.ts
does exactly that.
Testing and local development
The quickstart's binding names another Worker (script_name), and neither
@cloudflare/vitest-pool-workers nor @cloudflare/vite-plugin can invent it for
you: both build their environment from your wrangler.jsonc, find a Durable
Object binding pointing at a script that does not exist locally, and fail at
startup — before any test or request runs. In both cases the fix is to declare
the limiter Worker alongside yours; the two tools spell it differently.
Unit tests that never touch the binding need none of this — see Testable without magic for the in-process fake.
The cross-script topology under @cloudflare/vitest-pool-workers
@bakidev/durable-rate-limiter/testing exports the auxiliary-worker options,
because assembling them by hand means finding three separate failures the hard
way.
// vitest.config.ts
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
import { miniflareLimiterWorker } from '@bakidev/durable-rate-limiter/testing';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
miniflare: {
// `name` MUST equal the `script_name` in your binding. That is the
// whole join between the two configs.
workers: [miniflareLimiterWorker({ name: 'my-limiter' })],
},
},
},
// The one-time `configure` — see below.
setupFiles: ['./test/setup-limiter.ts'],
},
});The helper takes { name, binding?, scriptPath? }; binding defaults to
RATE_LIMITER and scriptPath resolves do-worker.js beside the installed
package. Each field it generates answers a failure you would otherwise meet
individually:
- the entry module is
do-worker.js, notdo.js— workerd validates every top-level export of an entry module anddo.jsexports plain values, so it is rejected withIncorrect type for map entry 'ENVELOPE_VERSION'; - it is declared through an explicit
modulesarray rather than ascriptPath, because miniflare's default module rules parse a bare.jsundernode_modulesas CommonJS and fail withERR_MODULE_PARSE; useSQLiteis declared on the auxiliary worker, because your own config rightly has nomigrationsfor a class it only binds — and the object needs SQLite storage wherever it actually runs.
Your application's config is unchanged, and in particular still has no
migrations:
// wrangler.jsonc — the consumer, exactly as in the quickstart
{
"durable_objects": {
"bindings": [
{
"name": "RATE_LIMITER",
"class_name": "LimiterDO",
"script_name": "my-limiter", // === miniflareLimiterWorker({ name })
},
],
},
}The local object starts unconfigured, and a bucket that has never been
configured does not exist — so without a setup step every call rejects with
NoSuchLimiterError before proving anything. A setup file does once what the CLI
does against a deployment:
// test/setup-limiter.ts
import { env } from 'cloudflare:test';
import type { LimiterRpc } from '@bakidev/durable-rate-limiter/do';
// The one place a raw stub's type is asserted — see anti-pattern 9.
const stub = env.RATE_LIMITER.get(
env.RATE_LIMITER.idFromName('example-api')
) as unknown as LimiterRpc;
await stub.configure('example-api', {
bucket: { limitPerWindow: 1000, windowInMs: 60_000 },
concurrency: 5,
});Use limits sized for a test run, not your upstream's: this bucket is local, and the point is to exercise the wiring rather than to pace it.
One further setting is worth knowing about. The object's persistence is
write-through and fire-and-forget, so vitest-pool-workers' default
isolatedStorage — which pops a storage stack between tests — can race a write
still in flight. This repo's own suites set isolatedStorage: false and isolate
with distinct instance names instead, which is also cheaper.
That is not a recipe written from theory: test-consumer/ and
vitest.consumer.config.ts are exactly this
topology — a consumer Worker with a script_name binding and no migrations,
against the limiter as an auxiliary worker — and they run in CI on every change,
from dist/, so the published artifact is what gets tested (npm run
test:consumer).
Local dev with @cloudflare/vite-plugin
Same missing Worker, same class of failure at dev-server startup. Point the plugin at the limiter Worker's own wrangler config:
// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin';
export default defineConfig({
plugins: [
cloudflare({
// Your app's config is the plugin's default; this adds the Worker its
// `script_name` names.
auxiliaryWorkers: [{ configPath: './limiter/wrangler.jsonc' }],
}),
],
});The limiter Worker loaded this way is an entry module, so its main must not
export plain values — re-export from /do-worker, which is the build that
exists for this:
// limiter/src/index.ts
export {
LimiterDO,
LimiterEntrypoint,
} from '@bakidev/durable-rate-limiter/do-worker';
// A Worker exporting a WorkerEntrypoint still needs its own default export;
// `/do-worker` ships one, and `export *` would not carry it.
export { default } from '@bakidev/durable-rate-limiter/do-worker';If that Worker also carries the CLI's /configure route, keep its own handler
as default instead and re-export only the two classes.
The local object likewise starts unconfigured. configure is a method on the
Durable Object, so only running code can call it — there is no wrangler
command that reaches a DO method, and the CLI's configure is an HTTP POST to
the key-guarded /configure route on the limiter Worker, authorised by the
DRL_CONFIG_KEY the Worker reads from its own env. Locally, that means:
- if the limiter Worker is reachable at an origin of its own —
wrangler devagainstlimiter/wrangler.jsonc, say — point the CLI at it once withnpx @bakidev/durable-rate-limiter configure --url http://localhost:8787, withDRL_CONFIG_KEYset for the local Worker (a.dev.varsbeside its config) and exported for the CLI.--urlis remembered in the state file, so it is needed once — and it overrides the remembered origin, which is how you switch back and forth between local and deployed; - under the vite plugin an auxiliary Worker is reached through bindings, not
as its own origin, so the simpler path is to configure it from the
application: call
stub.configure(...)from a guarded dev-only route, or from theconfigureLimiter(env)moduleinitwrites when you decline the config route. It is the same one-time call as the test setup file above.
Either way it is once per local object, not once per run: local Durable Object
storage is persisted under .wrangler/ and survives a restart until you clear
it.
Known limits — stated, not buried
- No release has carried production traffic that isn't its own test harness.
The numbers below are real, from a real deployment, but they come from a load
generator built to exercise the package — not from an application that needed
the limiter for its own reasons. That is what the
0.xversion means; see Stability. - The wait queue is memory-only. An RPC function handle cannot be persisted,
so queued callbacks do not survive object eviction — measured at 2.4% of calls
under load. The client retries this for you when the callback never ran, but
the retry is bounded:
call()is still throwable, now withCallDroppedError, and a caller that must not lose work needs its own durable retry above this one. No compounded failure probability is published, because the drop rate comes from a small sample and the events are not independent — one redeploy takes out every parked caller at once. Measure yours withonDrop. - A drop after the callback started is never retried automatically. It cannot be distinguished from a completed upstream request, so it is left to you. Make such calls idempotent, or reconcile them.
fnis re-invoked on retry and must build a fresh request each time. A closure over an already-consumed body fails on the second attempt.- The limiter holds no work. If a caller disconnects, its pending call goes with it. This is not a proxy, a queue or a job runner — the caller owns the work and the limiter owns only the timing.
- Hooks are per-call, client-side. They cannot be registered once on the object; sharing an API's convention across call sites is what limiter-level defaults are for.
configurerejects anyone currently queued. It is a setup call. Rebuilding the bucket is the honest signal that their wait will never be satisfied under the limits they were waiting on.
API
@bakidev/durable-rate-limiter/client
| Export | What it is |
| ------------------------------ | ------------------------------------------------------------------ |
| defineBinder(name) | Names the DO binding, checked against your generated Env. |
| defineBinder.unchecked(name) | The same, without the compile-time check. |
| defineTestBinder(namespace) | Injects a namespace directly, for tests outside workerd. |
| defineLimiter(definition) | Captures binder, instance name, hook defaults and drop policy. |
| limiter.for(env) | Per-request bind. Where the binding's presence is checked. |
| bound.call(fn, options) | Runs fn under the shared limiter; returns what read extracted. |
| CallDroppedError | Every drop retry spent and the callback never ran. |
| NoSuchLimiterError | The bucket was never configured. Thrown at once, never retried. |
| DEFAULT_DROP_RETRIES | 5. |
Types: Binder, Limiter, BoundLimiter, LimiterDefinition, CallOptions,
DropEvent, DropHook, RateLimitHook, RateLimitSignal, ErrorHook,
FailureDescription, HookSlot, CallReport, LimiterStub, NamespaceLike,
DoBindings, and ENVELOPE_VERSION.
Telling a missing bucket from a caller dropped in transit is done for you: the
client raises NoSuchLimiterError (permanent, never retried) rather than
CallDroppedError (transient, retried) across an RPC boundary that erases error
types. The wire marker behind that decision is an internal envelope detail and
is deliberately not part of the client's public surface.
@bakidev/durable-rate-limiter/do
| Export | What it is |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| LimiterDO | The Durable Object. Re-export it from your limiter Worker. |
| LimiterEntrypoint | The named WorkerEntrypoint RPC surface. |
| LimiterNotConfiguredError | What execute, stats and reconfigure throw on a bucket that was never configured — which is to say, one that does not exist. There is deliberately no default config to fall back on. Consumers going through call() see NoSuchLimiterError instead. |
| REGISTRY_NAME | The reserved instance holding every bucket's name. Your limiter Worker's /stats route needs it; nothing else should address it. |
| CallFailedError | The rejection a reported failure becomes once it is final. |
| ENVELOPE_VERSION | Compare against ping() to catch skew. |
Types: LimiterConfig, LimiterStats, LimiterEnv, LimiterService,
LimiterRpc, LimiterPing, CallReport.
LimiterDO methods: execute(fn), configure(name, config),
reconfigure(patch), stats(), listNames(). LimiterEntrypoint takes the
instance name first: execute(name, fn), configure(name, config),
reconfigure(name, patch), stats(name), plus listNames() and ping().
configure creates and takes a complete config; reconfigure patches one
that already exists. Only configure carries the name, because a Durable Object
cannot recover the name it was addressed by — ctx.id.name is undefined
inside one — and it needs one to enter the registry listNames() reads. A
modification has nothing to register, so it needs no name.
@bakidev/durable-rate-limiter/do-worker
The /do half as a Worker entry module: LimiterDO, LimiterEntrypoint and
a default fetch that answers liveness — and deliberately nothing else, because
workerd rejects an entry module that exports a plain value. Use it as the main
of a limiter Worker that a harness loads directly (a miniflare auxiliary worker,
a @cloudflare/vite-plugin auxiliary worker); a deployed limiter Worker can
re-export from it too. Import types and constants from /do as before.
@bakidev/durable-rate-limiter/testing
| Export | What it is |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| createFakeLimiterNamespace() | A namespace for defineTestBinder with the real object's terminal semantics, plus a calls log of every CallReport. |
| miniflareLimiterWorker(cfg) | The miniflare auxiliary-worker options that make a script_name binding work under @cloudflare/vitest-pool-workers. |
Types: FakeLimiterNamespace, CapturedCall, MiniflareLimiterWorkerConfig,
MiniflareLimiterWorkerOptions.
The two run in different runtimes — the fake inside workerd, the helper in the
Node process that loads your vitest config — so this module imports no Node
built-in and nothing from cloudflare:, which is what lets one entrypoint serve
both. See Testing and local development.
Deployed verification
The claims above are about production behaviour, and none of them can be
established locally. verify/ is a reproducible harness: two Workers,
a Durable Object, and N Workflow instances that all sleep until one shared
timestamp and then stampede, so the load is genuinely concurrent across isolates
rather than sequential. It produces a plain-text report designed to be pasted
back verbatim.
It costs money to run — Workflows, Durable Object wall time, and a run that
parks callers for twenty minutes parks them for twenty minutes. Deploy it,
measure, wrangler delete both Workers.
# 1. Build. The harness imports from dist/, so it verifies the published artifact.
npm install && npm run build && npm run verify:typecheck
# 2. Limiter Worker first — the consumer's bindings name it.
npx wrangler deploy --config verify/limiter/wrangler.jsonc
# 3. Consumer, then the shared secret. The routes are internet-facing; an unset
# PROBE_KEY denies everything.
npx wrangler deploy --config verify/consumer/wrangler.jsonc
npx wrangler secret put PROBE_KEY --config verify/consumer/wrangler.jsonc
# 4. Export the consumer's workers.dev URL from step 3, and the key.
export VERIFY_URL=https://drl-verify-consumer.<your-subdomain>.workers.dev
export PROBE_KEY=<the value you just set>Each probe is independent; &via=direct swaps the two-hop service binding for
the one-hop cross-script Durable Object binding, and every route accepts it.
curl "$VERIFY_URL/ping?key=$PROBE_KEY" # both halves up, versions agree
curl "$VERIFY_URL/closure-check?key=$PROBE_KEY" # the closure runs in the caller
curl "$VERIFY_URL/closure-check?key=$PROBE_KEY&via=direct"
curl "$VERIFY_URL/client-path?key=$PROBE_KEY" # read(), hooks, envelope, end to end
curl "$VERIFY_URL/cap-probe?key=$PROBE_KEY&max=64" # per-request invocation ceiling
curl "$VERIFY_URL/cap-probe?key=$PROBE_KEY&max=64&via=direct"
# The load run: 10 Workflow instances x 10 calls, all stampeding 60s from now at
# 10/min — a ~10 minute drain, so the last caller parks well past the six-minute
# mark the design hinges on. Prints its probe id.
curl "$VERIFY_URL/start?key=$PROBE_KEY&insta