Skip to content

Commit 16fbf81

Browse files
[vitest-pool-workers] fix: reset() now clears ratelimit binding state between tests (#14409)
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent c5f79a1 commit 16fbf81

7 files changed

Lines changed: 175 additions & 46 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"miniflare": patch
3+
"@cloudflare/vitest-pool-workers": patch
4+
---
5+
6+
`reset()` from `cloudflare:test` now resets ratelimit binding state between tests. Previously, `RATE_LIMITERS` bindings retained their in-memory bucket counts across test boundaries, causing later tests in the same file to see stale rate-limit exhaustion state.
Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
/* eslint-disable */
2-
// Generated by Wrangler by running `wrangler types ./reset/src/env.d.ts -c ./reset/wrangler.jsonc --no-include-runtime` (hash: 524c60174bc1732153e84c1b8699023e)
3-
interface __BaseEnv_Env {
4-
KV_NAMESPACE: KVNamespace;
5-
R2_BUCKET: R2Bucket;
6-
DATABASE: D1Database;
7-
COUNTER: DurableObjectNamespace<import("./index").Counter>;
8-
}
2+
// Generated by Wrangler by running `wrangler types src/env.d.ts -c wrangler.jsonc --no-include-runtime` (hash: 6992c0652c326145f228d37275b18131)
93
declare namespace Cloudflare {
104
interface GlobalProps {
115
mainModule: typeof import("./index");
126
durableNamespaces: "Counter";
137
}
14-
interface Env extends __BaseEnv_Env {}
8+
interface Env {
9+
KV_NAMESPACE: KVNamespace;
10+
R2_BUCKET: R2Bucket;
11+
DATABASE: D1Database;
12+
RATE_LIMITER: RateLimit;
13+
COUNTER: DurableObjectNamespace<import("./index").Counter>;
14+
}
1515
}
16-
interface Env extends __BaseEnv_Env {}
16+
interface Env extends Cloudflare.Env {}

fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,24 @@ it("sees reset Durable Object storage after reset", async ({ expect }) => {
6262
const response = await stub.fetch("https://example.com");
6363
expect(await response.text()).toBe("1");
6464
});
65+
66+
it("exhausts ratelimit then resets between tests", async ({ expect }) => {
67+
// First call succeeds
68+
const first = await env.RATE_LIMITER.limit({ key: "test-key" });
69+
expect(first.success).toBe(true);
70+
71+
// Exhaust the full limit of 100
72+
for (let i = 1; i < 100; i++) {
73+
await env.RATE_LIMITER.limit({ key: "test-key" });
74+
}
75+
76+
// 101st call should fail
77+
const over = await env.RATE_LIMITER.limit({ key: "test-key" });
78+
expect(over.success).toBe(false);
79+
});
80+
81+
it("sees reset ratelimit state after reset", async ({ expect }) => {
82+
// After reset(), buckets should be cleared — first call succeeds again
83+
const result = await env.RATE_LIMITER.limit({ key: "test-key" });
84+
expect(result.success).toBe(true);
85+
});

fixtures/vitest-pool-workers-examples/reset/wrangler.jsonc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,11 @@
3434
"database_id": "00000000-0000-0000-0000-000000000000",
3535
},
3636
],
37+
"ratelimits": [
38+
{
39+
"name": "RATE_LIMITER",
40+
"namespace_id": "1",
41+
"simple": { "limit": 100, "period": 60 },
42+
},
43+
],
3744
}

packages/miniflare/src/plugins/ratelimit/index.ts

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
import SCRIPT_RATELIMIT_OBJECT from "worker:ratelimit/ratelimit";
1+
import SCRIPT_RATELIMIT_CLIENT from "worker:ratelimit/ratelimit";
2+
import SCRIPT_RATELIMIT_OBJECT from "worker:ratelimit/ratelimit-object";
23
import { z } from "zod";
3-
import { ProxyNodeBinding } from "../shared";
4-
import type { Worker_Binding } from "../../runtime";
4+
import { kVoid } from "../../runtime";
5+
import { SharedBindings } from "../../workers";
6+
import {
7+
getMiniflareObjectBindings,
8+
getUserBindingServiceName,
9+
objectEntryWorker,
10+
ProxyNodeBinding,
11+
SERVICE_LOOPBACK,
12+
} from "../shared";
13+
import type {
14+
Service,
15+
Worker_Binding,
16+
Worker_Binding_DurableObjectNamespaceDesignator,
17+
} from "../../runtime";
518
import type { Plugin } from "../shared";
619

