diff --git a/apps/public-api/package.json b/apps/public-api/package.json index ed600cb18..8d07fb27a 100644 --- a/apps/public-api/package.json +++ b/apps/public-api/package.json @@ -28,6 +28,7 @@ "express": "5.1.0", "express-rate-limit": "8.2.1", "ioredis": "5.10.0", + "rate-limit-redis": "3.1.0", "jsonwebtoken": "9.0.2", "mongoose": "8.19.2", "multer": "2.0.2", @@ -37,6 +38,7 @@ "zod": "4.1.13" }, "devDependencies": { + "autocannon": "8.0.0", "jest": "30.3.0", "supertest": "7.2.2" } diff --git a/apps/public-api/src/controllers/health.controller.js b/apps/public-api/src/controllers/health.controller.js index 750711f6c..788062c81 100644 --- a/apps/public-api/src/controllers/health.controller.js +++ b/apps/public-api/src/controllers/health.controller.js @@ -30,6 +30,56 @@ const getHealth = async (req, res) => { }); }; +const getRedisHealth = async (req, res) => { + if (!redis || redis.status !== 'ready') { + return res.status(503).json({ + status: 'error', + timestamp: new Date().toISOString(), + redis: { connected: false, error: 'Redis client not ready' }, + }); + } + + try { + const info = await redis.info('stats'); + const memory = await redis.info('memory'); + const clients = await redis.info('clients'); + + const parseInfo = (raw, key) => { + const match = raw.match(new RegExp(`${key}:(\\d+)`)); + return match ? parseInt(match[1], 10) : null; + }; + + const parseInfoStr = (raw, key) => { + const match = raw.match(new RegExp(`${key}:(.+)`)); + return match ? match[1].trim() : null; + }; + + const connectedClients = parseInfo(clients, 'connected_clients'); + const usedMemory = parseInfoStr(memory, 'used_memory_human'); + const totalCommands = parseInfo(info, 'total_commands_processed'); + + return res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + redis: { + connected: true, + status: redis.status, + connectedClients, + usedMemory, + totalCommandsProcessed: totalCommands, + reconnectAttempts: redis.options?.retryStrategy ? 'enabled' : 'disabled', + }, + }); + } catch (err) { + return res.status(503).json({ + status: 'error', + timestamp: new Date().toISOString(), + redis: { connected: false, error: err.message }, + }); + } +}; + module.exports = { getHealth, + getRedisHealth, }; diff --git a/apps/public-api/src/middlewares/api_usage.js b/apps/public-api/src/middlewares/api_usage.js index 45affdf93..b45521d08 100644 --- a/apps/public-api/src/middlewares/api_usage.js +++ b/apps/public-api/src/middlewares/api_usage.js @@ -2,7 +2,10 @@ const rateLimit = require('express-rate-limit'); const { Log, redis, ApiAnalytics, getDayKey, DEFAULT_DAILY_TTL_SECONDS, incrWithTtlAtomic } = require('@urbackend/common'); const FIRST_API_SUCCESS_FLAG_TTL_SECONDS = 2 * 365 * 24 * 60 * 60; -// Rate Limiter +// --- Redis-backed project rate limiter store --- +const RedisStore = require('rate-limit-redis'); + +// Rate Limiter (global IP-based) const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, @@ -15,6 +18,35 @@ const limiter = rateLimit({ } }); +// Project-specific Redis-backed rate limiter (replaces in-memory projectRateLimiter) +const projectRateLimiter = rateLimit({ + store: new RedisStore({ + sendCommand: (...args) => redis.call(...args), + prefix: 'rl:project:', + }), + windowMs: 15 * 60 * 1000, + max: async (req, res) => { + if (req.project && req.project.rateLimit) { + return req.project.rateLimit; + } + return 500; + }, + keyGenerator: (req, res) => { + if (!req.project || !req.project._id) { + return 'unauthorized'; + } + return req.project._id.toString(); + }, + handler: (req, res, next, options) => { + res.status(options.statusCode).json({ + error: "Too Many Requests", + message: "Project rate limit exceeded. Please try again later." + }); + }, + standardHeaders: true, + legacyHeaders: false, +}); + // Logger with API analytics const logger = (req, res, next) => { // Capture start time for response time measurement diff --git a/apps/public-api/src/routes/health.js b/apps/public-api/src/routes/health.js index 154fca7f3..56c5d0852 100644 --- a/apps/public-api/src/routes/health.js +++ b/apps/public-api/src/routes/health.js @@ -1,8 +1,9 @@ const express = require('express'); -const { getHealth } = require('../controllers/health.controller'); +const { getHealth, getRedisHealth } = require('../controllers/health.controller'); const router = express.Router(); router.get('/', getHealth); +router.get('/redis', getRedisHealth); module.exports = router; diff --git a/apps/public-api/tests/load/rate-limit.load.test.js b/apps/public-api/tests/load/rate-limit.load.test.js new file mode 100644 index 000000000..2c6474ff9 --- /dev/null +++ b/apps/public-api/tests/load/rate-limit.load.test.js @@ -0,0 +1,45 @@ +const autocannon = require('autocannon'); +const { redis } = require('@urbackend/common'); + +const TARGET_URL = process.env.TARGET_URL || 'http://localhost:1235'; +const API_KEY = process.env.TEST_API_KEY || 'test-api-key'; + +async function runRateLimitLoadTest() { + console.log('šŸš€ Starting rate limit load test...\n'); + + // Warm up: clear any existing rate limit keys + const keys = await redis.keys('rl:project:*'); + if (keys.length) await redis.del(...keys); + console.log(`Cleared ${keys.length} existing rate limit keys\n`); + + const instance = autocannon({ + url: `${TARGET_URL}/api/data`, + connections: 100, + duration: 30, + headers: { 'x-api-key': API_KEY }, + requests: [{ method: 'GET' }], + }); + + autocannon.track(instance, { renderProgressBar: true }); + + const result = await instance; + console.log('\nšŸ“Š Results:'); + console.log(` 2xx: ${result['2xx']}`); + console.log(` 429: ${result['429']}`); + console.log(` Mean latency: ${result.latency.mean}ms`); + console.log(` P99 latency: ${result.latency.p99}ms`); + console.log(` Errors: ${result.errors}`); + + // Verify Redis keys were created + const rlKeys = await redis.keys('rl:project:*'); + console.log(`\nšŸ”‘ Redis rate limit keys created: ${rlKeys.length}`); +} + +if (require.main === module) { + runRateLimitLoadTest().catch((err) => { + console.error('Load test failed:', err); + process.exit(1); + }); +} + +module.exports = { runRateLimitLoadTest }; \ No newline at end of file diff --git a/apps/public-api/tests/resilience/redis-failure.resilience.test.js b/apps/public-api/tests/resilience/redis-failure.resilience.test.js new file mode 100644 index 000000000..1457f7fe5 --- /dev/null +++ b/apps/public-api/tests/resilience/redis-failure.resilience.test.js @@ -0,0 +1,34 @@ +const request = require('supertest'); +const app = require('../../src/app'); +const { redis } = require('@urbackend/common'); + +describe('Redis resilience', () => { + afterEach(async () => { + if (redis.status === 'ready') await redis.flushdb(); + }); + + test('health endpoint returns 503 when Redis is unreachable', async () => { + // Simulate Redis failure by disconnecting temporarily + await redis.disconnect(); + + const res = await request(app).get('/api/health/redis'); + expect(res.status).toBe(503); + expect(res.body.redis.connected).toBe(false); + + // Reconnect for subsequent tests + await redis.connect(); + }); + + test('public API endpoints degrade gracefully when Redis is down', async () => { + await redis.disconnect(); + + const res = await request(app) + .get('/api/data') + .set('x-api-key', 'test-key'); + + // Should not crash; may return 401 or 500 depending on auth flow + expect([200, 401, 403, 500]).toContain(res.status); + + await redis.connect(); + }); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ce26f0305..725a53b99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,14 @@ services: # ── Redis ───────────────────────────────────────────────────────────────────── redis: - image: redis:latest + image: redis:7-alpine container_name: urbackend_redis restart: unless-stopped + command: redis-server --maxclients 10000 --tcp-keepalive 60 volumes: - redis_data:/data + sysctls: + - net.core.somaxconn=65535 # ── Dashboard API (Admin Server) ───────────────────────────────────────────── dashboard-api: diff --git a/packages/common/src/config/redis.js b/packages/common/src/config/redis.js index a4482666e..188439b7e 100644 --- a/packages/common/src/config/redis.js +++ b/packages/common/src/config/redis.js @@ -11,14 +11,25 @@ if (!process.env.REDIS_URL) { const redis = new Redis(process.env.REDIS_URL, { retryStrategy(times) { - const delay = Math.min(times * 50, 2000); - if (times > 3) { + if (times > 10) { console.warn("āš ļø Redis: Max retries reached. Caching will be disabled."); return null; // Stop retrying } + const baseDelay = Math.min(Math.pow(2, times) * 100, 30000); + const jitter = Math.floor(Math.random() * 100); + const delay = baseDelay + jitter; + console.log(`šŸ” Redis retry attempt ${times}, next delay ${delay}ms`); return delay; }, - maxRetriesPerRequest: null // Allow requests to fail if not connected + maxRetriesPerRequest: 3, + enableReadyCheck: true, + keepAlive: 30000, + connectTimeout: 10000, + disconnectTimeout: 2000, + commandTimeout: 5000, + lazyConnect: false, + enableOfflineQueue: true, + offlineQueue: true, }); redis.on('ready', () => {