Skip to content

Commit eea1126

Browse files
Merge pull request #1161 from Baskarayelu/feat/issue-1145-redis-fail-safe
[#1145] Add fail-safe rate limiting for distributed store outages
2 parents 5a3badb + e4e2ed9 commit eea1126

9 files changed

Lines changed: 503 additions & 4 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ RATE_LIMIT_MAX_REQUESTS=5
8484
RATE_LIMIT_WINDOW_MS=60000
8585
RATE_LIMIT_STORE=memory
8686
RATE_LIMIT_PG_TABLE=gateway_rate_limit_buckets
87+
# Behavior when the distributed rate-limit store is unavailable. `fail-closed`
88+
# rejects protected requests; `fallback` allows only the bounded local policy.
89+
RATE_LIMIT_OUTAGE_MODE=fail-closed
90+
RATE_LIMIT_FALLBACK_MAX_REQUESTS=10
91+
RATE_LIMIT_FALLBACK_WINDOW_MS=60000
92+
RATE_LIMIT_FALLBACK_MAX_BUCKETS=10000
8793

8894
# -----------------------------------------------------------------------------
8995
# Credits endpoint token-bucket rate limiting (GET /api/billing/credits)

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ For request-id validation, AsyncLocalStorage propagation, structured logging, an
463463
| `RATE_LIMIT_WINDOW_MS` | No | `60000` | Token-bucket refill window for `RATE_LIMIT_MAX_REQUESTS` (ms) |
464464
| `RATE_LIMIT_STORE` | No | `memory` | `memory` or `postgres`. Use `postgres` to share bucket state across multiple gateway instances |
465465
| `RATE_LIMIT_PG_TABLE` | No | `gateway_rate_limit_buckets` | Table name used when `RATE_LIMIT_STORE=postgres` (auto-created) |
466+
| `RATE_LIMIT_OUTAGE_MODE` | No | `fail-closed` | Distributed-store outage policy: reject protected requests or use the bounded local fallback (`fallback`) |
467+
| `RATE_LIMIT_FALLBACK_MAX_REQUESTS` | No | `10` | Maximum requests per key during fallback mode; never exceeds the distributed request policy |
468+
| `RATE_LIMIT_FALLBACK_WINDOW_MS` | No | `60000` | Fallback window length in milliseconds |
469+
| `RATE_LIMIT_FALLBACK_MAX_BUCKETS` | No | `10000` | Hard cap on local fallback keys; oldest keys are evicted during an outage |
466470
| `QUOTA_RATE_LIMIT_CAPACITY` | No | `60` | Token-bucket burst capacity for all `/api/quotas` endpoints (per user / IP) |
467471
| `QUOTA_RATE_LIMIT_REFILL_RATE` | No | `1` | Tokens added per second to each `/api/quotas` bucket; governs steady-state request rate |
468472
| `CORS_ALLOWED_ORIGINS` | No | `http://localhost:5173` | Comma-separated allowed origins |

src/config/env.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ describe('env schema — gateway rate limit config', () => {
153153
expect(result.data.RATE_LIMIT_WINDOW_MS).toBe(60_000);
154154
expect(result.data.RATE_LIMIT_STORE).toBe('memory');
155155
expect(result.data.RATE_LIMIT_PG_TABLE).toBe('gateway_rate_limit_buckets');
156+
expect(result.data.RATE_LIMIT_OUTAGE_MODE).toBe('fail-closed');
157+
expect(result.data.RATE_LIMIT_FALLBACK_MAX_REQUESTS).toBe(10);
158+
expect(result.data.RATE_LIMIT_FALLBACK_WINDOW_MS).toBe(60_000);
159+
expect(result.data.RATE_LIMIT_FALLBACK_MAX_BUCKETS).toBe(10_000);
156160
}
157161
});
158162

@@ -173,6 +177,35 @@ describe('env schema — gateway rate limit config', () => {
173177
}
174178
});
175179