720
export enum PeriodType {
@@ -14,7 +27,7 @@ export const RatelimitConfigSchema = z.object({
1427
limit: z.number().gt(0),
1528

1629
// may relax this to be any number in the future
17-
period: z.nativeEnum(PeriodType).optional(),
30+
period: z.nativeEnum(PeriodType).optional().default(PeriodType.MINUTE),
1831
}),
1932
});
2033
export const RatelimitOptionsSchema = z.object({
@@ -24,6 +37,12 @@ export const RatelimitOptionsSchema = z.object({
2437
export const RATELIMIT_PLUGIN_NAME = "ratelimit";
2538
const SERVICE_RATELIMIT_PREFIX = `${RATELIMIT_PLUGIN_NAME}`;
2639
const SERVICE_RATELIMIT_MODULE = `cloudflare-internal:${SERVICE_RATELIMIT_PREFIX}:module`;
40+
const RATELIMIT_ENTRY_SERVICE_PREFIX = `${RATELIMIT_PLUGIN_NAME}:ns`;
41+
const RATELIMIT_OBJECT_CLASS_NAME = "RateLimiterObject";
42+
const RATELIMIT_OBJECT: Worker_Binding_DurableObjectNamespaceDesignator = {
43+
serviceName: RATELIMIT_ENTRY_SERVICE_PREFIX,
44+
className: RATELIMIT_OBJECT_CLASS_NAME,
45+
};
2746

2847
function buildJsonBindings(bindings: Record<string, any>): Worker_Binding[] {
2948
return Object.entries(bindings).map(([name, value]) => ({
@@ -44,11 +63,21 @@ export const RATELIMIT_PLUGIN: Plugin<typeof RatelimitOptionsSchema> = {
4463
name,
4564
wrapped: {
4665
moduleName: SERVICE_RATELIMIT_MODULE,
47-
innerBindings: buildJsonBindings({
48-
namespaceId: name,
49-
limit: config.simple.limit,
50-
period: config.simple.period,
51-
}),
66+
innerBindings: [
67+
{
68+
name: "fetcher",
69+
service: {
70+
name: getUserBindingServiceName(
71+
RATELIMIT_ENTRY_SERVICE_PREFIX,
72+
name
73+
),
74+
},
75+
},
76+
...buildJsonBindings({
77+
limit: config.simple.limit,
78+
period: config.simple.period,
79+
}),
80+
],
5281
},
5382
})
5483
);
@@ -65,8 +94,46 @@ export const RATELIMIT_PLUGIN: Plugin<typeof RatelimitOptionsSchema> = {
6594
])
6695
);
6796
},
68-
async getServices() {
69-
return [];
97+
async getServices({ options, unsafeStickyBlobs }) {
98+
if (!options.ratelimits) {
99+
return [];
100+
}
101+
const names = Object.keys(options.ratelimits);
102+
const services = names.map<Service>((name) => ({
103+
name: getUserBindingServiceName(RATELIMIT_ENTRY_SERVICE_PREFIX, name),
104+
worker: objectEntryWorker(RATELIMIT_OBJECT, name),
105+
}));
106+
107+
const uniqueKey = `miniflare-${RATELIMIT_OBJECT_CLASS_NAME}`;
108+
services.push({
109+
name: RATELIMIT_ENTRY_SERVICE_PREFIX,
110+
worker: {
111+
compatibilityDate: "2023-07-24",
112+
compatibilityFlags: ["nodejs_compat", "experimental"],
113+
modules: [
114+
{
115+
name: "ratelimit-object.worker.js",
116+
esModule: SCRIPT_RATELIMIT_OBJECT(),
117+
},
118+
],
119+
durableObjectNamespaces: [
120+
{ className: RATELIMIT_OBJECT_CLASS_NAME, uniqueKey },
121+
],
122+
// Counters are only ever needed for the lifetime of the Miniflare
123+
// process, never persisted across restarts (matching the previous
124+
// purely in-memory implementation).
125+
durableObjectStorage: { inMemory: kVoid },
126+
bindings: [
127+
{
128+
name: SharedBindings.MAYBE_SERVICE_LOOPBACK,
129+
service: { name: SERVICE_LOOPBACK },
130+
},
131+
...getMiniflareObjectBindings(unsafeStickyBlobs),
132+
],
133+
},
134+
});
135+
136+
return services;
70137
},
71138
getExtensions({ options }) {
72139
if (!options.some((o) => o.ratelimits)) {
@@ -77,7 +144,7 @@ export const RATELIMIT_PLUGIN: Plugin<typeof RatelimitOptionsSchema> = {
77144
modules: [
78145
{
79146
name: SERVICE_RATELIMIT_MODULE,
80-
esModule: SCRIPT_RATELIMIT_OBJECT(),
147+
esModule: SCRIPT_RATELIMIT_CLIENT(),
81148
internal: true,
82149
},
83150
],
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Durable Object backing the emulated Ratelimit binding. Moving bucket/epoch
2+
// state here (instead of an in-memory object behind a `wrapped` binding)
3+
// means it's automatically torn down by `deleteAllDurableObjects()`, so
4+
// vitest-pool-workers' `reset()` clears it for free.
5+
import { MiniflareDurableObject, POST } from "miniflare:shared";
6+
import type { RouteHandler } from "miniflare:shared";
7+
8+
interface LimitRequestBody {
9+
key: string;
10+
limit: number;
11+
period: number;
12+
}
13+
14+
interface LimitResult {
15+
success: boolean;
16+
}
17+
18+
export class RateLimiterObject extends MiniflareDurableObject {
19+
#buckets = new Map<string, number>();
20+
#epoch = 0;
21+
22+
@POST("/limit")
23+
limit: RouteHandler = async (req) => {
24+
const { key, limit, period } = await req.json<LimitRequestBody>();
25+
26+
const epoch = Math.floor(Date.now() / (period * 1000));
27+
if (epoch !== this.#epoch) {
28+
this.#epoch = epoch;
29+
this.#buckets.clear();
30+
}
31+
32+
const value = this.#buckets.get(key) ?? 0;
33+
if (value >= limit) {
34+
return Response.json({ success: false } satisfies LimitResult);
35+
}
36+
this.#buckets.set(key, value + 1);
37+
return Response.json({ success: true } satisfies LimitResult);
38+
};
39+
}

packages/miniflare/src/workers/ratelimit/ratelimit.worker.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Emulated Ratelimit Binding
2+
//
3+
// Thin client: keeps option validation local (so error messages surface
4+
// synchronously in the caller's isolate), forwards each `.limit()` call to
5+
// the `RateLimiterObject` Durable Object, which owns the bucket/epoch state.
26

37
// ENV configuration
48
interface RatelimitConfig {
5-
namespaceId: number;
69
limit: number;
710
period: number;
11+
fetcher: Fetcher;
812
}
913

1014
// options for Ratelimit
@@ -25,22 +29,18 @@ function validate(test: boolean, message: string): asserts test {
2529
}
2630

2731
class Ratelimit {
28-
namespaceId: number;
32+
fetcher: Fetcher;
2933
limitVal: number;
3034
period: number;
31-
buckets: Map<string, number>;
32-
epoch: number;
3335

3436
constructor(config: RatelimitConfig) {
35-
this.namespaceId = config.namespaceId;
37+
this.fetcher = config.fetcher;
3638
this.limitVal = config.limit;
3739
this.period = config.period;
38-
39-
this.buckets = new Map<string, number>();
40-
this.epoch = 0;
4140
}
4241

43-
// method that counts and checks against the limit in in-memory buckets
42+
// method that counts and checks against the limit, delegating the actual
43+
// bucket/epoch bookkeeping to the `RateLimiterObject` Durable Object
4444
async limit(options: unknown): Promise<RatelimitResult> {
4545
// validate options input
4646
validate(
@@ -67,22 +67,11 @@ class Ratelimit {
6767
`unsupported period: ${period}`
6868
);
6969

70-
const epoch = Math.floor(Date.now() / (period * 1000));
71-
if (epoch != this.epoch) {
72-
// clear counters
73-
this.epoch = epoch;
74-
this.buckets.clear();
75-
}
76-
const val = this.buckets.get(key) || 0;
77-
if (val >= limit) {
78-
return {
79-
success: false,
80-
};
81-
}
82-
this.buckets.set(key, val + 1);
83-
return {
84-
success: true,
85-
};
70+
const res = await this.fetcher.fetch("http://ratelimit/limit", {
71+
method: "POST",
72+
body: JSON.stringify({ key, limit, period }),
73+
});
74+
return await res.json<RatelimitResult>();
8675
}
8776
}
8877

0 commit comments

Comments
 (0)