202 lines
6.8 KiB
JavaScript
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,
|
|
};
|
|
}
|