180+
it('accepts explicit fallback outage policy and bounds', () => {
181+
const result = envSchema.safeParse({
182+
...baseEnv,
183+
RATE_LIMIT_STORE: 'postgres',
184+
RATE_LIMIT_OUTAGE_MODE: 'fallback',
185+
RATE_LIMIT_FALLBACK_MAX_REQUESTS: '7',
186+
RATE_LIMIT_FALLBACK_WINDOW_MS: '15000',
187+
RATE_LIMIT_FALLBACK_MAX_BUCKETS: '250',
188+
});
189+
expect(result.success).toBe(true);
190+
if (result.success) {
191+
expect(result.data.RATE_LIMIT_OUTAGE_MODE).toBe('fallback');
192+
expect(result.data.RATE_LIMIT_FALLBACK_MAX_REQUESTS).toBe(7);
193+
expect(result.data.RATE_LIMIT_FALLBACK_WINDOW_MS).toBe(15_000);
194+
expect(result.data.RATE_LIMIT_FALLBACK_MAX_BUCKETS).toBe(250);
195+
}
196+
});
197+
198+
it('rejects an unsupported outage mode and unsafe fallback dimensions', () => {
199+
const result = envSchema.safeParse({
200+
...baseEnv,
201+
RATE_LIMIT_OUTAGE_MODE: 'allow-all',
202+
RATE_LIMIT_FALLBACK_MAX_REQUESTS: '0',
203+
RATE_LIMIT_FALLBACK_WINDOW_MS: '-1',
204+
RATE_LIMIT_FALLBACK_MAX_BUCKETS: '0',
205+
});
206+
expect(result.success).toBe(false);
207+
});
208+
176209
it('rejects a store value other than "memory" or "postgres"', () => {
177210
const result = envSchema.safeParse({
178211
...baseEnv,

src/config/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ export const envSchema = z
108108
RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(5),
109109
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
110110
RATE_LIMIT_STORE: z.enum(["memory", "postgres"]).default("memory"),
111+
RATE_LIMIT_OUTAGE_MODE: z.enum(["fail-closed", "fallback"]).default("fail-closed"),
112+
RATE_LIMIT_FALLBACK_MAX_REQUESTS: z.coerce.number().int().positive().default(10),
113+
RATE_LIMIT_FALLBACK_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
114+
RATE_LIMIT_FALLBACK_MAX_BUCKETS: z.coerce.number().int().positive().default(10_000),
111115
RATE_LIMIT_PG_TABLE: z
112116
.string()
113117
.regex(

src/config/index.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ describe('config validation', () => {
9191
windowMs: number;
9292
store: 'memory' | 'postgres';
9393
postgresTable: string;
94+
outageMode: 'fail-closed' | 'fallback';
95+
fallbackMaxRequests: number;
96+
fallbackWindowMs: number;
97+
maxFallbackBuckets: number;
9498
};
9599
};
96100
}
@@ -104,6 +108,10 @@ describe('config validation', () => {
104108
windowMs: 60_000,
105109
store: 'memory',
106110
postgresTable: 'gateway_rate_limit_buckets',
111+
outageMode: 'fail-closed',
112+
fallbackMaxRequests: 10,
113+
fallbackWindowMs: 60_000,
114+
maxFallbackBuckets: 10_000,
107115
});
108116
});
109117

@@ -125,6 +133,10 @@ describe('config validation', () => {
125133
windowMs: number;
126134
store: 'memory' | 'postgres';
127135
postgresTable: string;
136+
outageMode: 'fail-closed' | 'fallback';
137+
fallbackMaxRequests: number;
138+
fallbackWindowMs: number;
139+
maxFallbackBuckets: number;
128140
};
129141
};
130142
}
@@ -138,6 +150,10 @@ describe('config validation', () => {
138150
windowMs: 10_000,
139151
store: 'postgres',
140152
postgresTable: 'custom_rate_limit_buckets',
153+
outageMode: 'fail-closed',
154+
fallbackMaxRequests: 10,
155+
fallbackWindowMs: 60_000,
156+
maxFallbackBuckets: 10_000,
141157
});
142158
});
143159

src/config/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ export const config = {
201201
windowMs: env.RATE_LIMIT_WINDOW_MS,
202202
store: env.RATE_LIMIT_STORE,
203203
postgresTable: env.RATE_LIMIT_PG_TABLE,
204+
outageMode: env.RATE_LIMIT_OUTAGE_MODE,
205+
fallbackMaxRequests: env.RATE_LIMIT_FALLBACK_MAX_REQUESTS,
206+
fallbackWindowMs: env.RATE_LIMIT_FALLBACK_WINDOW_MS,
207+
maxFallbackBuckets: env.RATE_LIMIT_FALLBACK_MAX_BUCKETS,
204208
},
205209

206210
sorobanRpc:

src/metrics.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ import { UnauthorizedError } from './errors/index.js';
77
export const register = new client.Registry();
88
client.collectDefaultMetrics({ register });
99

10+
const rateLimiterStoreOutages = new client.Counter({
11+
name: 'rate_limiter_store_outages_total',
12+
help: 'Number of distributed rate-limiter store outages observed',
13+
labelNames: ['outage_mode'],
14+
});
15+
16+
const rateLimiterStoreDegraded = new client.Gauge({
17+
name: 'rate_limiter_store_degraded',
18+
help: 'Whether the distributed rate-limiter store is currently degraded',
19+
});
20+
21+
register.registerMetric(rateLimiterStoreOutages);
22+
register.registerMetric(rateLimiterStoreDegraded);
23+
24+
export function recordRateLimiterStoreOutage(outageMode: 'fail-closed' | 'fallback'): void {
25+
rateLimiterStoreOutages.inc({ outage_mode: outageMode });
26+
rateLimiterStoreDegraded.set(1);
27+
}
28+
29+
export function recordRateLimiterStoreRecovery(): void {
30+
rateLimiterStoreDegraded.set(0);
31+
}
32+
1033
// ── Route groups ──────────────────────────────────────────────────────────────
1134
//
1235
// A `route_group` label is added to every HTTP metric so dashboards can slice

0 commit comments

Comments
 (0)