1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { describe, expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import Stack from "./fixtures/stack.ts";
9
10const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
11 providers: Cloudflare.providers(),
12});
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const stack = beforeAll(deploy(Stack));
20afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
21
22// The worker calls `rateLimit.limit({ key })` `n` times inside a single
23// request and returns the per-call `success` flags. Hitting the binding from
24// one isolate makes the throttling deterministic and avoids relying on edge
25// propagation between separate HTTP requests.
26const burst = Effect.fn(function* (url: string, key: string, n: number) {
27 const client = yield* HttpClient.HttpClient;
28 const res = yield* client
29 .get(`${url}/burst?key=${encodeURIComponent(key)}&n=${n}`)
30 .pipe(
31 Effect.flatMap((res) =>
32 res.status === 200
33 ? Effect.succeed(res)
34 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
35 ),
36 Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 15 }),
37 );
38 return (yield* res.json) as { key: string; results: boolean[] };
39});
40
41const freshKey = () => `fresh-${Math.random().toString(36).slice(2)}`;
42
43// Both worker styles bind the same RateLimit shape (`limit: 2`, `period: 10`)
44// and expose the identical `/burst` contract, so they share one behavioral
45// suite parameterized by which deployed URL to hit.
46const behaviorSuite = (label: string, getUrl: () => Effect.Effect<string>) =>
47 describe(
48 label,
49 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
50 () => {
51 test(
52 "throttles requests past the configured limit",
53 Effect.gen(function* () {
54 const url = yield* getUrl();
55 expect(url).toBeTypeOf("string");
56
57 // Limit is 2 over a 10s window. Cloudflare's limiter is best-effort
58 // (per-colo, approximate counting), so we don't assert an exact
59 // allowed count — only the observable contract: the first call is
60 // allowed, the burst is eventually throttled, and once the budget is
61 // exhausted later calls stay denied.
62 const n = 10;
63 const { results } = yield* burst(url, freshKey(), n);
64
65 expect(results).toHaveLength(n);
66 expect(results[0]).toBe(true);
67 expect(results.at(-1)).toBe(false);
68 // Some calls were allowed, some denied.
69 const allowed = results.filter(Boolean).length;
70 expect(allowed).toBeGreaterThan(0);
71 expect(allowed).toBeLessThan(n);
72 }).pipe(logLevel),
73 { timeout: 180_000 },
74 );
75
76 test(
77 "tracks each key independently",
78 Effect.gen(function* () {
79 const url = yield* getUrl();
80
81 // A distinct key gets its own fresh budget regardless of how much a
82 // previous key was throttled.
83 const fresh = yield* burst(url, freshKey(), 1);
84 expect(fresh.results).toEqual([true]);
85 }).pipe(logLevel),
86 { timeout: 180_000 },
87 );
88 },
89 );
90
91behaviorSuite("async worker (env binding)", () =>
92 stack.pipe(Effect.map((s) => s.asyncUrl)),
93);
94
95behaviorSuite("effect worker (yield* RateLimit)", () =>
96 stack.pipe(Effect.map((s) => s.effectUrl)),
97);