work work hard work
This commit is contained in:
201
tests/load/router-rate-limiting-load-test.js
Normal file
201
tests/load/router-rate-limiting-load-test.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user