/** * Router Rate Limiting Load Test Suite (k6) * Reference: SPRINT_1200_001_005 (RRL-05-003) * * Goals: * - Validate 429 + Retry-After behavior under load (instance and/or environment limits). * - Measure overhead (latency) while rate limiting is enabled. * - Exercise route-level matching via mixed-path traffic. * * Notes: * - This test suite is environment-config driven. Ensure Router rate limiting is configured * for the targeted route(s) in the environment under test. * - "Scenario B" (environment multi-instance) is achieved by running the same test * concurrently from multiple machines/agents. */ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend } from 'k6/metrics'; const BASE_URL = (__ENV.BASE_URL || 'http://localhost:5000').replace(/\/+$/, ''); const METHOD = (__ENV.METHOD || 'GET').toUpperCase(); const PATH = __ENV.PATH || '/api/test'; const PATHS_JSON = __ENV.PATHS_JSON || ''; const TENANT_ID = __ENV.TENANT_ID || 'load-test-tenant'; const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; const RESULTS_DIR = __ENV.RESULTS_DIR || 'results'; function parsePaths() { if (!PATHS_JSON) { return [PATH]; } try { const parsed = JSON.parse(PATHS_JSON); if (Array.isArray(parsed) && parsed.length > 0) { return parsed.map((p) => (typeof p === 'string' ? p : PATH)).filter((p) => !!p); } } catch { // Ignore parse errors; fall back to single PATH. } return [PATH]; } const PATHS = parsePaths(); // Custom metrics const rateLimitDenied = new Rate('router_rate_limit_denied'); const retryAfterSeconds = new Trend('router_rate_limit_retry_after_seconds'); const status429MissingRetryAfter = new Rate('router_rate_limit_429_missing_retry_after'); // Scenario configuration (defaults can be overridden via env vars) const BELOW_RPS = parseInt(__ENV.BELOW_RPS || '50', 10); const ABOVE_RPS = parseInt(__ENV.ABOVE_RPS || '500', 10); export const options = { scenarios: { // Scenario A: baseline below configured limits below_limit: { executor: 'constant-arrival-rate', rate: BELOW_RPS, timeUnit: '1s', duration: __ENV.BELOW_DURATION || '2m', preAllocatedVUs: parseInt(__ENV.BELOW_VUS || '50', 10), maxVUs: parseInt(__ENV.BELOW_MAX_VUS || '200', 10), tags: { scenario: 'below_limit' }, }, // Scenario B: above configured limits (expect some 429s) above_limit: { executor: 'ramping-arrival-rate', startRate: BELOW_RPS, timeUnit: '1s', stages: [ { duration: __ENV.ABOVE_RAMP_UP || '20s', target: ABOVE_RPS }, { duration: __ENV.ABOVE_HOLD || '40s', target: ABOVE_RPS }, { duration: __ENV.ABOVE_RAMP_DOWN || '20s', target: BELOW_RPS }, ], preAllocatedVUs: parseInt(__ENV.ABOVE_VUS || '100', 10), maxVUs: parseInt(__ENV.ABOVE_MAX_VUS || '500', 10), startTime: __ENV.ABOVE_START || '2m10s', tags: { scenario: 'above_limit' }, }, // Scenario C: route mix (exercise route-specific limits/matching) route_mix: { executor: 'constant-arrival-rate', rate: parseInt(__ENV.MIX_RPS || '100', 10), timeUnit: '1s', duration: __ENV.MIX_DURATION || '2m', preAllocatedVUs: parseInt(__ENV.MIX_VUS || '75', 10), maxVUs: parseInt(__ENV.MIX_MAX_VUS || '300', 10), startTime: __ENV.MIX_START || '3m30s', tags: { scenario: 'route_mix' }, }, // Scenario F: activation gate (low traffic then spike) activation_gate: { executor: 'ramping-arrival-rate', startRate: 1, timeUnit: '1s', stages: [ { duration: __ENV.GATE_LOW_DURATION || '2m', target: parseInt(__ENV.GATE_LOW_RPS || '5', 10) }, { duration: __ENV.GATE_SPIKE_DURATION || '30s', target: parseInt(__ENV.GATE_SPIKE_RPS || '200', 10) }, { duration: __ENV.GATE_RECOVERY_DURATION || '30s', target: parseInt(__ENV.GATE_LOW_RPS || '5', 10) }, ], preAllocatedVUs: parseInt(__ENV.GATE_VUS || '50', 10), maxVUs: parseInt(__ENV.GATE_MAX_VUS || '300', 10), startTime: __ENV.GATE_START || '5m40s', tags: { scenario: 'activation_gate' }, }, }, thresholds: { 'http_req_failed': ['rate<0.01'], 'router_rate_limit_429_missing_retry_after': ['rate<0.001'], }, }; export default function () { const path = PATHS[Math.floor(Math.random() * PATHS.length)]; const normalizedPath = path.startsWith('/') ? path : `/${path}`; const url = `${BASE_URL}${normalizedPath}`; const headers = { 'Accept': 'application/json', 'X-Tenant-Id': TENANT_ID, 'X-Correlation-Id': `rl-load-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, }; if (AUTH_TOKEN) { headers['Authorization'] = `Bearer ${AUTH_TOKEN}`; } const res = http.request(METHOD, url, null, { headers, tags: { endpoint: normalizedPath }, }); const is429 = res.status === 429; rateLimitDenied.add(is429); if (is429) { const retryAfter = res.headers['Retry-After']; status429MissingRetryAfter.add(!retryAfter); if (retryAfter) { const parsed = parseInt(retryAfter, 10); if (!Number.isNaN(parsed)) { retryAfterSeconds.add(parsed); } } } check(res, { 'status is 2xx or 429': (r) => (r.status >= 200 && r.status < 300) || r.status === 429, 'Retry-After present on 429': (r) => r.status !== 429 || r.headers['Retry-After'] !== undefined, }); sleep(0.05 + Math.random() * 0.1); } export function setup() { console.log(`Starting Router rate limiting load test against ${BASE_URL}`); console.log(`Method=${METHOD}, paths=${JSON.stringify(PATHS)}`); } export function handleSummary(data) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); function metricValue(metricName, valueName) { const metric = data.metrics && data.metrics[metricName]; const values = metric && metric.values; return values ? values[valueName] : undefined; } const summary = { timestampUtc: new Date().toISOString(), baseUrl: BASE_URL, method: METHOD, paths: PATHS, metrics: { httpReqFailedRate: metricValue('http_req_failed', 'rate'), httpReqDurationP95Ms: metricValue('http_req_duration', 'p(95)'), rateLimitDeniedRate: metricValue('router_rate_limit_denied', 'rate'), retryAfterP95Seconds: metricValue('router_rate_limit_retry_after_seconds', 'p(95)'), missingRetryAfterRate: metricValue('router_rate_limit_429_missing_retry_after', 'rate'), }, notes: [ `Set RESULTS_DIR to control file output directory (default: ${RESULTS_DIR}).`, 'Ensure the results directory exists before running if you want JSON artifacts written.', ], }; const json = JSON.stringify(data, null, 2); const summaryJson = JSON.stringify(summary, null, 2); return { stdout: `${summaryJson}\n`, [`${RESULTS_DIR}/router-rate-limiting-load-test-${timestamp}.json`]: json, [`${RESULTS_DIR}/router-rate-limiting-load-test-latest.json`]: json, }; }