Files
git.stella-ops.org/tests/load/router-rate-limiting-load-test.js
2025-12-18 00:47:24 +02:00

202 lines
6.8 KiB
JavaScript

/**
* 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,
};
}