diff --git a/.env.example b/.env.example index db18f4d3..a4c5aebf 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,12 @@ RATE_LIMIT_MAX_REQUESTS=5 RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_STORE=memory RATE_LIMIT_PG_TABLE=gateway_rate_limit_buckets +# Behavior when the distributed rate-limit store is unavailable. `fail-closed` +# rejects protected requests; `fallback` allows only the bounded local policy. +RATE_LIMIT_OUTAGE_MODE=fail-closed +RATE_LIMIT_FALLBACK_MAX_REQUESTS=10 +RATE_LIMIT_FALLBACK_WINDOW_MS=60000 +RATE_LIMIT_FALLBACK_MAX_BUCKETS=10000 # ----------------------------------------------------------------------------- # Credits endpoint token-bucket rate limiting (GET /api/billing/credits) diff --git a/README.md b/README.md index 1c407272..265a52b6 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,10 @@ For request-id validation, AsyncLocalStorage propagation, structured logging, an | `RATE_LIMIT_WINDOW_MS` | No | `60000` | Token-bucket refill window for `RATE_LIMIT_MAX_REQUESTS` (ms) | | `RATE_LIMIT_STORE` | No | `memory` | `memory` or `postgres`. Use `postgres` to share bucket state across multiple gateway instances | | `RATE_LIMIT_PG_TABLE` | No | `gateway_rate_limit_buckets` | Table name used when `RATE_LIMIT_STORE=postgres` (auto-created) | +| `RATE_LIMIT_OUTAGE_MODE` | No | `fail-closed` | Distributed-store outage policy: reject protected requests or use the bounded local fallback (`fallback`) | +| `RATE_LIMIT_FALLBACK_MAX_REQUESTS` | No | `10` | Maximum requests per key during fallback mode; never exceeds the distributed request policy | +| `RATE_LIMIT_FALLBACK_WINDOW_MS` | No | `60000` | Fallback window length in milliseconds | +| `RATE_LIMIT_FALLBACK_MAX_BUCKETS` | No | `10000` | Hard cap on local fallback keys; oldest keys are evicted during an outage | | `QUOTA_RATE_LIMIT_CAPACITY` | No | `60` | Token-bucket burst capacity for all `/api/quotas` endpoints (per user / IP) | | `QUOTA_RATE_LIMIT_REFILL_RATE` | No | `1` | Tokens added per second to each `/api/quotas` bucket; governs steady-state request rate | | `CORS_ALLOWED_ORIGINS` | No | `http://localhost:5173` | Comma-separated allowed origins | diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 1f277621..9144d01a 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -153,6 +153,10 @@ describe('env schema — gateway rate limit config', () => { expect(result.data.RATE_LIMIT_WINDOW_MS).toBe(60_000); expect(result.data.RATE_LIMIT_STORE).toBe('memory'); expect(result.data.RATE_LIMIT_PG_TABLE).toBe('gateway_rate_limit_buckets'); + expect(result.data.RATE_LIMIT_OUTAGE_MODE).toBe('fail-closed'); + expect(result.data.RATE_LIMIT_FALLBACK_MAX_REQUESTS).toBe(10); + expect(result.data.RATE_LIMIT_FALLBACK_WINDOW_MS).toBe(60_000); + expect(result.data.RATE_LIMIT_FALLBACK_MAX_BUCKETS).toBe(10_000); } }); @@ -173,6 +177,35 @@ describe('env schema — gateway rate limit config', () => { } }); + it('accepts explicit fallback outage policy and bounds', () => { + const result = envSchema.safeParse({ + ...baseEnv, + RATE_LIMIT_STORE: 'postgres', + RATE_LIMIT_OUTAGE_MODE: 'fallback', + RATE_LIMIT_FALLBACK_MAX_REQUESTS: '7', + RATE_LIMIT_FALLBACK_WINDOW_MS: '15000', + RATE_LIMIT_FALLBACK_MAX_BUCKETS: '250', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.RATE_LIMIT_OUTAGE_MODE).toBe('fallback'); + expect(result.data.RATE_LIMIT_FALLBACK_MAX_REQUESTS).toBe(7); + expect(result.data.RATE_LIMIT_FALLBACK_WINDOW_MS).toBe(15_000); + expect(result.data.RATE_LIMIT_FALLBACK_MAX_BUCKETS).toBe(250); + } + }); + + it('rejects an unsupported outage mode and unsafe fallback dimensions', () => { + const result = envSchema.safeParse({ + ...baseEnv, + RATE_LIMIT_OUTAGE_MODE: 'allow-all', + RATE_LIMIT_FALLBACK_MAX_REQUESTS: '0', + RATE_LIMIT_FALLBACK_WINDOW_MS: '-1', + RATE_LIMIT_FALLBACK_MAX_BUCKETS: '0', + }); + expect(result.success).toBe(false); + }); + it('rejects a store value other than "memory" or "postgres"', () => { const result = envSchema.safeParse({ ...baseEnv, diff --git a/src/config/env.ts b/src/config/env.ts index 647d55e2..a42db339 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -108,6 +108,10 @@ export const envSchema = z RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(5), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), RATE_LIMIT_STORE: z.enum(["memory", "postgres"]).default("memory"), + RATE_LIMIT_OUTAGE_MODE: z.enum(["fail-closed", "fallback"]).default("fail-closed"), + RATE_LIMIT_FALLBACK_MAX_REQUESTS: z.coerce.number().int().positive().default(10), + RATE_LIMIT_FALLBACK_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + RATE_LIMIT_FALLBACK_MAX_BUCKETS: z.coerce.number().int().positive().default(10_000), RATE_LIMIT_PG_TABLE: z .string() .regex( diff --git a/src/config/index.test.ts b/src/config/index.test.ts index d0d9c8a5..534eb1cf 100644 --- a/src/config/index.test.ts +++ b/src/config/index.test.ts @@ -91,6 +91,10 @@ describe('config validation', () => { windowMs: number; store: 'memory' | 'postgres'; postgresTable: string; + outageMode: 'fail-closed' | 'fallback'; + fallbackMaxRequests: number; + fallbackWindowMs: number; + maxFallbackBuckets: number; }; }; } @@ -104,6 +108,10 @@ describe('config validation', () => { windowMs: 60_000, store: 'memory', postgresTable: 'gateway_rate_limit_buckets', + outageMode: 'fail-closed', + fallbackMaxRequests: 10, + fallbackWindowMs: 60_000, + maxFallbackBuckets: 10_000, }); }); @@ -125,6 +133,10 @@ describe('config validation', () => { windowMs: number; store: 'memory' | 'postgres'; postgresTable: string; + outageMode: 'fail-closed' | 'fallback'; + fallbackMaxRequests: number; + fallbackWindowMs: number; + maxFallbackBuckets: number; }; }; } @@ -138,6 +150,10 @@ describe('config validation', () => { windowMs: 10_000, store: 'postgres', postgresTable: 'custom_rate_limit_buckets', + outageMode: 'fail-closed', + fallbackMaxRequests: 10, + fallbackWindowMs: 60_000, + maxFallbackBuckets: 10_000, }); }); diff --git a/src/config/index.ts b/src/config/index.ts index ab726591..d41b1cb6 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -201,6 +201,10 @@ export const config = { windowMs: env.RATE_LIMIT_WINDOW_MS, store: env.RATE_LIMIT_STORE, postgresTable: env.RATE_LIMIT_PG_TABLE, + outageMode: env.RATE_LIMIT_OUTAGE_MODE, + fallbackMaxRequests: env.RATE_LIMIT_FALLBACK_MAX_REQUESTS, + fallbackWindowMs: env.RATE_LIMIT_FALLBACK_WINDOW_MS, + maxFallbackBuckets: env.RATE_LIMIT_FALLBACK_MAX_BUCKETS, }, sorobanRpc: diff --git a/src/metrics.ts b/src/metrics.ts index a76562ba..98ede9f4 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -7,6 +7,29 @@ import { UnauthorizedError } from './errors/index.js'; export const register = new client.Registry(); client.collectDefaultMetrics({ register }); +const rateLimiterStoreOutages = new client.Counter({ + name: 'rate_limiter_store_outages_total', + help: 'Number of distributed rate-limiter store outages observed', + labelNames: ['outage_mode'], +}); + +const rateLimiterStoreDegraded = new client.Gauge({ + name: 'rate_limiter_store_degraded', + help: 'Whether the distributed rate-limiter store is currently degraded', +}); + +register.registerMetric(rateLimiterStoreOutages); +register.registerMetric(rateLimiterStoreDegraded); + +export function recordRateLimiterStoreOutage(outageMode: 'fail-closed' | 'fallback'): void { + rateLimiterStoreOutages.inc({ outage_mode: outageMode }); + rateLimiterStoreDegraded.set(1); +} + +export function recordRateLimiterStoreRecovery(): void { + rateLimiterStoreDegraded.set(0); +} + // ── Route groups ────────────────────────────────────────────────────────────── // // A `route_group` label is added to every HTTP metric so dashboards can slice diff --git a/src/services/rateLimiter.resilience.test.ts b/src/services/rateLimiter.resilience.test.ts new file mode 100644 index 00000000..9a182814 --- /dev/null +++ b/src/services/rateLimiter.resilience.test.ts @@ -0,0 +1,300 @@ +import assert from 'node:assert/strict'; +import type { RateLimitResult } from '../types/gateway.js'; +import { + InMemoryRateLimiterStore, + ResilientRateLimiterStore, + type RateLimiterStore, + type RateLimiterStoreCheckOptions, +} from './rateLimiter.js'; + +class ToggleStore implements RateLimiterStore { + failing = false; + calls = 0; + readonly results: RateLimitResult[] = []; + + async check(_bucketKey: string, _options: RateLimiterStoreCheckOptions): Promise { + this.calls += 1; + if (this.failing) { + throw new Error('distributed store unavailable'); + } + const result = this.results.shift() ?? { allowed: true }; + return result; + } +} + +function options(overrides: Partial = {}): RateLimiterStoreCheckOptions { + return { + maxRequests: 100, + windowMs: 60_000, + now: 1_000, + ...overrides, + }; +} + +describe('ResilientRateLimiterStore', () => { + it('fails closed by default when the distributed store is unavailable', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary); + + const result = await store.check('protected-key', options({ windowMs: 5_000 })); + + assert.deepEqual(result, { allowed: false, retryAfterMs: 5_000 }); + assert.equal(store.isDegraded(), true); + assert.equal(primary.calls, 1); + }); + + it('does not call the local fallback in fail-closed mode', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fail-closed', + fallbackMaxRequests: 1, + }); + + const first = await store.check('same-key', options({ maxRequests: 1 })); + const second = await store.check('same-key', options({ maxRequests: 1 })); + + assert.equal(first.allowed, false); + assert.equal(second.allowed, false); + assert.equal(first.retryAfterMs, 60_000); + assert.equal(primary.calls, 2); + }); + + it('uses a bounded local policy during an explicitly configured outage', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 2, + fallbackWindowMs: 2_000, + maxFallbackBuckets: 100, + }); + + assert.deepEqual(await store.check('burst-key', options({ maxRequests: 100 })), { allowed: true }); + assert.deepEqual(await store.check('burst-key', options({ maxRequests: 100, now: 1_500 })), { allowed: true }); + + const blocked = await store.check('burst-key', options({ maxRequests: 100, now: 1_750 })); + assert.equal(blocked.allowed, false); + assert.equal(blocked.retryAfterMs, 1_250); + }); + + it('caps fallback requests and window independently of the distributed policy', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 1, + fallbackWindowMs: 1_000, + }); + + const first = await store.check('capped-key', options({ maxRequests: 500, windowMs: 60_000 })); + const blocked = await store.check('capped-key', options({ maxRequests: 500, windowMs: 60_000, now: 1_500 })); + + assert.equal(first.allowed, true); + assert.equal(blocked.allowed, false); + assert.equal(blocked.retryAfterMs, 500); + }); + + it('evicts the oldest fallback key at the configured hard bound', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 1, + maxFallbackBuckets: 2, + }); + + await store.check('oldest', options()); + await store.check('middle', options()); + await store.check('newest', options()); + + const oldestAfterEviction = await store.check('oldest', options({ now: 2_000 })); + assert.equal(oldestAfterEviction.allowed, true); + }); + + it('returns to the primary store and clears local counters after recovery', async () => { + const primary = new ToggleStore(); + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 1, + fallbackWindowMs: 60_000, + }); + + primary.failing = true; + await store.check('recovery-key', options()); + const duringOutage = await store.check('recovery-key', options({ now: 2_000 })); + assert.equal(duringOutage.allowed, false); + + primary.failing = false; + primary.results.push({ allowed: true }); + const recovered = await store.check('recovery-key', options({ now: 3_000 })); + assert.deepEqual(recovered, { allowed: true }); + assert.equal(store.isDegraded(), false); + + primary.failing = true; + const freshFallback = await store.check('recovery-key', options({ now: 4_000 })); + assert.deepEqual(freshFallback, { allowed: true }); + }); + + it('does not merge fallback counters into a recovered distributed bucket', async () => { + const primary = new ToggleStore(); + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 1, + }); + + primary.failing = true; + await store.check('no-merge', options()); + + primary.failing = false; + primary.results.push({ allowed: false, retryAfterMs: 9_000 }); + const distributedDecision = await store.check('no-merge', options()); + + assert.deepEqual(distributedDecision, { allowed: false, retryAfterMs: 9_000 }); + assert.equal(primary.calls, 2); + }); + + it('re-enters fallback mode if the store fails again after recovery', async () => { + const primary = new ToggleStore(); + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 1, + }); + + primary.failing = true; + await store.check('flapping-key', options()); + primary.failing = false; + primary.results.push({ allowed: true }); + await store.check('flapping-key', options()); + primary.failing = true; + + const firstAfterFlap = await store.check('flapping-key', options()); + assert.deepEqual(firstAfterFlap, { allowed: true }); + const secondAfterFlap = await store.check('flapping-key', options()); + assert.equal(secondAfterFlap.allowed, false); + }); + + it('rejects unsafe fallback dimensions before accepting traffic', () => { + const primary = new InMemoryRateLimiterStore(); + + assert.throws( + () => new ResilientRateLimiterStore(primary, { fallbackMaxRequests: 0 }), + /fallbackMaxRequests must be a positive integer/, + ); + assert.throws( + () => new ResilientRateLimiterStore(primary, { fallbackWindowMs: -1 }), + /fallbackWindowMs must be a positive integer/, + ); + assert.throws( + () => new ResilientRateLimiterStore(primary, { maxFallbackBuckets: 0 }), + /maxBuckets must be a positive integer/, + ); + }); + + it('isolates fallback buckets by the complete distributed bucket key', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 1, + }); + + const userA = await store.check('tenant-a:user-1', options()); + const userB = await store.check('tenant-b:user-1', options()); + const userASecond = await store.check('tenant-a:user-1', options()); + + assert.equal(userA.allowed, true); + assert.equal(userB.allowed, true); + assert.equal(userASecond.allowed, false); + }); + + it('keeps fail-closed retry hints bounded to a usable positive duration', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary, { outageMode: 'fail-closed' }); + + const result = await store.check('bounded-retry', options({ windowMs: 1 })); + + assert.equal(result.allowed, false); + assert.equal(result.retryAfterMs, 1_000); + }); + + it('enforces the fallback ceiling under a concurrent burst', async () => { + const primary = new ToggleStore(); + primary.failing = true; + const store = new ResilientRateLimiterStore(primary, { + outageMode: 'fallback', + fallbackMaxRequests: 3, + }); + + const results = await Promise.all( + Array.from({ length: 10 }, () => store.check('concurrent-burst', options())), + ); + + assert.equal(results.filter((result) => result.allowed).length, 3); + assert.equal(results.filter((result) => !result.allowed).length, 7); + }); + + it('does not expose primary store error details to callers', async () => { + const primary: RateLimiterStore = { + async check() { + throw new Error('postgres://admin:password@internal/db'); + }, + }; + const store = new ResilientRateLimiterStore(primary, { outageMode: 'fail-closed' }); + + const result = await store.check('safe-error', options()); + + assert.deepEqual(result, { allowed: false, retryAfterMs: 60_000 }); + assert.equal(JSON.stringify(result).includes('password'), false); + }); + + it('does not reset healthy primary state when local fallback is empty', async () => { + const primary = new ToggleStore(); + const store = new ResilientRateLimiterStore(primary, { outageMode: 'fallback' }); + + primary.results.push({ allowed: false, retryAfterMs: 321 }); + const result = await store.check('primary-decision', options()); + + assert.deepEqual(result, { allowed: false, retryAfterMs: 321 }); + assert.equal(store.isDegraded(), false); + }); + + it('records outage and recovery metrics for operational dashboards', async () => { + const primary = new ToggleStore(); + const store = new ResilientRateLimiterStore(primary, { outageMode: 'fallback' }); + primary.failing = true; + await store.check('metric-key', options()); + + const degradedMetrics = await import('../metrics.js'); + const outageMetric = (await degradedMetrics.register.getMetricsAsJSON()).find( + (metric: { name: string }) => metric.name === 'rate_limiter_store_outages_total', + ) as { values?: Array<{ labels: Record; value: number }> } | undefined; + assert.ok(outageMetric?.values?.some( + (entry) => entry.labels.outage_mode === 'fallback' && entry.value >= 1, + )); + + primary.failing = false; + await store.check('metric-key', options()); + const stateMetric = (await degradedMetrics.register.getMetricsAsJSON()).find( + (metric: { name: string }) => metric.name === 'rate_limiter_store_degraded', + ) as { values?: Array<{ value: number }> } | undefined; + assert.equal(stateMetric?.values?.[0]?.value, 0); + }); +}); + +describe('InMemoryRateLimiterStore capacity guard', () => { + it('rejects a zero capacity guard instead of allowing unbounded storage', () => { + assert.throws(() => new InMemoryRateLimiterStore(0), /maxBuckets must be a positive integer/); + }); + + it('retains normal store behavior with its default capacity', async () => { + const store = new InMemoryRateLimiterStore(); + const first = await store.check('default-capacity', options({ maxRequests: 1 })); + const second = await store.check('default-capacity', options({ maxRequests: 1 })); + + assert.deepEqual(first, { allowed: true }); + assert.equal(second.allowed, false); + }); +}); diff --git a/src/services/rateLimiter.ts b/src/services/rateLimiter.ts index f27a6512..b1ecb5cc 100644 --- a/src/services/rateLimiter.ts +++ b/src/services/rateLimiter.ts @@ -1,5 +1,10 @@ import type { PoolClient } from 'pg'; import type { RateLimiter, RateLimitResult } from '../types/gateway.js'; +import { logger } from '../logger.js'; +import { + recordRateLimiterStoreOutage, + recordRateLimiterStoreRecovery, +} from '../metrics.js'; interface TokenBucket { tokens: number; @@ -57,9 +62,18 @@ export interface InMemoryRateLimiterConfig extends ConfiguredRateLimiterOptions store?: 'memory'; } +export type RateLimiterOutageMode = 'fail-closed' | 'fallback'; + +export interface ResilientRateLimiterOptions { + outageMode?: RateLimiterOutageMode; + fallbackMaxRequests?: number; + fallbackWindowMs?: number; + maxFallbackBuckets?: number; +} + export type RateLimiterConfig = | InMemoryRateLimiterConfig - | PersistentRateLimiterConfig; + | (PersistentRateLimiterConfig & ResilientRateLimiterOptions); const DEFAULT_MAX_REQUESTS = 100; const DEFAULT_WINDOW_MS = 60_000; @@ -168,11 +182,21 @@ async function rollbackQuietly(client: PersistentRateLimiterClient): Promise(); + constructor(private readonly maxBuckets = 10_000) { + if (!Number.isInteger(maxBuckets) || maxBuckets <= 0) { + throw new Error('maxBuckets must be a positive integer.'); + } + } + async check( bucketKey: string, options: RateLimiterStoreCheckOptions, ): Promise { const existingBucket = this.buckets.get(bucketKey); + if (!existingBucket && this.buckets.size >= this.maxBuckets) { + const oldestKey = this.buckets.keys().next().value as string | undefined; + if (oldestKey !== undefined) this.buckets.delete(oldestKey); + } const { bucket, result } = computeRateLimitResult( existingBucket, options.maxRequests, @@ -193,6 +217,82 @@ export class InMemoryRateLimiterStore implements RateLimiterStore { } } +/** + * Keeps a distributed limiter fail-safe when its backing store is unavailable. + * + * A fallback bucket is deliberately isolated from the distributed bucket: it + * is never written back after recovery, so a recovered store cannot inherit + * stale local counters. The local store also evicts the oldest key at a hard + * bound to prevent an outage from turning into an unbounded memory sink. + */ +export class ResilientRateLimiterStore implements RateLimiterStore { + private readonly fallback: InMemoryRateLimiterStore; + private degraded = false; + private readonly outageMode: RateLimiterOutageMode; + private readonly fallbackMaxRequests: number; + private readonly fallbackWindowMs: number; + + constructor( + private readonly primary: RateLimiterStore, + options: ResilientRateLimiterOptions = {}, + ) { + this.outageMode = options.outageMode ?? 'fail-closed'; + this.fallbackMaxRequests = normalizePositiveInteger( + options.fallbackMaxRequests ?? 10, + 'fallbackMaxRequests', + ); + this.fallbackWindowMs = normalizePositiveInteger( + options.fallbackWindowMs ?? 60_000, + 'fallbackWindowMs', + ); + this.fallback = new InMemoryRateLimiterStore(options.maxFallbackBuckets ?? 10_000); + } + + async check( + bucketKey: string, + options: RateLimiterStoreCheckOptions, + ): Promise { + try { + const result = await this.primary.check(bucketKey, options); + if (this.degraded) { + this.degraded = false; + this.fallback.reset(); + recordRateLimiterStoreRecovery(); + logger.info('[rateLimiter] distributed store recovered; local fallback reset', { + bucketKey, + }); + } + return result; + } catch (error) { + if (!this.degraded) { + this.degraded = true; + recordRateLimiterStoreOutage(this.outageMode); + logger.error('[rateLimiter] distributed store unavailable', { + outageMode: this.outageMode, + error: error instanceof Error ? error.message : String(error), + }); + } + + if (this.outageMode === 'fail-closed') { + return { + allowed: false, + retryAfterMs: Math.max(options.windowMs, 1_000), + }; + } + + return this.fallback.check(bucketKey, { + maxRequests: Math.min(options.maxRequests, this.fallbackMaxRequests), + now: options.now, + windowMs: Math.min(options.windowMs, this.fallbackWindowMs), + }); + } + } + + isDegraded(): boolean { + return this.degraded; + } +} + export class PostgresRateLimiterStore implements RateLimiterStore { private readonly pool: PersistentRateLimiterPool; private readonly tableName: string; @@ -393,6 +493,10 @@ export interface AppRateLimiterConfig { windowMs: number; store: 'memory' | 'postgres'; postgresTable: string; + outageMode?: RateLimiterOutageMode; + fallbackMaxRequests?: number; + fallbackWindowMs?: number; + maxFallbackBuckets?: number; } /** @@ -411,6 +515,10 @@ export function resolveRateLimiterConfig( maxRequests: config.maxRequests, windowMs: config.windowMs, tableName: config.postgresTable, + ...(config.outageMode ? { outageMode: config.outageMode } : {}), + ...(config.fallbackMaxRequests !== undefined ? { fallbackMaxRequests: config.fallbackMaxRequests } : {}), + ...(config.fallbackWindowMs !== undefined ? { fallbackWindowMs: config.fallbackWindowMs } : {}), + ...(config.maxFallbackBuckets !== undefined ? { maxFallbackBuckets: config.maxFallbackBuckets } : {}), }; } @@ -436,12 +544,13 @@ export function createConfiguredRateLimiter( ); } + const distributedStore = new PostgresRateLimiterStore(persistentPool, { + tableName: config.tableName, + }); return new StoreBackedRateLimiter( maxRequests, windowMs, - new PostgresRateLimiterStore(persistentPool, { - tableName: config.tableName, - }), + new ResilientRateLimiterStore(distributedStore, config), tierPolicies, ); }