{"_id":"@bakidev/durable-rate-limiter","_rev":"2-5cf41c7bc34aa8abf77e749a7aff3286","name":"@bakidev/durable-rate-limiter","dist-tags":{"latest":"0.10.0"},"versions":{"0.9.0":{"name":"@bakidev/durable-rate-limiter","version":"0.9.0","keywords":["cloudflare","workers","durable-objects","rate-limit","rate-limiter","throttle","concurrency","429","sliding-window","backpressure"],"author":{"name":"Nurbaki Kasikci"},"license":"MIT","_id":"@bakidev/durable-rate-limiter@0.9.0","maintainers":[{"name":"mnkasikci93","email":"mnkasikci@gmail.com"}],"homepage":"https://github.com/mnkasikci/durable-rate-limiter#readme","bugs":{"url":"https://github.com/mnkasikci/durable-rate-limiter/issues"},"bin":{"durable-rate-limiter":"dist/cli.js"},"dist":{"shasum":"04597f28178d0d86c5a37102701f0a0c54e50989","tarball":"https://registry.npmjs.org/@bakidev/durable-rate-limiter/-/durable-rate-limiter-0.9.0.tgz","fileCount":9,"integrity":"sha512-6pAiEvIl98743igyxJrJBY3Yfz1Xg7kVusKje22d8g75fu+Msp5RS5Tez0J+8itguFcDsbTl68LHn7p0R2VUNA==","signatures":[{"sig":"MEYCIQCm2sepwCWL/8j9iYnOnDmlNQpt5mF1ewyLy4I1hO3bAAIhAIzme/8tP6s+VHKnFsbJfHO3GuC6CXgYNkjL4zstOv3y","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":204512},"type":"module","engines":{"node":">=18"},"exports":{"./do":{"types":"./dist/do.d.ts","import":"./dist/do.js","default":"./dist/do.js"},"./client":{"types":"./dist/client.d.ts","import":"./dist/client.js","default":"./dist/client.js"},"./package.json":"./package.json"},"gitHead":"1914120f9423d51ad882086d85ece9af710919f1","scripts":{"lint":"eslint .","test":"vitest run","build":"rm -rf dist && tsup","check":"npm run typecheck && npm run lint && npm run format:check && npm run coverage && npm run test:cli","clean":"rm -rf dist coverage","format":"prettier --write .","coverage":"vitest run --coverage","lint:fix":"eslint . --fix","test:cli":"vitest run --config vitest.cli.config.ts","typecheck":"tsc --noEmit && tsc -p cli/tsconfig.json","test:watch":"vitest","format:check":"prettier --check .","prepublishOnly":"npm run clean && npm run build && npm run check","verify:typecheck":"tsc -p verify/tsconfig.json","example:typecheck":"tsc -p example/limiter/tsconfig.json && tsc -p example/upstream/tsconfig.json && tsc -p example/app-alpha/tsconfig.json && tsc -p example/app-bravo/tsconfig.json && tsc -p example/dashboard/tsconfig.json"},"_npmUser":{"name":"mnkasikci93","email":"mnkasikci@gmail.com"},"repository":{"url":"git+https://github.com/mnkasikci/durable-rate-limiter.git","type":"git"},"_npmVersion":"11.6.2","description":"A shared rate limiter and concurrency gate for Cloudflare Workers, backed by a Durable Object.","directories":{},"sideEffects":false,"_nodeVersion":"25.2.1","publishConfig":{"access":"public"},"typesVersions":{"*":{"do":["./dist/do.d.ts"],"client":["./dist/client.d.ts"]}},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","eslint":"^9.32.0","vitest":"~3.2.0","prettier":"^3.6.0","wrangler":"^4.26.0","@eslint/js":"^9.32.0","typescript":"^5.8.0","@types/node":"^22.0.0","typescript-eslint":"^8.38.0","eslint-config-prettier":"^10.1.0","@cloudflare/workers-types":"^5.20260714.1","@vitest/coverage-istanbul":"~3.2.0","@cloudflare/vitest-pool-workers":"^0.9.0"},"peerDependencies":{"@cloudflare/workers-types":">=4"},"peerDependenciesMeta":{"@cloudflare/workers-types":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/durable-rate-limiter_0.9.0_1785504865191_0.872359275482927","host":"s3://npm-registry-packages-npm-production"}},"0.10.0":{"name":"@bakidev/durable-rate-limiter","version":"0.10.0","description":"A shared rate limiter and concurrency gate for Cloudflare Workers, backed by a Durable Object.","keywords":["cloudflare","workers","durable-objects","rate-limit","rate-limiter","throttle","concurrency","429","sliding-window","backpressure"],"license":"MIT","author":{"name":"Nurbaki Kasikci"},"repository":{"type":"git","url":"git+https://github.com/mnkasikci/durable-rate-limiter.git"},"bugs":{"url":"https://github.com/mnkasikci/durable-rate-limiter/issues"},"homepage":"https://github.com/mnkasikci/durable-rate-limiter#readme","type":"module","sideEffects":false,"engines":{"node":">=18"},"publishConfig":{"access":"public"},"bin":{"durable-rate-limiter":"dist/cli.js"},"exports":{"./do":{"types":"./dist/do.d.ts","import":"./dist/do.js","default":"./dist/do.js"},"./do-worker":{"types":"./dist/do-worker.d.ts","import":"./dist/do-worker.js","default":"./dist/do-worker.js"},"./client":{"types":"./dist/client.d.ts","import":"./dist/client.js","default":"./dist/client.js"},"./testing":{"types":"./dist/testing.d.ts","import":"./dist/testing.js","default":"./dist/testing.js"},"./package.json":"./package.json"},"typesVersions":{"*":{"do":["./dist/do.d.ts"],"do-worker":["./dist/do-worker.d.ts"],"client":["./dist/client.d.ts"],"testing":["./dist/testing.d.ts"]}},"scripts":{"build":"rm -rf dist && tsup","clean":"rm -rf dist coverage","typecheck":"tsc --noEmit && tsc -p cli/tsconfig.json && tsc -p test-consumer/tsconfig.json","lint":"eslint .","lint:fix":"eslint . --fix","format":"prettier --write .","format:check":"prettier --check .","test":"vitest run","test:watch":"vitest","test:cli":"vitest run --config vitest.cli.config.ts","test:consumer":"vitest run --config vitest.consumer.config.ts","coverage":"vitest run --coverage","check":"npm run typecheck && npm run lint && npm run format:check && npm run coverage && npm run test:cli && npm run test:consumer","verify:typecheck":"tsc -p verify/tsconfig.json","example:typecheck":"tsc -p example/limiter/tsconfig.json && tsc -p example/upstream/tsconfig.json && tsc -p example/app-alpha/tsconfig.json && tsc -p example/app-bravo/tsconfig.json && tsc -p example/dashboard/tsconfig.json","prepublishOnly":"npm run clean && npm run build && npm run check"},"peerDependencies":{"@cloudflare/workers-types":">=4"},"peerDependenciesMeta":{"@cloudflare/workers-types":{"optional":true}},"devDependencies":{"@cloudflare/vitest-pool-workers":"^0.9.0","@cloudflare/workers-types":"^5.20260714.1","@eslint/js":"^9.32.0","@types/node":"^22.0.0","@vitest/coverage-istanbul":"~3.2.0","eslint":"^9.32.0","eslint-config-prettier":"^10.1.0","prettier":"^3.6.0","tsup":"^8.5.0","typescript":"^5.8.0","typescript-eslint":"^8.38.0","vitest":"~3.2.0","wrangler":"^4.26.0"},"gitHead":"9b0b7b88b349179d01ec7ed7b5fedc35150e0478","_id":"@bakidev/durable-rate-limiter@0.10.0","_nodeVersion":"25.2.1","_npmVersion":"11.6.2","dist":{"integrity":"sha512-EUbAWsaE/VyfXaOA68mHCWO6W3rQxhn1Wb9s0NhQjAMOfyp01mxHDCbBH9ZWmJsfJk/A1qKOMuTUySMmyR1rcg==","shasum":"e3c0136580869975ebfb4a3029453bfcfe9d8533","tarball":"https://registry.npmjs.org/@bakidev/durable-rate-limiter/-/durable-rate-limiter-0.10.0.tgz","fileCount":14,"unpackedSize":266715,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCsp8NFZCzOkVBmlVK1hhrQr6N60II2Dw7t264w86krXAIhAKI2xaUPhXb9aHyZ55nz3awFqqt9FG+EiFGeUhfHWai/"}]},"_npmUser":{"name":"mnkasikci93","email":"mnkasikci@gmail.com"},"directories":{},"maintainers":[{"name":"mnkasikci93","email":"mnkasikci@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/durable-rate-limiter_0.10.0_1785510104047_0.21836486478749983"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-31T13:34:25.005Z","modified":"2026-07-31T15:01:44.440Z","0.9.0":"2026-07-31T13:34:25.350Z","0.10.0":"2026-07-31T15:01:44.203Z"},"bugs":{"url":"https://github.com/mnkasikci/durable-rate-limiter/issues"},"author":{"name":"Nurbaki Kasikci"},"license":"MIT","homepage":"https://github.com/mnkasikci/durable-rate-limiter#readme","keywords":["cloudflare","workers","durable-objects","rate-limit","rate-limiter","throttle","concurrency","429","sliding-window","backpressure"],"repository":{"type":"git","url":"git+https://github.com/mnkasikci/durable-rate-limiter.git"},"description":"A shared rate limiter and concurrency gate for Cloudflare Workers, backed by a Durable Object.","maintainers":[{"name":"mnkasikci93","email":"mnkasikci@gmail.com"}],"readme":"# @bakidev/durable-rate-limiter\n\nA shared rate limiter and concurrency gate for Cloudflare Workers, backed by a\nDurable Object.\n\n```ts\nconst value = await limiter.for(env).call(() => fetch(url, init), {\n  read: (res) => res.json(),\n});\n```\n\nOne shared window, shared by every isolate, every Workflow instance, every cron\ntick and every application bound to it. `call()` takes a **function**, not a\nrequest — so the object decides _when_ your work runs while the work itself runs\n_in your isolate_.\n\n---\n\n## The problem\n\nRate limiting is a per-process concern in most libraries: a token bucket lives\nin memory, the code that calls the API asks it for permission, and everything\nworks as long as there is exactly one process.\n\n**On Workers there is never exactly one process.** A Worker runs in many\nisolates across many locations; a Workflow spawns instances that neither know\nnor can reach each other; a cron tick and a user request fire simultaneously.\nEach constructs its own in-memory bucket, each politely paces itself to the\nconfigured rate, and collectively they exceed the upstream quota by however many\nisolates happen to be warm. **The limiter is locally correct and globally\nuseless.**\n\nThe same problem appears one level up: several _applications_ sharing one API\nkey have no way to coordinate at all.\n\nPorting an in-process limiter does not fix it, for two independent reasons:\n\n- **Timers are not allowed where the state would have to live.** Workers forbids\n  `setTimeout`/`setInterval` at module scope, which is the only place a shared\n  singleton could sit — and the failure appears **only at deploy**, never\n  locally.\n- **In-memory window state is destroyed constantly.** An isolate is discarded\n  between requests; a Durable Object is evicted after 70–140 seconds idle. An\n  in-memory count resets to _an empty window_ on every cold start, handing out a\n  fresh full allowance against someone else's quota precisely when traffic has\n  just resumed.\n\nSo the window has to be **state-driven rather than event-driven** — its usage\nread from a persisted `{ grants, forcedUntil }` log, one grant per take, pruned\nagainst the wall clock at read time — and it has to live somewhere with identity\nand durable storage. On Cloudflare that means a Durable Object: the\nonly primitive that guarantees a single instance serialising all callers against\none piece of state.\n\n### Why a function and not a request\n\nA conventional gateway proxies: you hand over a URL, headers and a body, it\nperforms the request when the limit allows. That forces every byte through a\nsingle-threaded object, requires the gateway to hold your credentials, and makes\nit specific to each upstream's auth and error conventions.\n\nThis package sends a function. Workers RPC does not serialise functions — it\npasses a handle, and invoking that handle calls back into the isolate the\nfunction came from. Everything else follows:\n\n- **Payloads never transit the object.** A multi-megabyte download happens in\n  your isolate. The object sees only a small summary.\n- **No credentials cross.** You build your own headers; the limiter holds no\n  secrets and parses nothing but `{ status, retryAfter }`.\n- **It is enforcement, not cooperation.** The object controls the moment of\n  execution, so a caller cannot fire early no matter what it intends.\n- **Concurrency is genuinely measured.** The object awaits your callback, so it\n  knows when work _finishes_ — which a design handing out permits or timestamps\n  can never know.\n- **One caller's 429 throttles all of them**, in every isolate and every\n  application, with no reporting protocol for anyone to forget to implement.\n\n---\n\n## Install\n\n```sh\nnpm install @bakidev/durable-rate-limiter\n```\n\nFour subpath exports; the first two are the ones an application uses.\n\n| Import                                    | Who uses it                                                                             |\n| ----------------------------------------- | --------------------------------------------------------------------------------------- |\n| `@bakidev/durable-rate-limiter/do`        | the **limiter Worker** you deploy once                                                  |\n| `@bakidev/durable-rate-limiter/client`    | every **consuming application**                                                         |\n| `@bakidev/durable-rate-limiter/do-worker` | the same `/do` half as a **Worker entry module** — for harnesses that load it directly  |\n| `@bakidev/durable-rate-limiter/testing`   | **test doubles and harness wiring**: the fake namespace, the miniflare auxiliary worker |\n\n`/do` and `/client` are built from one shared envelope definition, so the halves\ncannot drift. `/do-worker` exists because workerd validates **every** top-level\nexport of a module it loads as an entry — a miniflare auxiliary worker's script,\na `@cloudflare/vite-plugin` auxiliary worker — and `/do` exports plain values\n(`ENVELOPE_VERSION`, `REGISTRY_NAME`, the error classes) alongside its classes,\nso workerd rejects it with `Incorrect type for map entry 'ENVELOPE_VERSION': the\nprovided value is not of type 'function or ExportedHandler'`. `/do-worker`\nexports `LimiterDO`, `LimiterEntrypoint` and a default fetch handler and nothing\nelse. Your deployed limiter Worker can simply re-export from it; the reason it\nexists is [testing](#testing-and-local-development), where the entry module is\nloaded from `node_modules` with no bundler in front of it.\n\n### Requirements\n\n- **Workers types.** The published type surface references ambient Workers types\n  (`DurableObjectNamespace`, `DurableObjectStub`, `DurableObjectId`). They are\n  declared as an **optional** `peerDependency` on `@cloudflare/workers-types`\n  (`>=4`): a project that types its runtime through wrangler's generated\n  `worker-configuration.d.ts` already has them and needs nothing, and a project\n  that does not gets a clear install hint instead of bare \"cannot find name\"\n  errors. Install it if you see those errors: `npm i -D @cloudflare/workers-types`.\n- **Module resolution.** `moduleResolution: \"bundler\"` (or `\"nodenext\"`) is\n  recommended and resolves the subpath exports directly. For older TS configs on\n  classic `moduleResolution: \"node\"`, a `typesVersions` map is shipped as a\n  fallback so all four subpaths still resolve their declarations. The package\n  is subpath-only by design — there is no root `.` import.\n- **`compatibility_date`.** The floor is **`2024-04-03`** — the date Workers RPC\n  (`WorkerEntrypoint`) became available, which is the only platform feature the\n  limiter depends on. SQLite-backed Durable Objects are enabled by the\n  `new_sqlite_classes` migration, not by a compatibility date. Any date at or\n  after the floor works; the CLI scaffolds at the floor for maximum\n  compatibility, and you may raise it.\n\n### Stability — this is a `0.x` release\n\nThe logic is covered at 100% and the production claims below were measured\nagainst a real deployment, not emulation. What has **not** happened is a release\nrunning someone else's traffic. Version `0.x` says that, and buys the room to fix\nan interface the first real integration proves wrong.\n\nTwo things are deliberately unsettled until `1.0`, both because they are the ones\nthat would need a migration story rather than a patch:\n\n- **The RPC envelope.** `ENVELOPE_VERSION` may be bumped in a `0.x` minor if the\n  wire shape turns out to be wrong. It stays at `1` unless that happens.\n- **The persisted `{ grants, forcedUntil }` log.** A shape change means existing\n  buckets need converting, and the conversion path is worth designing against\n  real data.\n\nVersioning follows semver's `0.x` rules, which are what protects you here:\n`^0.10.0` resolves to `>=0.10.0 <0.11.0`, so a **minor** bump carries anything\nbreaking and a **patch** carries fixes only. A caret range cannot pull a breaking\nchange into your build. Pin exactly if you want even that decision to be yours.\n\n---\n\n## TL;DR — setup in five steps\n\nMinimal configuration, start to first paced call. This uses the one-hop\ncross-script binding, which is the shortest correct path; the service-binding\ntopology is described [below](#the-other-topology-a-service-binding).\n\n> **Or let the CLI do it.** `npx @bakidev/durable-rate-limiter init`, run from\n> your application's root, walks these five steps and writes every file below —\n> including your upstream's real limit, written verbatim as `limitPerWindow`. It\n> shows each file and command before it acts. [What it does, exactly.](#the-setup-cli)\n\n### 1. Create the limiter Worker\n\nThe object **must live in a Worker of its own** — see\n[anti-pattern 6](#6-dont-put-the-durable-object-in-your-application-worker). It\ncontains no logic; it re-exports the package.\n\n```ts\n// limiter/src/index.ts\nexport { LimiterDO, LimiterEntrypoint } from '@bakidev/durable-rate-limiter/do';\n\n// A Worker exporting a WorkerEntrypoint still needs its own default export.\nexport default {\n  fetch: () => new Response('limiter ok'),\n};\n```\n\n```jsonc\n// limiter/wrangler.jsonc\n{\n  \"name\": \"my-limiter\",\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2024-04-03\", // floor; any later date works too\n  \"durable_objects\": {\n    \"bindings\": [{ \"name\": \"RATE_LIMITER\", \"class_name\": \"LimiterDO\" }],\n  },\n  \"migrations\": [{ \"tag\": \"v1\", \"new_sqlite_classes\": [\"LimiterDO\"] }],\n}\n```\n\n```sh\nnpx wrangler deploy --config limiter/wrangler.jsonc\n```\n\nDeploy this **first**. A consumer's binding names the Worker, and the binding\ncannot be created before the Worker it names exists.\n\n### 2. Bind it from your application\n\nYour application binds the class cross-script. Note there is deliberately **no\n`migrations` entry** here: this Worker _binds_ the class, it does not\n_implement_ it — which is also what keeps your preview URLs.\n\n```jsonc\n// app/wrangler.jsonc\n{\n  \"durable_objects\": {\n    \"bindings\": [\n      {\n        \"name\": \"RATE_LIMITER\",\n        \"class_name\": \"LimiterDO\",\n        \"script_name\": \"my-limiter\", // the Worker from step 1\n      },\n    ],\n  },\n}\n```\n\n```sh\nnpx wrangler types   # so defineBinder can typecheck the binding name\n```\n\n### 3. Define the limiter at module scope\n\n```ts\n// app/src/limiter.ts\nimport {\n  defineBinder,\n  defineLimiter,\n} from '@bakidev/durable-rate-limiter/client';\n\nconst binder = defineBinder('RATE_LIMITER'); // WHICH binding — typechecked\nexport const api = defineLimiter({ binder, name: 'example-api' }); // WHICH bucket\n```\n\n> **Define at module scope. Bind and call wherever you have `env`.**\n\n`defineBinder` and `defineLimiter` perform no I/O and start no timers, so a\nconfigured limiter is safe as a module-scope singleton. Binding is a separate\nstep because it needs `env` — a fetch or scheduled handler, a queue consumer, a\nWorkflow step, a Durable Object method all qualify. Module scope is the one\nplace that does not, because `env` does not exist there.\n\n`name` is the instance name (`idFromName`'s argument): `example-api`,\n`billing-api` and `search-api` are independent buckets on the same class and the\nsame binding.\n\n### 4. Call\n\n```ts\n// app/src/index.ts\nimport { api } from './limiter.js';\n\nexport default {\n  async fetch(request: Request, env: Env): Promise<Response> {\n    const limiter = api.for(env); // here, because `env` exists here\n\n    const file = await limiter.call(\n      () =>\n        fetch('https://api.example.com/v1/files', {\n          headers: { authorization: `Bearer ${env.TOKEN}` },\n        }),\n      { read: (res) => res.json<{ id: string }>() }\n    );\n\n    return Response.json(file);\n  },\n};\n```\n\nThat call may `await` for minutes. That is the design, and it is cheap — see\n[what waiting costs](#what-waiting-costs).\n\n### 5. Set your limits — this is what creates the bucket\n\n**A rate limit is never assumed.** There is no default configuration. A bucket\nthat has never been configured **does not exist**: it holds zero bytes of\nstorage, and `execute` and `stats` on it throw `LimiterNotConfiguredError`\nrather than pacing it at some invented rate.\n\nThat is deliberate, and it is the difference between a typo you find in\ndevelopment and one you find when your API key is banned. A mistyped instance\nname is not an error anywhere else in the system — it is simply a _different\nbucket_ — so a fallback default would turn it into a second limiter running at a\nplausible-looking rate against the same upstream quota, invisibly, possibly\ntaking down every other application sharing that quota with you.\n\nIt also makes `listNames()` trustworthy: a name enters the registry when it is\nconfigured, and nothing that was not configured can run, so nothing live is\nmissing from the list.\n\n`configure` is a **setup call, not a per-request one**; it is persisted, and it\nrejects anyone currently queued.\n\n```ts\n// Run once — from a deploy script, an admin route, or a guarded first-run path.\nconst stub = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName('example-api'));\n\n// The name goes in twice on purpose: once to address the object, once so the\n// object knows what it is called. It cannot work that out for itself —\n// `ctx.id.name` is undefined inside a Durable Object — and it needs a name to\n// enter the registry that makes `listNames()` possible.\n//\n// The config is COMPLETE, not a patch. There is nothing to merge a fragment\n// onto, and a half-specified bucket is exactly what this refuses to create.\nawait stub.configure('example-api', {\n  bucket: { limitPerWindow: 60, windowInMs: 60_000 },\n  concurrency: 5,\n  retry: { maxRetries: 3, maxDelayInMs: 30_000 },\n});\n\nconsole.log(await stub.stats()); // name, remaining, resetAt, penalty state, in-flight\n\n// To change one knob later, patch it. No name, because an existing bucket is\n// already registered — and it throws if there is nothing there to patch.\nawait stub.reconfigure({ concurrency: 8 });\n```\n\nCreation is all-or-nothing. Registering and configuring are writes to two\ndifferent objects, so there is no transaction to hold them together — the\nregistration goes first, so the only survivable failure is a name with no bucket\nbehind it, never a live bucket nobody can see. If the config write then fails,\nthe registration is undone and the object erases itself; and any name that slips\nthrough anyway is pruned the next time `stats` walks the list.\n\n---\n\n## The setup CLI\n\nEvery step above is mechanical, and every one of them is a chance to get a name\nwrong — a mistyped instance name does not error, it silently creates a second\nbucket. The CLI asks instead.\n\n```sh\ncd my-application\nnpx @bakidev/durable-rate-limiter init\n```\n\nIt walks the five steps in the order they must happen — the limiter Worker\nfirst, because a consumer's binding names it — and for each one:\n\n| Step           | What `init` does                                                                                                                                         |\n| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| limiter Worker | scaffolds `src/index.ts` and `wrangler.jsonc`                                                                                                            |\n| binding        | asks which topology, then inserts the binding into your existing config                                                                                  |\n| types          | offers to run `wrangler types`, so `defineBinder` typechecks the binding name                                                                            |\n| 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 |\n| limits         | asks your upstream's real limit, writes it verbatim as `limitPerWindow`, and writes an editable limits file                                              |\n| deploy         | deploys the limiter Worker, sets its guard secret, and applies those limits — the bucket is live before `init` exits                                     |\n\nIt opens by telling you to commit first, and reports whether your working tree is\nclean, because everything after that is easiest to read as a diff. Nothing is\nwritten or run before it is shown, existing files are never overwritten without\nasking, and your wrangler config is edited by insertion rather than reserialised\n— a round-trip through a JSON parser would delete every comment in a file whose\nformat exists to have them. If the relevant key is already present, `init` prints\nthe fragment for you to merge instead of guessing.\n\nAnything it could not do ends up in a \"still to do\" list with the exact command.\n`--yes` takes every default without asking, and never deploys.\n\n### Configuring without writing a deploy script\n\n`configure` is a method on the Durable Object, so **only a deployed Worker can\ncall it** — no `wrangler` command reaches a DO method. `init` therefore offers to\ngive the limiter Worker a key-guarded route, and to put your limits in a file\nbeside it:\n\n```jsonc\n// durable-rate-limiter/durable-rate-limiter.limits.jsonc\n// One entry per upstream limit. NOT read at runtime — see below.\n{\n  \"limits\": {\n    \"read-api\": {\n      \"bucket\": { \"limitPerWindow\": 60, \"windowInMs\": 60000 },\n      \"concurrency\": 5,\n    },\n    \"write-api\": {\n      \"bucket\": { \"limitPerWindow\": 30, \"windowInMs\": 60000 },\n      \"concurrency\": 2,\n    },\n  },\n}\n```\n\n**This file is never deployed and the limiter Worker never imports it.** The\nlimits are durable state inside the Durable Object; this is the copy you keep in\nversion control, and `configure` is what carries one to the other. That is the\nwhole reason it is JSONC rather than TypeScript — a TypeScript file would have to\nbe imported by the Worker, which would make every limit change a code change and\nevery code change a deploy.\n\nSo the limits still live in version control and still change in a reviewable\ndiff, but retuning one costs a single command:\n\n```sh\nnpx @bakidev/durable-rate-limiter configure     # upload the file — no deploy\nnpx @bakidev/durable-rate-limiter stats         # read every bucket's live state back\nnpx @bakidev/durable-rate-limiter stats --save  # ...and overwrite the file with it\nnpx @bakidev/durable-rate-limiter sample        # write an example file to start from\n```\n\nBoth commands need to know **where the limiter answers**. They remember it from\nthe deploy `init` performed, and ask once if they do not know — but a limiter you\ndeployed yourself, one that moved behind a custom route, or one running locally\nwas never recorded, and a prompt in CI is a hang rather than a question. `--url`\n(or `--url=`) is the answer, on either command:\n\n```sh\nnpx @bakidev/durable-rate-limiter configure --url https://my-limiter.example.workers.dev\n```\n\nIt takes precedence over the remembered origin and is written back, so it is\nneeded once rather than every run. Together with `DRL_CONFIG_KEY` in the\nenvironment and `--yes`, that makes both commands fully non-interactive. And\nwhen there is genuinely nobody to ask — stdin is not a TTY — they now fail\nimmediately, naming the flag or variable that would have answered the question,\ninstead of blocking on a pipe.\n\n`stats` needs no list of names: the object keeps a registry of every bucket that\nhas been configured, because a Durable Object namespace cannot be enumerated —\nthere is no `list()`, and `idFromName` does not run backwards. `stats --save` is\ntherefore the way to get an accurate limits file for a limiter you inherited, or\nto recover one you lost.\n\nRedeploy the limiter Worker only when **its own code** changes — a package\nupgrade, an edit to its `index.ts`. Never for a limit. If you point a current CLI\nat a Worker deployed before this arrangement existed, `configure` notices it\nignored the upload and tells you to redeploy once.\n\nBoth routes are guarded by a `DRL_CONFIG_KEY` secret and **deny everything while it\nis unset** — an unset secret means denied, never open. `init` generates a key,\nsets it with `wrangler secret put`, and applies your limits before it exits, so\nthe bucket is configured by the time anything calls it. The Worker's secret and\nthe environment variable the CLI reads share that one name deliberately — seeing\n`DRL_CONFIG_KEY` in the Cloudflare dashboard should tell you what it belongs to.\nExport it to skip the prompt in CI.\n\nA secret cannot be read back, so a key you have lost and a key you have typed\nwrong have the same remedy: set a new one, which replaces it. Both commands say\nso at the point it matters.\n\n```sh\nnpx wrangler secret put DRL_CONFIG_KEY --config durable-rate-limiter/wrangler.jsonc\n# or: Workers & Pages → your limiter Worker → Settings → Variables and Secrets\n```\n\n`init` leaves a `.durable-rate-limiter.jsonc` **inside the limiter's own folder**\n— your project root gains one directory and nothing else. It records the Worker\nname, its config, where the limits file is and the deployed URL; no secrets, so\ncommit it. Every path in it is relative to that folder, and `configure`/`stats`\nfind it from anywhere in the project, so they work from a subdirectory too.\n\nThe two files are deliberately separate. That one is written by the CLI and\nrewritten without warning; the limits file is written once and never touched\nagain, so a comment explaining why a limit is what it is survives.\n\nDecline the route and `init` writes a `configureLimiter(env)` module instead, for\nyou to call from a deploy script, an admin route, or a guarded first-run path.\n\n> `configure` rebuilds each bucket and **rejects anyone currently queued** —\n> their wait can never be satisfied under limits that no longer exist. It is a\n> setup call, not a per-request one. Prefer a quiet moment.\n\n---\n\n## 🚨 The sizing rule\n\n> ### Set `limitPerWindow` to the upstream limit, verbatim.\n>\n> \"100 per minute\" is `{ limitPerWindow: 100, windowInMs: 60_000 }`. A rested\n> caller may spend all of it at once — that is the point, and what the surveyed\n> upstreams themselves enforce.\n\nAnd that is the whole of it — the guarantee is exact. The pacing is a\n**sliding log**: every take is recorded and counts against the allowance until it\nis `windowInMs` old, so the allowance is measured continuously and no _rolling_\nwindow ever holds more than `limitPerWindow`. A rested caller may still spend the\nwhole limit at once, and the peak in any window is bounded by `limitPerWindow`\nwith nothing to add on top:\n\n| Upstream limit | Config                                       | Steady state | Any rolling window |\n| -------------- | -------------------------------------------- | ------------ | ------------------ |\n| 60 / minute    | `{ limitPerWindow: 60, windowInMs: 60_000 }` | 60 / minute  | never above 60     |\n| 30 / minute    | `{ limitPerWindow: 30, windowInMs: 60_000 }` | 30 / minute  | never above 30     |\n\nOn top of that guarantee, the same `pause()` feedback a real `429` drives\nthrottles **every** caller and reopens the recovering window at half its\nallowance, so a limit tripped upstream damps the next call rather than\ncompounding it.\n\n---\n\n## Anti-patterns\n\nEach of these compiles, and several of them work in local development.\n\n### 1. Don't hand it a request — hand it a function\n\n```ts\n// ❌ The fetch has already fired. The limiter paced nothing.\nawait limiter.call(fetch(url), { read });\n\n// ❌ Same problem in disguise: `fetch` unbound, and no way to build a fresh\n//    request per attempt.\nawait limiter.call(fetch, { read });\n\n// ✅ A thunk. The object decides when this runs.\nawait limiter.call(() => fetch(url), { read });\n```\n\nThe whole design rests on the limiter receiving something it can invoke _later_.\nAnything already in flight is outside its control, and passing `fetch` itself\ngives it nothing to send.\n\n### 2. Don't close over a built `Request`\n\n`fn` is re-invoked **from scratch** on every retry, so it must construct its own\nrequest each time.\n\n```ts\n// ❌ Body already consumed on attempt two.\nconst req = new Request(url, { method: 'POST', body });\nawait limiter.call(() => fetch(req), { read });\n\n// ✅ Fresh request per attempt.\nawait limiter.call(\n  () => fetch(url, { method: 'POST', body: JSON.stringify(payload) }),\n  { read }\n);\n```\n\nThe wrong form fails on retry with a \"body already used\" error that looks\nnothing like a retry problem.\n\n### 3. Don't throw on a non-2xx — report it\n\nWorkers RPC reconstructs a thrown error from `name`, `message` and `stack`\n**only**. Every custom property is stripped crossing the boundary — a `status`,\na `code`, a `retryable` flag. Retryability therefore _cannot_ be signalled by\nthrowing: the object sees something indistinguishable from a network blip and\nretries your 404 to exhaustion.\n\n```ts\n// ❌ `retryable` and `status` do not survive the hop.\nawait limiter.call(\n  async () => {\n    const res = await fetch(url);\n    if (!res.ok) throw Object.assign(new Error('bad'), { status: res.status });\n    return res;\n  },\n  { read }\n);\n\n// ✅ The failure travels as data, and the object honours the decision.\nawait limiter.call(() => fetch(url), {\n  read: (res) => res.json(),\n  error: (res) =>\n    res.ok ? null : { message: 'bad', retryable: res.status >= 500 },\n});\n```\n\nThe plain HTTP status is carried for you automatically; the `error` hook is for\nfailures your upstream hides in a 200 body.\n\n### 4. Don't return the response (or a big payload) from `read`\n\n`read` exists to extract the small thing you actually need while the body stays\nlocal. Returning something large drags it back through a single-threaded object\nto reach the caller that already had it.\n\n```ts\n// ❌ Defeats the entire point.\n{\n  read: (res) => res.blob();\n}\n\n// ✅ Do the whole job caller-side, return an identifier.\n{\n  read: async (res) => (await uploadToStorage(res.body!, folderId)).id;\n}\n```\n\n`read` also runs on **error** responses, so read defensively if your upstream\nreturns a different shape on failure — a throw from `read` propagates and is\nretried as an unknown error.\n\n### 5. Don't do work at module scope, and don't bind there\n\n```ts\n// ❌ `env` does not exist at module scope, and a timer there fails at DEPLOY —\n//    never locally, never in tests.\nconst bound = api.for(env);\n\n// ✅ Define at module scope; bind and call wherever `env` reaches you.\nexport const api = defineLimiter({ binder, name: 'example-api' });\n```\n\nThis is exactly what sank the predecessor package: its constructor started an\ninterval, so a module-scope singleton could not be constructed at all.\n\n### 6. Don't put the Durable Object in your application Worker\n\nTwo platform constraints, both verified:\n\n- **A Worker implementing a Durable Object gets no preview URLs.** This applies\n  to the whole Worker, not just the object.\n- **A new Durable Object migration cannot be uploaded as a version.** A version\n  containing both a migration and a binding referencing the new class is\n  rejected with 403. Migrations apply only on deployment, and are atomic.\n\nA Worker that merely _binds_ an object defined elsewhere (via `script_name`) does\nnot implement one, and is unaffected. Giving the limiter its own Worker is\nrequired anyway for multi-application use.\n\n### 7. Don't mistype the instance name\n\nA mistyped `name` does not error. It silently creates a **second bucket**, and\neach paces at the full configured rate against one upstream quota. The failure\nsurfaces later as unexplained 429s from an upstream nobody was over-calling.\nThis is why `name` lives on the limiter definition — written exactly once.\n\n### 8. Don't export the entrypoint as `default`\n\n```ts\n// ❌ Typechecks, then fails at startup: \"has no such named entrypoint\".\nexport default class LimiterEntrypoint { ... }\n\n// ✅ A service binding with \"entrypoint\" resolves against NAMED exports only.\nexport { LimiterEntrypoint } from '@bakidev/durable-rate-limiter/do';\n```\n\n### 9. Don't let a raw stub type reach a call site\n\nRPC **erases generics — completely, and silently.**\n`DurableObjectStub<LimiterDO>['execute']` resolves to `never`, including for\nmethods whose type parameter is inferred from an argument. `never` is assignable\nto everything, so nothing errors; type checking simply stops at the stub\nboundary and a completely wrong call still compiles.\n\nType a service binding as `LimiterService`, and assert a direct stub to\n`LimiterRpc` exactly once, where the stub is obtained. `defineBinder` already\ndoes this for you.\n\n### 10. Don't use `rateLimit` for one endpoint's failure\n\nThe two hooks mean different things, and blurring them turns one endpoint's 500\ninto a stall for every other caller of the same upstream.\n\n| Hook        | Non-null result means                        | Scope                              |\n| ----------- | -------------------------------------------- | ---------------------------------- |\n| `rateLimit` | treat exactly as an HTTP 429 with this delay | **global** — pauses every caller   |\n| `error`     | the call failed; `retryable` decides         | **local** — retries this call only |\n\n### 11. Don't block or throw inside `onDrop`\n\nIt runs on the caller's path between attempts. Anything slow there is added\ndirectly to the latency of a call that has already been unlucky once.\n\n---\n\n## Features\n\n### Global rate limiting that actually holds\n\nOne sliding log in a Durable Object, shared by every isolate, every Workflow\ninstance, every cron tick, and every application bound to it. Not a window per\nisolate. A rested caller may spend the whole limit at once, and no rolling window\never holds more than it — the allowance is bounded continuously. See\n[the sizing rule](#-the-sizing-rule).\n\n### Real concurrency limiting\n\nA cap on calls actually in flight, not an approximation derived from rate.\nBecause the object awaits the callback, it knows when work _finishes_. Measured\nholding at exactly the configured value under variable production latency.\n\n### Cross-caller backpressure\n\nA rate-limit response seen by one caller pauses the shared bucket for all of\nthem — including callers already queued, in other isolates, in other\napplications. No reporting protocol, nothing for a consumer to forget to\nimplement.\n\n`Retry-After` is honoured in both documented forms (integer seconds and HTTP\ndate) and from both header shapes (a `Headers` object and a plain object). Where\na delay must be inferred instead, exponential backoff clamped to a maximum.\n\nConcurrent penalties do not stack: the deadline is the maximum of existing and\nnew, so three simultaneous `5s / 60s / 5s` responses wait 60 seconds, not 5. A\npenalty does not reopen a full window either — it sets a floor on the spend the\nrecovering window carries, leaving at most `penaltyRefillFraction` of the limit\navailable (default `0.5`, so half), because a full window aimed at an API that\njust asked for backoff re-trips it immediately. It is a floor, not a reset: real\ntakes from before the penalty that are still inside their window keep counting,\nso the window can open with less than that available but never more.\n\n### Rate limits and errors hidden in response bodies\n\nNot every API says 429. Two optional hooks handle the ones that don't\n([their scopes differ](#10-dont-use-ratelimit-for-one-endpoints-failure)):\n\n```ts\nexport const api = defineLimiter({\n  binder,\n  name: 'example-api',\n  rateLimit: (res, body) =>\n    (body as ApiError)?.error?.status === 'RATE_LIMIT_EXCEEDED'\n      ? { retryAfterMs: 60_000 }\n      : null,\n  error: (res, body) =>\n    (body as ApiError)?.error\n      ? {\n          message: (body as ApiError).error.message,\n          retryable: res.status >= 500,\n        }\n      : null,\n});\n```\n\nBoth resolve in three chained layers — call site, then limiter default, then\nbuilt-in HTTP — each falling through on `null`. The HTTP layer is\n**unconditional**, so overriding a hook for one odd endpoint never silently\ndisables genuine 429 handling there. An explicit `null` at a call site opts out\nof both hook layers; the HTTP layer still applies.\n\nDefaults live on the limiter definition, so an API's convention is written once\nand reused across every call site.\n\n### Retries with correct 4xx handling\n\nClient errors are not retried; 429 is. Exponential backoff with a configurable\nfloor, ceiling and factor, and partial options **merged** with the defaults\nrather than replacing them wholesale.\n\n### Dropped callers are retried for you\n\nA caller parked in the object's memory-only queue can be dropped — measured at\n2.4% of calls under load. The client retries that automatically, and safely even\nfor non-idempotent work. [Full detail below.](#dropped-callers-and-why-the-package-retries-them)\n\n### Survives eviction without a burst\n\nBucket state is a persisted `{ grants, forcedUntil }` log — one grant per take —\npruned against the wall clock when read. An object evicted after 70–140 seconds\nidle resumes with its outstanding grants intact — not a fresh full window,\nwhich is what an in-memory counter opens, precisely when traffic resumes.\n\n### Idle limiters cost nothing\n\nNo timer exists unless a caller is waiting; one timer serves the whole queue,\nsized to the exact deficit, cleared when the queue drains. An idle object\nhibernates.\n\n### Many limiters, one deployment\n\nInstances are addressed by name, so `example-api`, `billing-api` and `search-api` are\nindependent buckets on one class and one binding. `defineBinder` is declared once\nand reused.\n\n### Typechecked bindings\n\n`defineBinder` is constrained to keys of your generated `Env` that are actually\nDurable Object namespaces. A typo fails to compile; so does pointing at a KV or\nD1 binding. Zero runtime cost. A runtime presence check at bind time covers\nconsumers who haven't run `wrangler types`, naming the bindings it _did_ find.\n\nWithout a generated `Env` there is nothing to match against and every argument is\nrejected — that is what `defineBinder.unchecked('RATE_LIMITER')` is for.\nExplicit, so the absence of checking is visible at the call site rather than\ninferred from a mysteriously permissive signature.\n\n### Testable without magic\n\nUnder `@cloudflare/vitest-pool-workers` the real binding exists and is backed by\na local Durable Object, so most tests need no seam at all. For unit tests outside\nworkerd, `defineTestBinder` injects a namespace directly and returns the same\n`Binder` type, so the module under test is unchanged — and\n`createFakeLimiterNamespace` is a namespace built to plug straight into it.\n\n```ts\nimport {\n  defineTestBinder,\n  defineLimiter,\n} from '@bakidev/durable-rate-limiter/client';\nimport { createFakeLimiterNamespace } from '@bakidev/durable-rate-limiter/testing';\n\nconst fake = createFakeLimiterNamespace();\nconst api = defineLimiter({\n  binder: defineTestBinder(fake.namespace),\n  name: 'example-api',\n  error: (res) =>\n    res.ok ? null : { message: 'upstream failed', retryable: false },\n});\n\n// A success resolves with whatever `read` extracted.\nawait expect(\n  api.for({}).call(() => new Response('{}', { status: 200 }), {\n    read: (res) => res.text(),\n  })\n).resolves.toBe('{}');\n\n// A reported failure REJECTS, exactly as the real object does once it is final.\nawait expect(\n  api.for({}).call(() => new Response('nope', { status: 400 }), {\n    read: (res) => res.text(),\n  })\n).rejects.toThrow('upstream failed (status 400)');\n```\n\n⚠️ **Do not hand `defineTestBinder` the tempting one-liner**\n`{ execute: async (fn) => (await fn()).value }`. It resolves even when the report\ncarries a `failure`, which the real object never does — so a wrapper that parses\nthe returned value gets fed error bodies in tests, a behaviour production never\nshows. The fake keeps the outcome honest: a report with `failure` rejects with\nthe same `CallFailedError` the object would build; anything else resolves with\n`value`.\n\nIt is terminal-only on purpose. There is no waiting, no pacing and no\nre-invoking of `fn` — retry _scheduling_ is the object's concern, and a fake that\nslept through backoffs would slow your tests without proving anything about it.\nA **retryable** failure therefore also rejects immediately, as if its retries\nwere already spent.\n\n`fake.calls` captures `{ name, report }` for every call in order, which is how\nhook wiring is asserted: the hooks run client-side, so the `CallReport` is the\nonly place their effect is visible.\n\n```ts\nconst quota = createFakeLimiterNamespace();\nconst limited = defineLimiter({\n  binder: defineTestBinder(quota.namespace),\n  name: 'example-api',\n  // This upstream signals a quota breach as 403 with a body-derived delay.\n  rateLimit: (res) => (res.status === 403 ? { retryAfterMs: 60_000 } : null),\n});\n\n// Resolves: a rate limit is the object's business, not a failed call.\nawait limited.for({}).call(() => new Response('{}', { status: 403 }), {\n  read: (res) => res.text(),\n});\n\nconst [{ name, report }] = quota.calls;\nexpect(name).toBe('example-api'); // which bucket it addressed\n// It crossed the boundary as a 429 — the one vocabulary the object understands —\n// with the body-derived delay attached.\nexpect(report.status).toBe(429);\nexpect(report.retryAfterMs).toBe(60_000);\n```\n\nA failure is visible the same way, and travels as **data** rather than a throw —\n`retryable` is a custom property, and custom properties do not survive the RPC\nboundary on an `Error`. Using the first `fake` above, after its 400:\n\n```ts\nexpect(fake.calls.at(-1)?.report.failure).toEqual({\n  message: 'upstream failed',\n  retryable: false,\n});\n```\n\n`CallReport` is exported as a type from both `/client` and `/do`.\n\nNo allowlisted magic names. The injection point is explicit and discoverable.\nFor the binding that a `script_name` consumer needs under vitest, see\n[Testing and local development](#testing-and-local-development).\n\n### Safe at module scope\n\n`defineBinder` and `defineLimiter` perform no I/O and start no timers, so a\nconfigured limiter can be exported as a module-scope singleton.\n\n### Observable\n\n`stats()` returns the remaining window allowance, when it next resets, penalty\nstate, in-flight count and the raw persisted log. A shared limiter nobody can\ninspect is a shared limiter nobody will trust.\n\n### Version skew is loud\n\n`ping()` reports the limiter Worker's `ENVELOPE_VERSION`. The two halves deploy\non independent schedules, so a consumer can compare it against its own at startup\ninstead of discovering the mismatch as silent mis-limiting.\n\n---\n\n## Dropped callers, and why the package retries them\n\nThe object's wait queue is memory-only — an RPC function handle cannot be\npersisted, so a queue of them cannot be written to storage. A caller parked in\nthat queue holds an open RPC connection, and if the object is evicted, reset or\nredeployed while it waits, the connection breaks and the call rejects with a\ntransport error.\n\nMeasured against a real deployment across four runs of ten Workflow instances\nstampeding one bucket: **7 of 290 calls, 2.4%** (95% CI 1.2–4.9%). Every one was\ndropped _while parked_; none died once its callback had started. The waits at the\nmoment of the drop ran from 47 s to 8.8 min and did not cluster at the long end,\nso this is not a duration ceiling — it reads as eviction or restart landing on\nwhoever happens to be queued.\n\nAt that rate, \"callers must retry\" is not a footnote. The client retries it, up\nto five times by default, so a call has to be dropped **six separate times** to\nfail. In the run made after this shipped, both drops recovered on their first\nretry and the run finished with zero failures.\n\n```ts\nexport const api = defineLimiter({\n  binder,\n  name: 'example-api',\n  dropRetries: 5, // the default; 0 opts out\n  onDrop: ({ limiter, attempt, willRetry, error }) =>\n    metrics.increment('limiter.drop', { limiter, willRetry }),\n});\n```\n\n**Why this is safe even for non-idempotent work.** The retry fires on exactly one\ncondition: the callback never ran. That is knowable rather than guessed, because\nthe callback runs in _your_ isolate — if it never fired, no request reached the\nupstream and there is nothing to duplicate. A connection lost _after_ the\ncallback started is never retried for you; that case is genuinely ambiguous, and\ndeciding it on your behalf could send a payment twice. It propagates unchanged,\nfor you to make idempotent or reconcile.\n\nThe retry takes a fresh stub, because the handle whose connection just broke\nwould otherwise repeat the failure instantly. It adds no backoff — a retry must\ntake from the bucket again before it runs, so the bucket's own pacing is already the\nwait, which also spreads the attempts out rather than firing them into one bad\nwindow. When the attempts are spent, the call rejects with `CallDroppedError`,\ncarrying `attempts`, `limiter` and the original transport message as `cause`.\nUnlike the errors thrown inside the object, this one never crosses an RPC\nboundary, so its properties actually survive to be read.\n\n**A limiter that does not exist is not a drop.** Both failures arrive the same\nway — a rejection from the object before the callback ever fired — so the client\nhas to tell them apart, and it does: a bucket that was never configured rejects\nwith `NoSuchLimiterError` on the _first_ attempt, with no retries and no `onDrop`\nevents. Retrying could not make it exist, and counting it as a drop would poison\nthe one number you have for sizing real drops. It is almost always a mistyped\ninstance name; `error.limiter` says which.\n\nThe two are distinguished by a marker in the error message rather than its type,\nbecause nothing else survives: an error thrown inside a Durable Object reaches\nthe caller as a plain `Error` with `name === 'Error'` and every custom property\nstripped. `instanceof` does not work across that boundary.\n\n`onDrop` fires on **every** drop, retried or not. The drop rate is a property of\n_your_ deployment — object churn, redeploy cadence, how long your callers park —\nnot of the measurements above, so it has to be observable in production rather\nthan assumed.\n\n### Why this page quotes no failure probability\n\nSix attempts at a 2.4% drop rate multiplies out to roughly one in ten billion,\nand that figure would be worth very little.\n\n- **The base rate is uncertain.** 7 of 290 is a small sample. The real rate is\n  somewhere in 1.2–4.9%, and compounding an uncertain number amplifies the\n  uncertainty — across that interval the six-attempt result spans a factor of\n  5 000. A single number would be false precision presented as a guarantee.\n- **The events are not independent.** Drops come from eviction, reset and\n  redeploy, and a redeploy drops _every_ parked caller at once — then their\n  retries re-queue into the same window. No run has yet produced a call dropped\n  twice, so the probability of a second drop given a first is unmeasured, and\n  that is precisely the quantity the exponent assumes.\n\nBeyond about five retries the residual risk is dominated by correlated failures\nthat more attempts cannot fix, while the costs stay real — every attempt takes\nfrom the bucket before it runs, so a retry storm spends upstream quota doing nothing.\nMeasure your own rate with `onDrop`; that is the number that should inform your\n`dropRetries`.\n\n---\n\n## Production numbers\n\nEverything below was measured against a real Cloudflare deployment — two Workers,\nDurable Objects, Workflows. Local emulation does not reproduce the platform\nlimits: Miniflare does not appear to enforce invocation accounting, so local\ntests are for logic, not for limits.\n\n\"Production\" here means the real platform, not real users: the load came from the\n[`verify/`](verify/) harness, which is a generator built to stress the package.\nNothing below is a report from an application that adopted it.\n\n### A parked callback survives 23 minutes\n\nThe platform documents that a passed function \"only lasts until the end of the\nWorkers' execution contexts\". In practice, while the caller remains awaiting,\nthis is not a practical constraint. Across 100 calls from 10 independent Workflow\ninstances, all synchronised to stampede at one instant:\n\n```\nlongest successful park   23.25 min (1 395 136 ms)\nmedian park                4.40 min\nsucceeded                  100 / 100\nfailed                     0\n```\n\nNo drops, no timeouts, no partial results. Two further runs without induced\nbackpressure completed 100/100 with a longest park of 4.99 minutes — bounded by\nthe test's own batching, not by any platform limit. **\"Hold the caller until its\nturn\" is viable for waits measured in tens of minutes.**\n\n### Concurrency is enforced exactly\n\nConfigured concurrency 5; peak observed overlap of caller-side work was **exactly\n5** across every production run, under genuinely variable network latency. This is\nthe condition under which an in-process cap is most likely to be silently broken.\n\n### Backpressure works, and compounds\n\nTen rate-limit responses carrying `Retry-After: 30` stretched a workload that\ndrains in ~10 minutes out to **~28 minutes**. Every call still succeeded. The\nrecovery curve after repeated rate limiting is steeper than the sum of the\nindividual delays suggests.\n\n### The two topologies are indistinguishable\n\n| Path                       | Cold   | Warm  |\n| -------------------------- | ------ | ----- |\n| service binding (two hops) | 541 ms | 45 ms |\n| direct DO (one hop)        | 412 ms | 43 ms |\n\nCold-start cost dominates and is unrelated to hop count. Choose the topology on\nAPI-surface grounds, not performance.\n\n### The 32-invocation limit did not manifest\n\nDocumented as \"a single request has a maximum of 32 Worker invocations, and each\ncall to a Service binding counts towards this limit\". Neither topology failed at\n64 sequential calls in one request — 64 is where the probe stopped, not where\nanything broke. Per-request call volume is not a practical design constraint at\nrealistic scale.\n\n### What waiting costs\n\nWorkers bills CPU, and a request awaiting I/O consumes none. Durable Object\nduration is billed per object and **shared across all requests active on it at\nonce**, so a hundred parked callers cost what one costs. There is no hard\nwall-clock limit while the caller stays connected.\n\nThe one real cost: an object with a request in flight cannot hibernate, so a lone\ncaller waiting against an otherwise-idle object pays for that wall time. Under\nbursty traffic the object is active regardless.\n\n---\n\n## The other topology: a service binding\n\nThe package also ships `LimiterEntrypoint`, a `WorkerEntrypoint` in front of the\nobject. It buys a declared interface that can evolve independently of the\nobject's class name, one place for the instance-name convention, and somewhere\nfor metrics, auth and per-consumer policy to live later. It costs ~2 ms warm,\nwhich is noise. The cross-script binding in the quickstart remains fully\nsupported, but it couples every consumer to the object's class name.\n\n```jsonc\n// app/wrangler.jsonc — instead of the durable_objects binding\n{\n  \"services\": [\n    {\n      \"binding\": \"LIMITER\",\n      \"service\": \"my-limiter\",\n      \"entrypoint\": \"LimiterEntrypoint\", // NAMED export; omitting this\n    }, //                                   resolves to the default export\n  ],\n}\n```\n\n```ts\nimport type { LimiterService } from '@bakidev/durable-rate-limiter/do';\n\n// Type the binding as LimiterService — see anti-pattern 9.\ndeclare global {\n  interface Env {\n    LIMITER: LimiterService;\n  }\n}\n\nawait env.LIMITER.configure('example-api', {\n  bucket: { limitPerWindow: 60, windowInMs: 60_000 },\n  concurrency: 5,\n});\nconst stats = await env.LIMITER.stats('example-api');\n```\n\n`defineBinder` only speaks Durable Object namespaces, which is correct — a\nservice binding is not one. To run the client stack over this topology, supply a\nnamespace-shaped adapter through `defineTestBinder`;\n[`verify/consumer/src/limiter-client.ts`](verify/consumer/src/limiter-client.ts)\ndoes exactly that.\n\n---\n\n## Testing and local development\n\nThe quickstart's binding names **another Worker** (`script_name`), and neither\n`@cloudflare/vitest-pool-workers` nor `@cloudflare/vite-plugin` can invent it for\nyou: both build their environment from your `wrangler.jsonc`, find a Durable\nObject binding pointing at a script that does not exist locally, and fail at\nstartup — before any test or request runs. In both cases the fix is to declare\nthe limiter Worker alongside yours; the two tools spell it differently.\n\nUnit tests that never touch the binding need none of this — see\n[Testable without magic](#testable-without-magic) for the in-process fake.\n\n### The cross-script topology under `@cloudflare/vitest-pool-workers`\n\n`@bakidev/durable-rate-limiter/testing` exports the auxiliary-worker options,\nbecause assembling them by hand means finding three separate failures the hard\nway.\n\n```ts\n// vitest.config.ts\nimport { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';\nimport { miniflareLimiterWorker } from '@bakidev/durable-rate-limiter/testing';\n\nexport default defineWorkersConfig({\n  test: {\n    poolOptions: {\n      workers: {\n        wrangler: { configPath: './wrangler.jsonc' },\n        miniflare: {\n          // `name` MUST equal the `script_name` in your binding. That is the\n          // whole join between the two configs.\n          workers: [miniflareLimiterWorker({ name: 'my-limiter' })],\n        },\n      },\n    },\n    // The one-time `configure` — see below.\n    setupFiles: ['./test/setup-limiter.ts'],\n  },\n});\n```\n\nThe helper takes `{ name, binding?, scriptPath? }`; `binding` defaults to\n`RATE_LIMITER` and `scriptPath` resolves `do-worker.js` beside the installed\npackage. Each field it generates answers a failure you would otherwise meet\nindividually:\n\n- the entry module is **`do-worker.js`, not `do.js`** — workerd validates every\n  top-level export of an entry module and `do.js` exports plain values, so it is\n  rejected with `Incorrect type for map entry 'ENVELOPE_VERSION'`;\n- it is declared through an explicit **`modules` array** rather than a\n  `scriptPath`, because miniflare's default module rules parse a bare `.js` under\n  `node_modules` as CommonJS and fail with `ERR_MODULE_PARSE`;\n- **`useSQLite` is declared on the auxiliary worker**, because your own config\n  rightly has no `migrations` for a class it only binds — and the object needs\n  SQLite storage wherever it actually runs.\n\nYour application's config is unchanged, and in particular still has no\n`migrations`:\n\n```jsonc\n// wrangler.jsonc — the consumer, exactly as in the quickstart\n{\n  \"durable_objects\": {\n    \"bindings\": [\n      {\n        \"name\": \"RATE_LIMITER\",\n        \"class_name\": \"LimiterDO\",\n        \"script_name\": \"my-limiter\", // === miniflareLimiterWorker({ name })\n      },\n    ],\n  },\n}\n```\n\nThe local object starts **unconfigured**, and a bucket that has never been\nconfigured does not exist — so without a setup step every call rejects with\n`NoSuchLimiterError` before proving anything. A setup file does once what the CLI\ndoes against a deployment:\n\n```ts\n// test/setup-limiter.ts\nimport { env } from 'cloudflare:test';\nimport type { LimiterRpc } from '@bakidev/durable-rate-limiter/do';\n\n// The one place a raw stub's type is asserted — see anti-pattern 9.\nconst stub = env.RATE_LIMITER.get(\n  env.RATE_LIMITER.idFromName('example-api')\n) as unknown as LimiterRpc;\n\nawait stub.configure('example-api', {\n  bucket: { limitPerWindow: 1000, windowInMs: 60_000 },\n  concurrency: 5,\n});\n```\n\nUse limits sized for a test run, not your upstream's: this bucket is local, and\nthe point is to exercise the wiring rather than to pace it.\n\nOne further setting is worth knowing about. The object's persistence is\nwrite-through and fire-and-forget, so vitest-pool-workers' default\n`isolatedStorage` — which pops a storage stack between tests — can race a write\nstill in flight. This repo's own suites set `isolatedStorage: false` and isolate\nwith distinct instance names instead, which is also cheaper.\n\nThat is not a recipe written from theory: [`test-consumer/`](test-consumer/) and\n[`vitest.consumer.config.ts`](vitest.consumer.config.ts) are exactly this\ntopology — a consumer Worker with a `script_name` binding and no migrations,\nagainst the limiter as an auxiliary worker — and they run in CI on every change,\nfrom `dist/`, so the published artifact is what gets tested (`npm run\ntest:consumer`).\n\n### Local dev with `@cloudflare/vite-plugin`\n\nSame missing Worker, same class of failure at dev-server startup. Point the\nplugin at the limiter Worker's own wrangler config:\n\n```ts\n// vite.config.ts\nimport { cloudflare } from '@cloudflare/vite-plugin';\n\nexport default defineConfig({\n  plugins: [\n    cloudflare({\n      // Your app's config is the plugin's default; this adds the Worker its\n      // `script_name` names.\n      auxiliaryWorkers: [{ configPath: './limiter/wrangler.jsonc' }],\n    }),\n  ],\n});\n```\n\nThe limiter Worker loaded this way is an **entry module**, so its `main` must not\nexport plain values — re-export from `/do-worker`, which is the build that\nexists for this:\n\n```ts\n// limiter/src/index.ts\nexport {\n  LimiterDO,\n  LimiterEntrypoint,\n} from '@bakidev/durable-rate-limiter/do-worker';\n// A Worker exporting a WorkerEntrypoint still needs its own default export;\n// `/do-worker` ships one, and `export *` would not carry it.\nexport { default } from '@bakidev/durable-rate-limiter/do-worker';\n```\n\nIf that Worker also carries the CLI's `/configure` route, keep its own handler\nas `default` instead and re-export only the two classes.\n\nThe local object likewise starts unconfigured. `configure` is a method on the\nDurable Object, so **only running code can call it** — there is no `wrangler`\ncommand that reaches a DO method, and the CLI's `configure` is an HTTP POST to\nthe key-guarded `/configure` route on the limiter Worker, authorised by the\n`DRL_CONFIG_KEY` the Worker reads from its own `env`. Locally, that means:\n\n- if the limiter Worker is reachable at an origin of its own — `wrangler dev`\n  against `limiter/wrangler.jsonc`, say — point the CLI at it once with\n  `npx @bakidev/durable-rate-limiter configure --url http://localhost:8787`,\n  with `DRL_CONFIG_KEY` set for the local Worker (a `.dev.vars` beside its\n  config) and exported for the CLI. `--url` is remembered in the state file, so\n  it is needed once — and it overrides the remembered origin, which is how you\n  switch back and forth between local and deployed;\n- under the vite plugin an auxiliary Worker is reached **through bindings, not\n  as its own origin**, so the simpler path is to configure it from the\n  application: call `stub.configure(...)` from a guarded dev-only route, or from\n  the `configureLimiter(env)` module `init` writes when you decline the config\n  route. It is the same one-time call as the test setup file above.\n\nEither way it is once per local object, not once per run: local Durable Object\nstorage is persisted under `.wrangler/` and survives a restart until you clear\nit.\n\n---\n\n## Known limits — stated, not buried\n\n- **No release has carried production traffic that isn't its own test harness.**\n  The numbers below are real, from a real deployment, but they come from a load\n  generator built to exercise the package — not from an application that needed\n  the limiter for its own reasons. That is what the `0.x` version means; see\n  [Stability](#stability--this-is-a-0x-release).\n- **The wait queue is memory-only.** An RPC function handle cannot be persisted,\n  so queued callbacks do not survive object eviction — measured at 2.4% of calls\n  under load. The client retries this for you when the callback never ran, but\n  the retry is bounded: `call()` is still throwable, now with `CallDroppedError`,\n  and a caller that must not lose work needs its own durable retry above this\n  one. **No compounded failure probability is published**, because the drop rate\n  comes from a small sample and the events are not independent — one redeploy\n  takes out every parked caller at once. Measure yours with `onDrop`.\n- **A drop after the callback started is never retried automatically.** It cannot\n  be distinguished from a completed upstream request, so it is left to you. Make\n  such calls idempotent, or reconcile them.\n- **`fn` is re-invoked on retry** and must build a fresh request each time. A\n  closure over an already-consumed body fails on the second attempt.\n- **The limiter holds no work.** If a caller disconnects, its pending call goes\n  with it. This is not a proxy, a queue or a job runner — the caller owns the\n  work and the limiter owns only the timing.\n- **Hooks are per-call, client-side.** They cannot be registered once on the\n  object; sharing an API's convention across call sites is what limiter-level\n  defaults are for.\n- **`configure` rejects anyone currently queued.** It is a setup call. Rebuilding\n  the bucket is the honest signal that their wait will never be satisfied under\n  the limits they were waiting on.\n\n---\n\n## API\n\n### `@bakidev/durable-rate-limiter/client`\n\n| Export                         | What it is                                                         |\n| ------------------------------ | ------------------------------------------------------------------ |\n| `defineBinder(name)`           | Names the DO binding, checked against your generated `Env`.        |\n| `defineBinder.unchecked(name)` | The same, without the compile-time check.                          |\n| `defineTestBinder(namespace)`  | Injects a namespace directly, for tests outside workerd.           |\n| `defineLimiter(definition)`    | Captures binder, instance name, hook defaults and drop policy.     |\n| `limiter.for(env)`             | Per-request bind. Where the binding's presence is checked.         |\n| `bound.call(fn, options)`      | Runs `fn` under the shared limiter; returns what `read` extracted. |\n| `CallDroppedError`             | Every drop retry spent and the callback never ran.                 |\n| `NoSuchLimiterError`           | The bucket was never configured. Thrown at once, never retried.    |\n| `DEFAULT_DROP_RETRIES`         | `5`.                                                               |\n\nTypes: `Binder`, `Limiter`, `BoundLimiter`, `LimiterDefinition`, `CallOptions`,\n`DropEvent`, `DropHook`, `RateLimitHook`, `RateLimitSignal`, `ErrorHook`,\n`FailureDescription`, `HookSlot`, `CallReport`, `LimiterStub`, `NamespaceLike`,\n`DoBindings`, and `ENVELOPE_VERSION`.\n\nTelling a missing bucket from a caller dropped in transit is done for you: the\nclient raises `NoSuchLimiterError` (permanent, never retried) rather than\n`CallDroppedError` (transient, retried) across an RPC boundary that erases error\ntypes. The wire marker behind that decision is an internal envelope detail and\nis deliberately not part of the client's public surface.\n\n### `@bakidev/durable-rate-limiter/do`\n\n| Export                      | What it is                                                                                                                                                                                                                                                    |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `LimiterDO`                 | The Durable Object. Re-export it from your limiter Worker.                                                                                                                                                                                                    |\n| `LimiterEntrypoint`         | The named `WorkerEntrypoint` RPC surface.                                                                                                                                                                                                                     |\n| `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. |\n| `REGISTRY_NAME`             | The reserved instance holding every bucket's name. Your limiter Worker's `/stats` route needs it; nothing else should address it.                                                                                                                             |\n| `CallFailedError`           | The rejection a reported failure becomes once it is final.                                                                                                                                                                                                    |\n| `ENVELOPE_VERSION`          | Compare against `ping()` to catch skew.                                                                                                                                                                                                                       |\n\nTypes: `LimiterConfig`, `LimiterStats`, `LimiterEnv`, `LimiterService`,\n`LimiterRpc`, `LimiterPing`, `CallReport`.\n\n`LimiterDO` methods: `execute(fn)`, `configure(name, config)`,\n`reconfigure(patch)`, `stats()`, `listNames()`. `LimiterEntrypoint` takes the\ninstance name first: `execute(name, fn)`, `configure(name, config)`,\n`reconfigure(name, patch)`, `stats(name)`, plus `listNames()` and `ping()`.\n\n`configure` creates and takes a **complete** config; `reconfigure` patches one\nthat already exists. Only `configure` carries the name, because a Durable Object\ncannot recover the name it was addressed by — `ctx.id.name` is `undefined`\ninside one — and it needs one to enter the registry `listNames()` reads. A\nmodification has nothing to register, so it needs no name.\n\n### `@bakidev/durable-rate-limiter/do-worker`\n\nThe `/do` half as a Worker **entry module**: `LimiterDO`, `LimiterEntrypoint` and\na default `fetch` that answers liveness — and deliberately nothing else, because\nworkerd rejects an entry module that exports a plain value. Use it as the `main`\nof a limiter Worker that a harness loads directly (a miniflare auxiliary worker,\na `@cloudflare/vite-plugin` auxiliary worker); a deployed limiter Worker can\nre-export from it too. Import types and constants from `/do` as before.\n\n### `@bakidev/durable-rate-limiter/testing`\n\n| Export                         | What it is                                                                                                              |\n| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |\n| `createFakeLimiterNamespace()` | A namespace for `defineTestBinder` with the real object's terminal semantics, plus a `calls` log of every `CallReport`. |\n| `miniflareLimiterWorker(cfg)`  | The miniflare auxiliary-worker options that make a `script_name` binding work under `@cloudflare/vitest-pool-workers`.  |\n\nTypes: `FakeLimiterNamespace`, `CapturedCall`, `MiniflareLimiterWorkerConfig`,\n`MiniflareLimiterWorkerOptions`.\n\nThe two run in different runtimes — the fake inside workerd, the helper in the\nNode process that loads your vitest config — so this module imports no Node\nbuilt-in and nothing from `cloudflare:`, which is what lets one entrypoint serve\nboth. See [Testing and local development](#testing-and-local-development).\n\n---\n\n## Deployed verification\n\nThe claims above are about production behaviour, and none of them can be\nestablished locally. [`verify/`](verify/) is a reproducible harness: two Workers,\na Durable Object, and N Workflow instances that all sleep until one shared\ntimestamp and then stampede, so the load is genuinely concurrent across isolates\nrather than sequential. It produces a plain-text report designed to be pasted\nback verbatim.\n\n**It costs money to run** — Workflows, Durable Object wall time, and a run that\nparks callers for twenty minutes parks them for twenty minutes. Deploy it,\nmeasure, `wrangler delete` both Workers.\n\n```sh\n# 1. Build. The harness imports from dist/, so it verifies the published artifact.\nnpm install && npm run build && npm run verify:typecheck\n\n# 2. Limiter Worker first — the consumer's bindings name it.\nnpx wrangler deploy --config verify/limiter/wrangler.jsonc\n\n# 3. Consumer, then the shared secret. The routes are internet-facing; an unset\n#    PROBE_KEY denies everything.\nnpx wrangler deploy --config verify/consumer/wrangler.jsonc\nnpx wrangler secret put PROBE_KEY --config verify/consumer/wrangler.jsonc\n\n# 4. Export the consumer's workers.dev URL from step 3, and the key.\nexport VERIFY_URL=https://drl-verify-consumer.<your-subdomain>.workers.dev\nexport PROBE_KEY=<the value you just set>\n```\n\nEach probe is independent; `&via=direct` swaps the two-hop service binding for\nthe one-hop cross-script Durable Object binding, and every route accepts it.\n\n```sh\ncurl \"$VERIFY_URL/ping?key=$PROBE_KEY\"                        # both halves up, versions agree\ncurl \"$VERIFY_URL/closure-check?key=$PROBE_KEY\"               # the closure runs in the caller\ncurl \"$VERIFY_URL/closure-check?key=$PROBE_KEY&via=direct\"\ncurl \"$VERIFY_URL/client-path?key=$PROBE_KEY\"                 # read(), hooks, envelope, end to end\ncurl \"$VERIFY_URL/cap-probe?key=$PROBE_KEY&max=64\"            # per-request invocation ceiling\ncurl \"$VERIFY_URL/cap-probe?key=$PROBE_KEY&max=64&via=direct\"\n\n# The load run: 10 Workflow instances x 10 calls, all stampeding 60s from now at\n# 10/min — a ~10 minute drain, so the last caller parks well past the six-minute\n# mark the design hinges on. Prints its probe id.\ncurl \"$VERIFY_URL/start?key=$PROBE_KEY&insta","readmeFilename":"README.md"}