File size: 28,597 Bytes
20f83d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 | import { Ratelimit, type Duration } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { getClientIp } from './client-ip';
// @ts-expect-error β JS module, no declaration file
import { captureSilentError } from '../../api/_sentry-edge.js';
// @ts-expect-error β JS module, no declaration file
import { durationToSeconds, limitWithFallback, resetRateLimitFallbackForTest } from '../../api/_rate-limit-fallback.js';
// Client-IP derivation lives in the dependency-free client-ip.ts (#5231) so
// seeder-reachable modules (usage.ts) can use it without pulling this file's
// @upstash imports into Railway containers. Re-exported here because this was
// the helpers' original home and existing callers import them from this
// module (getClientIp: api/ask.ts, api/a2a.ts, api/mcp-proxy.ts;
// UNKNOWN_CLIENT_IP: turnstile.ts; plus the rate-limit test suites).
export { getClientIp, hasCloudflareTransitProof, UNKNOWN_CLIENT_IP } from './client-ip';
// @upstash/redis defaults to 5 retries with exponential backoff (~4.3s total)
// before surfacing an unreachable-Redis error. The node test runner sets
// NODE_TEST_CONTEXT in the child that executes each file; in that context the
// fail-open / fail-closed rate-limit tests point UPSTASH_REDIS_REST_URL at a
// fake host and would otherwise burn that full backoff on every limiter call.
// Skip retries under the test runner only β production (env unset) keeps the
// resilient default untouched. Mirrors the retry:false already shipped on the
// MCP limiter to unblock the suite (PR #3963).
const REDIS_TEST_RETRY_OPTS: { retry?: false } = process.env.NODE_TEST_CONTEXT ? { retry: false } : {};
let ratelimit: Ratelimit | null = null;
const GLOBAL_RATE_LIMIT = 600;
const GLOBAL_RATE_WINDOW: Duration = '60 s';
const GLOBAL_RATE_WINDOW_SECONDS = durationToSeconds(GLOBAL_RATE_WINDOW);
function getRatelimit(): Ratelimit | null {
if (ratelimit) return ratelimit;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
ratelimit = new Ratelimit({
redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }),
limiter: Ratelimit.slidingWindow(GLOBAL_RATE_LIMIT, GLOBAL_RATE_WINDOW),
prefix: 'rl',
analytics: false,
});
return ratelimit;
}
// Structured one-line log so api/server log aggregation can grep for the
// "rate-limit available" gap independently of Sentry. Keep the prefix
// stable β operators and the api/_rate-limit.js mirror both emit it.
// Decide the Sentry level for a degraded-rate-limit capture. Upstash runtime
// transients β the Lua limiter script timing out under fan-out load
// (`ERR Error running script: execution timed out`), a dropped command, or a
// network/timeout blip β are absorbed by the fail-open / `failClosed`-503 path,
// so the user is unaffected. Capture those at `warning` so a sustained Redis
// outage still escalates by volume without a transient script-timeout drowning
// genuine error-level signal in the dashboard (WORLDMONITOR-RX; mirrors the
// SERVICE_UNAVAILABLE `level: 'warning'` precedent in api/user-prefs.ts). A
// `missing-config` stage is a real deploy misconfiguration and any novel error
// is unclassified β both stay at `error` so on-call still sees them.
// Mirrored verbatim in api/_rate-limit.js.
function rateLimitErrorLevel(stage: string, msg: string): 'warning' | 'error' {
if (stage.includes('missing-config')) return 'error';
if (/Error running script|execution timed out|Command failed|ETIMEDOUT|ECONNRESET|ENOTFOUND|fetch failed|network|timed out|socket hang up|Redis unavailable|Redis unreachable/i.test(msg)) {
return 'warning';
}
return 'error';
}
function logRateLimitDegraded(stage: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[rate-limit] redis-error stage=${stage} msg=${msg}`);
captureSilentError(err, {
tags: { surface: 'server', component: 'rate-limit', stage },
fingerprint: ['rate-limit', 'redis-error', stage],
level: rateLimitErrorLevel(stage, msg),
});
}
const scopedMissingConfigStages = new Set<string>();
function logScopedRateLimitMissingConfig(scope: string): void {
const stage = `checkScopedRateLimit:${scope}:missing-config`;
if (scopedMissingConfigStages.has(stage)) return;
scopedMissingConfigStages.add(stage);
logRateLimitDegraded(stage, new Error('UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN missing'));
}
// Marker header set on every degraded (fail-closed) response so observability
// can correlate "rate-limit unavailable" windows with downstream behaviour
// without parsing the JSON body. Mirrored in api/_rate-limit.js.
export const RATE_LIMIT_DEGRADED_HEADERS = {
'X-RateLimit-Mode': 'degraded',
// Short Retry-After encourages clients to retry once the limiter is back,
// rather than treating the 503 as a hard outage.
'Retry-After': '5',
} as const;
function tooManyRequestsResponse(limit: number, reset: number, corsHeaders: Record<string, string>, windowSeconds: number): Response {
// `reset` is a Unix epoch in MILLISECONDS (Upstash). IETF RateLimit fields
// carry a delta-seconds reset (`t` / RateLimit-Reset), NOT an epoch β derive
// it here. Legacy X-RateLimit-Reset stays epoch-ms for back-compat.
const resetSeconds = Math.max(0, Math.ceil((reset - Date.now()) / 1000));
return new Response(JSON.stringify({ error: 'Too many requests' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
// IETF RateLimit fields (draft-ietf-httpapi-ratelimit-headers). The
// combined RateLimit member references the "default" policy advertised on
// every API response via vercel.json so agents can self-throttle. Mirrors
// api/_rate-limit.js.
'RateLimit-Policy': `"default";q=${limit};w=${windowSeconds}`,
'RateLimit-Limit': String(limit),
'RateLimit-Remaining': '0',
'RateLimit-Reset': String(resetSeconds),
RateLimit: `"default";r=0;t=${resetSeconds}`,
// Legacy X-RateLimit-* retained for back-compat (Reset is epoch-ms).
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(reset),
'Retry-After': String(resetSeconds),
...corsHeaders,
},
});
}
function rateLimitDegradedResponse(corsHeaders: Record<string, string>): Response {
return new Response(JSON.stringify({ error: 'Rate-limit service temporarily unavailable' }), {
status: 503,
headers: {
'Content-Type': 'application/json',
...RATE_LIMIT_DEGRADED_HEADERS,
...corsHeaders,
},
});
}
export interface RateLimitOptions {
/**
* When true and Redis is unavailable, return a 503 (with the
* `X-RateLimit-Mode: degraded` marker) instead of allowing the request
* through. Pass `true` for endpoints where the rate-limit IS the abuse
* defence (LLM, checkout, lead capture). Default `false` keeps the
* availability-first posture for general traffic so a Redis blip doesn't
* black-hole the whole site. (#3531)
*/
failClosed?: boolean;
/**
* Optional trusted server-derived user ID for policies that should isolate
* authenticated principals sharing one public IP. Callers must never pass a
* raw client-controlled header here. The limiter owns the namespace prefix
* so user IDs cannot collide with anonymous IP buckets.
*/
principalUserId?: string;
}
export type EndpointRateLimitOptions = RateLimitOptions;
function getPrincipalRateLimitIdentifier(principalUserId?: string): string | null {
return principalUserId ? `user:${principalUserId}` : null;
}
export async function checkRateLimit(request: Request, corsHeaders: Record<string, string>, opts: RateLimitOptions = {}): Promise<Response | null> {
const rl = getRatelimit();
if (!rl) {
if (opts.failClosed) {
logRateLimitDegraded('checkRateLimit:missing-config', new Error('Upstash Redis is not configured'));
return rateLimitDegradedResponse(corsHeaders);
}
return null;
}
// Preserve the long-standing raw-IP key for anonymous traffic so an
// in-flight 60-second bucket does not reset during rollout. Trusted
// principals use a separate namespace.
const identifier =
getPrincipalRateLimitIdentifier(opts.principalUserId) ??
getClientIp(request);
try {
const { success, limit, reset } = await limitWithFallback(
rl,
identifier,
`rl:fw:${identifier}`,
GLOBAL_RATE_LIMIT,
GLOBAL_RATE_WINDOW_SECONDS,
);
if (!success) {
return tooManyRequestsResponse(limit, reset, corsHeaders, GLOBAL_RATE_WINDOW_SECONDS);
}
return null;
} catch (err) {
logRateLimitDegraded('checkRateLimit', err);
if (opts.failClosed) return rateLimitDegradedResponse(corsHeaders);
return null;
}
}
// --- Per-endpoint rate limiting ---
interface EndpointRatePolicy {
limit: number;
window: Duration;
}
// Exported so scripts/enforce-rate-limit-policies.mjs can import it directly
// (#3278) instead of regex-parsing this file. Internal callers should keep
// using checkEndpointRateLimit / hasEndpointRatePolicy below β the export is
// for tooling, not new runtime callers.
export const ENDPOINT_RATE_POLICIES: Record<string, EndpointRatePolicy> = {
// LLM article summarization is Pro-gated, but still needs a scoped,
// fail-closed budget so Redis degradation cannot silently lift the
// per-endpoint spend control.
'/api/news/v1/summarize-article': { limit: 30, window: '60 s' },
'/api/news/v1/summarize-article-cache': { limit: 3000, window: '60 s' },
'/api/intelligence/v1/classify-event': { limit: 600, window: '60 s' },
// LLM-backed situational deduction (imports callLlmReasoning) can drive
// provider spend on cache misses, so it must fail closed on Redis outage
// rather than inherit the global fail-open fallback. Mirror the sibling
// classify-event budget (same limit/window) β both are AI-backed Intelligence
// RPCs. (#4676)
'/api/intelligence/v1/deduct-situation': { limit: 600, window: '60 s' },
// Historical intelligence memory (#5694): both semantic routes embed the
// caller's free text through the OpenRouter embeddings API on every cache
// miss, so they are provider-backed spend, not pure reads. They are also
// premium-gated, which means the gateway serves them with no CDN cache β a
// The three intel-history reads. All are Pro-gated and reach the function on
// every request, but they spend two different budgets, so they are sized
// against two different ceilings.
//
// search + similar-events each embed their input on a paid provider. They
// share ONE budget while the registry is keyed per PATH, so a caller
// alternating them gets the sum, not the cap β 30/min each holds the
// combined worst case at the 60/min per-principal embeddings bill this is
// sized for. Still generous for interactive use (a search plus follow-ups),
// and far under the LLM routes' 600/min because nothing here runs in a
// page-load fan-out.
//
// timeline embeds nothing, which is why it originally carried no policy at
// all. That reasoning was right about money and wrong about the resource
// that actually scales with retention: Convex reads whole documents, and
// every intelHistory row carries a 512-float embedding the projection
// immediately discards. One limit=200 call scoped by both domain and
// country scans TIMELINE_MAX_SCAN=800 rows (4x over-fetch for the
// post-filter) β roughly 3MB of Convex read budget, which the 600/min
// availability-first fallback did not bound. 120/min keeps a timeline read
// comfortable while capping that worst case.
'/api/intelligence/v1/search-intel-history': { limit: 30, window: '60 s' },
'/api/intelligence/v1/get-similar-events': { limit: 30, window: '60 s' },
'/api/intelligence/v1/get-intel-timeline': { limit: 120, window: '60 s' },
// Batch humanitarian-summary fans out to the external HAPI (humdata) provider
// on cache miss β up to 25 countries per request, 5 concurrent upstream
// fetches. Batch aircraft-details fans out to the external Wingbits provider β
// up to 10 ICAO24 lookups per request. Both proxy external providers, so keep
// them at the same 30/min budget as the other provider-proxy routes
// (sanctions lookup / resilience ranking); conservative because a single
// request already amplifies into many upstream calls. (#4676)
'/api/conflict/v1/get-humanitarian-summary-batch': { limit: 30, window: '60 s' },
'/api/military/v1/get-aircraft-details-batch': { limit: 30, window: '60 s' },
// Generic batch fan-out: one request re-dispatches up to 20 gateway GETs, so
// cap the multiplier at the same 30/min budget as the other batch routes.
'/api/batch/v1/execute': { limit: 30, window: '60 s' },
// Legacy /api/sanctions-entity-search rate limit was 30/min per IP. Preserve
// that budget now that LookupSanctionEntity proxies OpenSanctions live.
'/api/sanctions/v1/lookup-sanction-entity': { limit: 30, window: '60 s' },
// Corporate intelligence (#5695): each cache miss proxies SEC EDGAR and/or
// Finnhub on the caller's behalf, and the per-company inputs are effectively
// unbounded (any ticker/name/domain), so these cannot inherit the fail-open
// global fallback. Same 30/min provider-proxy budget as the sanctions lookup
// and batch fan-out routes above.
'/api/intelligence/v1/get-company-enrichment': { limit: 30, window: '60 s' },
'/api/intelligence/v1/list-company-signals': { limit: 30, window: '60 s' },
'/api/intelligence/v1/search-sec-filings': { limit: 30, window: '60 s' },
// Lead capture: preserve the 3/hr and 5/hr budgets from legacy api/contact.js
// and api/register-interest.js. Lower limits than normal IP rate limit since
// these hit Convex + Resend per request.
'/api/leads/v1/submit-contact': { limit: 3, window: '1 h' },
'/api/leads/v1/register-interest': { limit: 5, window: '1 h' },
// Scenario engine: legacy /api/scenario/v1/run capped at 10 jobs/min/IP via
// inline Upstash INCR. Gateway preserves the same budget while using a
// trusted paid-user principal when available, otherwise the client IP.
'/api/scenario/v1/run-scenario': { limit: 10, window: '60 s' },
// #3734: trigger-simulation PRO endpoint, same shape as run-scenario.
// It follows the same trusted-principal-or-IP attribution contract.
'/api/forecast/v1/trigger-simulation': { limit: 10, window: '60 s' },
// Live tanker map (Energy Atlas): one user with 6 chokepoints Γ 1 call/min
// = 6 req/min/IP base load. 60/min headroom covers tab refreshes + zoom
// pans within a single user without flagging legitimate traffic.
'/api/maritime/v1/get-vessel-snapshot': { limit: 60, window: '60 s' },
// Country Resilience ranking can synchronously warm the full country table
// on cold/stale cache paths; keep it well below the global 600/min fallback.
'/api/resilience/v1/get-resilience-ranking': { limit: 30, window: '60 s' },
// #3805 / PR #3821: MCP proxy is a top-level Vercel Edge Function in
// `api/mcp-proxy.ts` (registered as `external-protocol` in
// api/api-route-exceptions.json β JSON-RPC shape dictated by the MCP spec),
// so it does NOT flow through the gateway and `checkEndpointRateLimit`
// never fires for it. The handler reads this policy and enforces it
// in-handler via `checkScopedRateLimit` β keeping the registry as the
// single source of truth so future audit additions (and the
// enforce-rate-limit-policies lint) see the endpoint. The audit script
// resolves edge-function paths via api/api-route-exceptions.json instead
// of the OpenAPI specs.
'/api/mcp-proxy': { limit: 30, window: '60 s' },
// Docs MCP facade (`api/docs-mcp.ts`, external-protocol exception β serves
// /docs/mcp, proxying the Mintlify docs MCP server and lifting its
// protocol-level tool-call failures into proper JSON-RPC error objects).
// Anonymous by design (upstream is fully public), so the per-IP minute
// limit is the whole abuse defence; 60/min mirrors the MCP public-method
// posture. Enforced in-handler via `checkScopedRateLimit`, same pattern as
// /api/mcp-proxy.
'/api/docs-mcp': { limit: 60, window: '60 s' },
// A2A concierge endpoint (`api/a2a.ts`, external-protocol exception β
// JSON-RPC shape dictated by the A2A spec, served at /a2a). Anonymous and
// quota-free by design (routes over the public tool catalog + public
// freshness envelope only), so the per-IP minute limit is the whole abuse
// defence; 60/min mirrors the MCP public-method posture. Enforced
// in-handler via `checkScopedRateLimit`, same pattern as /api/mcp-proxy.
'/api/a2a': { limit: 60, window: '60 s' },
// NLWeb /ask endpoint (`api/ask.ts`, external-protocol exception β request/
// response shape dictated by the NLWeb spec, served at /ask). Same anonymous
// cheap-catalog posture as /api/a2a, same in-handler enforcement.
'/api/ask': { limit: 60, window: '60 s' },
};
interface RateLimitPolicyDecision {
reason: string;
}
// Repo-native guardrail for routes where the rate-limit is part of the abuse
// defence. scripts/enforce-rate-limit-policies.mjs fails if any route listed
// here can drift back to the gateway's availability-first global fallback.
export const FAIL_CLOSED_ENDPOINT_RATE_POLICY_REQUIRED: Record<string, RateLimitPolicyDecision> = {
'/api/news/v1/summarize-article': {
reason: 'LLM-backed summarization can drive provider spend on cache misses.',
},
'/api/intelligence/v1/classify-event': {
reason: 'AI classification performs expensive provider-backed analysis.',
},
'/api/intelligence/v1/deduct-situation': {
reason: 'LLM-backed situational deduction can drive provider spend on cache misses.',
},
'/api/intelligence/v1/search-intel-history': {
reason: 'Semantic history search embeds the caller\'s query through a paid embeddings provider on every request.',
},
'/api/intelligence/v1/get-similar-events': {
reason: 'Precedent lookup embeds the caller\'s situation text through a paid embeddings provider on every request.',
},
'/api/conflict/v1/get-humanitarian-summary-batch': {
reason: 'Batch summary fans out to the external HAPI (humdata) provider on cache miss.',
},
'/api/intelligence/v1/get-company-enrichment': {
reason: 'Per-company composite fans out to SEC EDGAR and Finnhub on cache miss.',
},
'/api/intelligence/v1/list-company-signals': {
reason: 'Per-company signal discovery fans out to SEC EDGAR and Finnhub on cache miss.',
},
'/api/intelligence/v1/search-sec-filings': {
reason: 'Full-text filing search proxies SEC EDGAR on cache miss with unbounded query cardinality.',
},
'/api/military/v1/get-aircraft-details-batch': {
reason: 'Batch enrichment fans out to the external Wingbits provider on cache miss.',
},
'/api/batch/v1/execute': {
reason: 'Generic batch fan-out multiplies one request into up to 20 gateway sub-requests.',
},
'/api/sanctions/v1/lookup-sanction-entity': {
reason: 'Live sanctions lookup proxies an external provider.',
},
'/api/leads/v1/submit-contact': {
reason: 'Lead capture writes to Convex and sends email.',
},
'/api/leads/v1/register-interest': {
reason: 'Lead capture writes to Convex and sends email.',
},
'/api/scenario/v1/run-scenario': {
reason: 'Scenario runs are mutation-like jobs with a historical 10/min cap.',
},
'/api/forecast/v1/trigger-simulation': {
reason: 'Forecast simulation trigger starts expensive backend work.',
},
'/api/maritime/v1/get-vessel-snapshot': {
reason: 'Live vessel snapshots can generate high-frequency upstream load.',
},
'/api/resilience/v1/get-resilience-ranking': {
reason: 'Cold/stale cache paths can synchronously warm the full country table.',
},
};
// Explicit examples of read-only gateway routes where the global per-IP
// fallback remains acceptable during Redis degradation. New expensive/provider
// routes should not be added here; add them to ENDPOINT_RATE_POLICIES and
// FAIL_CLOSED_ENDPOINT_RATE_POLICY_REQUIRED instead.
export const GLOBAL_RATE_LIMIT_FALLBACK_READ_ROUTES: Record<string, RateLimitPolicyDecision> = {
'/api/aviation/v1/list-airport-delays': {
reason: 'Read-only cache-backed airport delay listing; availability-first fallback is acceptable.',
},
'/api/intelligence/v1/list-material-events': {
reason: 'Read-only Redis read of the seeded 8-K stream; no upstream fetch on miss, so availability-first fallback carries no spend risk.',
},
};
// Explicit allow-list of NON-GET (post/put/patch/delete) gateway routes that are
// permitted to inherit the global availability-first fallback during a Redis
// outage instead of declaring an ENDPOINT_RATE_POLICIES entry. The audit
// scripts/enforce-rate-limit-policies.mjs fails CI if any generated non-GET
// route is neither in ENDPOINT_RATE_POLICIES nor listed here β so a newly added
// expensive/mutation route can no longer silently fail open. Every entry MUST
// carry a justification for why fail-open is safe for that route. When a route
// becomes provider-backed / spend-bearing, move it to ENDPOINT_RATE_POLICIES +
// FAIL_CLOSED_ENDPOINT_RATE_POLICY_REQUIRED instead of keeping it here. (#4676)
export const RATE_LIMIT_MUTATION_FALLBACK_EXEMPT: Record<string, RateLimitPolicyDecision> = {
'/api/economic/v1/get-fred-series-batch': {
reason:
'Read-only despite POST shape: reads seeded FRED data from the Redis seed cache only; all external FRED API calls happen in the Railway seed job, so a cache miss never fans out to an external provider.',
},
'/api/infrastructure/v1/record-baseline-snapshot': {
reason:
'Redis-only write (setCachedJson) with no external provider or LLM call; if Redis is degraded the write itself cannot land, so the fail-open fallback carries no spend/abuse risk.',
},
'/api/v2/shipping/webhooks': {
reason:
'Webhook registration is API-key authenticated (validateApiKey) and premium-gated before any work, so unauthenticated abuse is already blocked; the handler only writes to Redis, with no external provider or LLM spend.',
},
};
const endpointLimiters = new Map<string, Ratelimit>();
function getEndpointRatelimit(pathname: string): Ratelimit | null {
const policy = ENDPOINT_RATE_POLICIES[pathname];
if (!policy) return null;
const cached = endpointLimiters.get(pathname);
if (cached) return cached;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
const rl = new Ratelimit({
redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }),
limiter: Ratelimit.slidingWindow(policy.limit, policy.window),
prefix: 'rl:ep',
analytics: false,
});
endpointLimiters.set(pathname, rl);
return rl;
}
export function hasEndpointRatePolicy(pathname: string): boolean {
return pathname in ENDPOINT_RATE_POLICIES;
}
export async function checkEndpointRateLimit(request: Request, pathname: string, corsHeaders: Record<string, string>, opts: EndpointRateLimitOptions = {}): Promise<Response | null> {
if (!hasEndpointRatePolicy(pathname)) return null;
const rl = getEndpointRatelimit(pathname);
if (!rl) {
const failClosed = opts.failClosed ?? true;
if (failClosed) {
logRateLimitDegraded(`checkEndpointRateLimit:${pathname}:missing-config`, new Error('Upstash Redis is not configured'));
return rateLimitDegradedResponse(corsHeaders);
}
return null;
}
const identifier =
getPrincipalRateLimitIdentifier(opts.principalUserId) ??
`ip:${getClientIp(request)}`;
const policy = ENDPOINT_RATE_POLICIES[pathname];
// hasEndpointRatePolicy(pathname) above already guarantees this β the
// extra check exists only to satisfy noUncheckedIndexedAccess, since TS
// can't carry that narrowing across a second independent index lookup.
if (!policy) return null;
try {
const { success, limit, reset } = await limitWithFallback(rl, `${pathname}:${identifier}`, `rl:ep:fw:${pathname}:${identifier}`, policy.limit, durationToSeconds(policy.window));
if (!success) {
return tooManyRequestsResponse(limit, reset, corsHeaders, durationToSeconds(policy.window));
}
return null;
} catch (err) {
logRateLimitDegraded(`checkEndpointRateLimit:${pathname}`, err);
// Per-endpoint policies exist precisely because the limit IS the abuse
// defence β an LLM endpoint or a 3/hr lead-capture endpoint is the
// worst place to silently fall through during a Redis outage. Default
// to fail-closed; callers can opt out via opts.failClosed = false.
const failClosed = opts.failClosed ?? true;
if (failClosed) return rateLimitDegradedResponse(corsHeaders);
return null;
}
}
// --- In-handler scoped rate limits ---
//
// Handlers that need a per-subscope cap *in addition to* the gateway-level
// endpoint policy (e.g. a tighter budget for one request variant) use this
// helper. Gateway's checkEndpointRateLimit still runs first β this is a
// second stage.
const scopedLimiters = new Map<string, Ratelimit>();
function getScopedRatelimit(scope: string, limit: number, window: Duration): Ratelimit | null {
const cacheKey = `${scope}|${limit}|${window}`;
const cached = scopedLimiters.get(cacheKey);
if (cached) return cached;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
const rl = new Ratelimit({
redis: new Redis({ url, token, ...REDIS_TEST_RETRY_OPTS }),
limiter: Ratelimit.slidingWindow(limit, window),
prefix: 'rl:scope',
analytics: false,
});
scopedLimiters.set(cacheKey, rl);
return rl;
}
export interface ScopedRateLimitResult {
allowed: boolean;
limit: number;
reset: number;
/**
* True when Redis was unreachable and the helper fell back to the
* fail-open default. Callers that need fail-closed semantics should
* gate on this β e.g. lead-capture handlers can refuse the write to
* preserve the 3/hr budget across a Redis blip. (#3531)
*/
degraded: boolean;
}
/**
* Returns whether the request is under the scoped budget. `scope` is an
* opaque namespace (e.g. `${pathname}#desktop`); `identifier` is usually the
* client IP but can be any stable caller identifier. Fail-open on Redis errors
* to stay consistent with checkRateLimit / checkEndpointRateLimit semantics,
* but the `degraded` flag lets callers escalate to fail-closed locally
* (#3531). The Redis error itself is logged once per call so silent bypass
* windows are visible in logs / Sentry.
*/
export async function checkScopedRateLimit(scope: string, limit: number, window: Duration, identifier: string): Promise<ScopedRateLimitResult> {
const rl = getScopedRatelimit(scope, limit, window);
if (!rl) {
logScopedRateLimitMissingConfig(scope);
return { allowed: true, limit, reset: 0, degraded: true };
}
try {
const result = await limitWithFallback(rl, `${scope}:${identifier}`, `rl:scope:fw:${scope}:${identifier}`, limit, durationToSeconds(window));
return {
allowed: result.success,
limit: result.limit,
reset: result.reset,
degraded: false,
};
} catch (err) {
logRateLimitDegraded(`checkScopedRateLimit:${scope}`, err);
return { allowed: true, limit, reset: 0, degraded: true };
}
}
/**
* Applies a distinct, fail-closed per-IP scoped guard and converts its result
* into the gateway's standard 429/503 response contract. Use this ahead of
* expensive identity-attribution lookups that cannot yet use the endpoint's
* final principal-scoped bucket.
*/
export async function checkFailClosedScopedIpRateLimit(
request: Request,
scope: string,
limit: number,
window: Duration,
corsHeaders: Record<string, string>,
): Promise<Response | null> {
const result = await checkScopedRateLimit(scope, limit, window, getClientIp(request));
if (result.degraded) return rateLimitDegradedResponse(corsHeaders);
if (!result.allowed) {
return tooManyRequestsResponse(result.limit, result.reset, corsHeaders, durationToSeconds(window));
}
return null;
}
export function __resetRateLimitForTest(): void {
ratelimit = null;
endpointLimiters.clear();
scopedLimiters.clear();
scopedMissingConfigStages.clear();
resetRateLimitFallbackForTest();
}
|