From 46198252c0e5369db95bc4f440a0b7c9df9678ec Mon Sep 17 00:00:00 2001 From: rojae Date: Wed, 31 Dec 2025 04:04:54 +0900 Subject: [PATCH] fix(k6): rate-limiter test with `k6` - /scripts/rate-limit-test.js - /scripts/stress-test.js - relocate dockerfiles --- .gitignore | 5 +- docker/{ => infra}/elk.yml | 0 docker/{ => infra}/full.yml | 0 docker/{ => infra}/mongo.yml | 0 docker/{ => infra}/redis-cluster.yml | 0 docker/{ => infra}/redis-standalone.yml | 0 docker/k6/README.md | 23 +++ docker/k6/docker-compose.yml | 28 ++++ docker/k6/scripts/rate-limit-test.js | 147 ++++++++++++++++++ docker/k6/scripts/stress-test.js | 140 +++++++++++++++++ .../fluxgate/spring/aop/RateLimitAspect.java | 2 + .../filter/FluxgateRateLimitFilter.java | 2 + .../fluxgate/spring/aop/RateLimitAspect.java | 2 + .../filter/FluxgateRateLimitFilter.java | 2 + 14 files changed, 350 insertions(+), 1 deletion(-) rename docker/{ => infra}/elk.yml (100%) rename docker/{ => infra}/full.yml (100%) rename docker/{ => infra}/mongo.yml (100%) rename docker/{ => infra}/redis-cluster.yml (100%) rename docker/{ => infra}/redis-standalone.yml (100%) create mode 100644 docker/k6/README.md create mode 100644 docker/k6/docker-compose.yml create mode 100644 docker/k6/scripts/rate-limit-test.js create mode 100644 docker/k6/scripts/stress-test.js diff --git a/.gitignore b/.gitignore index df77ab8..a669fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -98,4 +98,7 @@ replay_pid* *.temp # Secret -private.key \ No newline at end of file +private.key + +# k6-result +docker/k6/results \ No newline at end of file diff --git a/docker/elk.yml b/docker/infra/elk.yml similarity index 100% rename from docker/elk.yml rename to docker/infra/elk.yml diff --git a/docker/full.yml b/docker/infra/full.yml similarity index 100% rename from docker/full.yml rename to docker/infra/full.yml diff --git a/docker/mongo.yml b/docker/infra/mongo.yml similarity index 100% rename from docker/mongo.yml rename to docker/infra/mongo.yml diff --git a/docker/redis-cluster.yml b/docker/infra/redis-cluster.yml similarity index 100% rename from docker/redis-cluster.yml rename to docker/infra/redis-cluster.yml diff --git a/docker/redis-standalone.yml b/docker/infra/redis-standalone.yml similarity index 100% rename from docker/redis-standalone.yml rename to docker/infra/redis-standalone.yml diff --git a/docker/k6/README.md b/docker/k6/README.md new file mode 100644 index 0000000..c80c4d9 --- /dev/null +++ b/docker/k6/README.md @@ -0,0 +1,23 @@ +# FluxGate k6 Load Testing + +rate-limiter test with `k6` + +## Quick Start + +### 1. k6 container start + +```bash +cd docker/k6 +docker-compose up -d +``` + +### 2. Run test + +```bash +# Rate Limit Accuracy Test +docker exec fluxgate-k6 k6 run /scripts/rate-limit-test.js + +# Stress Test +docker exec fluxgate-k6 k6 run /scripts/stress-test.js +``` + diff --git a/docker/k6/docker-compose.yml b/docker/k6/docker-compose.yml new file mode 100644 index 0000000..66881aa --- /dev/null +++ b/docker/k6/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + k6: + image: grafana/k6:latest + container_name: fluxgate-k6 + volumes: + - ./scripts:/scripts + - ./results:/results + environment: + - BASE_URL=http://host.docker.internal:8085 + # Rate limit test settings + - EXPECTED_LIMIT=10 + - WINDOW_SECONDS=60 + - TEST_ITERATIONS=15 + # Distributed test settings + - NUM_CLIENTS=10 + - REQUESTS_PER_CLIENT=20 + # Default: wait mode (manual execution) + entrypoint: ["tail", "-f", "/dev/null"] + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - fluxgate-test + +networks: + fluxgate-test: + driver: bridge diff --git a/docker/k6/scripts/rate-limit-test.js b/docker/k6/scripts/rate-limit-test.js new file mode 100644 index 0000000..f862d4b --- /dev/null +++ b/docker/k6/scripts/rate-limit-test.js @@ -0,0 +1,147 @@ +/** + * FluxGate Rate Limit Accuracy Test + * + * This script verifies that the rate limiter correctly enforces the configured limit. + * It sends more requests than the allowed limit and checks: + * 1. Requests within limit return HTTP 200 + * 2. Requests exceeding limit return HTTP 429 + * 3. Rate limit headers are present in 429 responses + * + * Usage: + * docker exec fluxgate-k6 k6 run /scripts/rate-limit-test.js + * + * Environment Variables: + * - BASE_URL: Target server URL (default: http://localhost:8085) + * - EXPECTED_LIMIT: Expected rate limit per window (default: 10) + * - WINDOW_SECONDS: Rate limit window in seconds (default: 60) + */ + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Rate, Trend } from 'k6/metrics'; +import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js'; + +// ============================================================================= +// Custom Metrics +// ============================================================================= +// Counter: Tracks cumulative count of events +// Rate: Tracks percentage of non-zero values (used for pass/fail rates) +// Trend: Tracks statistical distribution (min, max, avg, percentiles) + +const rateLimitedRequests = new Counter('rate_limited_requests'); // Count of 429 responses +const successfulRequests = new Counter('successful_requests'); // Count of 200 responses +const rateLimitAccuracy = new Rate('rate_limit_accuracy'); // Success rate metric +const responseTime = new Trend('response_time'); // Response time distribution + +// ============================================================================= +// Configuration +// ============================================================================= +// __ENV allows reading environment variables passed to k6 +// These can be set in docker-compose.yml or via CLI: k6 run -e BASE_URL=http://... + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8085'; +const EXPECTED_LIMIT = parseInt(__ENV.EXPECTED_LIMIT) || 10; +const WINDOW_SECONDS = parseInt(__ENV.WINDOW_SECONDS) || 60; + +// ============================================================================= +// Test Options +// ============================================================================= +// options object configures how k6 executes the test + +export const options = { + scenarios: { + // Scenario defines test execution pattern + accuracy_test: { + // 'per-vu-iterations': Each VU runs exactly N iterations + executor: 'per-vu-iterations', + + // VUs (Virtual Users): Simulated concurrent users + vus: 1, + + // Total iterations per VU (limit + 5 to verify blocking works) + iterations: EXPECTED_LIMIT + 5, + + // Maximum time for scenario to complete + maxDuration: '30s', + }, + }, + + // Thresholds define pass/fail criteria for the test + thresholds: { + // p(95) = 95th percentile; 95% of requests must be under 500ms + http_req_duration: ['p(95)<500'], + + // rate > 0.9 means more than 90% must pass + rate_limit_accuracy: ['rate>0.9'], + }, +}; + +// ============================================================================= +// Main Test Function +// ============================================================================= +// default function is executed repeatedly by each VU + +export default function () { + // Send HTTP GET request to the test endpoint + const res = http.get(`${BASE_URL}/api/test`); + + // Record response time in our custom metric + responseTime.add(res.timings.duration); + + // Classify response status + const isSuccess = res.status === 200; + const isRateLimited = res.status === 429; + + // Increment appropriate counter + if (isSuccess) { + successfulRequests.add(1); + } + + if (isRateLimited) { + rateLimitedRequests.add(1); + + // Verify rate limit headers are present in 429 responses + // Note: k6 normalizes header names (e.g., X-RateLimit-Remaining -> X-Ratelimit-Remaining) + check(res, { + 'has retry-after header': (r) => r.headers['Retry-After'] !== undefined, + 'has rate-limit-remaining header': (r) => r.headers['X-Ratelimit-Remaining'] !== undefined, + }); + } + + // Verify response status is expected (either allowed or rate-limited) + check(res, { + 'status is 200 or 429': (r) => r.status === 200 || r.status === 429, + }); + + // Small delay between requests (100ms) + // sleep() pauses execution; time is in seconds + sleep(0.1); +} + +// ============================================================================= +// Summary Handler +// ============================================================================= +// handleSummary is called at the end of test with all collected metrics + +export function handleSummary(data) { + // Extract counts from metrics using optional chaining (?.) + const successful = data.metrics.successful_requests?.values?.count || 0; + const rateLimited = data.metrics.rate_limited_requests?.values?.count || 0; + const total = successful + rateLimited; + + // Print summary to console + console.log(`\n========== Rate Limit Test Summary ==========`); + console.log(`Expected Limit: ${EXPECTED_LIMIT} requests per ${WINDOW_SECONDS}s`); + console.log(`Total Requests: ${total}`); + console.log(`Successful (200): ${successful}`); + console.log(`Rate Limited (429): ${rateLimited}`); + console.log(`Accuracy: ${successful <= EXPECTED_LIMIT ? 'PASS' : 'FAIL'}`); + console.log(`==============================================\n`); + + // Return object specifying output files + // Keys are file paths, values are content to write + return { + '/results/rate-limit-test-result.html': htmlReport(data), + '/results/rate-limit-test-result.json': JSON.stringify(data, null, 2), + }; +} diff --git a/docker/k6/scripts/stress-test.js b/docker/k6/scripts/stress-test.js new file mode 100644 index 0000000..a552375 --- /dev/null +++ b/docker/k6/scripts/stress-test.js @@ -0,0 +1,140 @@ +/** + * FluxGate Stress Test (1 Million Requests) + * + * This script tests the rate limiter's stability under high load. + * It runs 1,000,000 total requests using concurrent virtual users to verify: + * 1. System remains responsive under sustained load + * 2. Rate limiting works correctly with many concurrent users + * 3. No unexpected errors or memory leaks occur + * + * Configuration: + * - Total Requests: 1,000,000 + * - Virtual Users: 100 concurrent + * - Iterations per VU: 10,000 + * + * Usage: + * docker exec fluxgate-k6 k6 run /scripts/stress-test.js + * + * Environment Variables: + * - BASE_URL: Target server URL (default: http://localhost:8085) + * - VUS: Number of virtual users (default: 100) + * - ITERATIONS: Iterations per VU (default: 10000) + */ + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Counter, Rate } from 'k6/metrics'; +import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js'; + +// ============================================================================= +// Custom Metrics +// ============================================================================= +// Track request outcomes separately for detailed analysis + +const rateLimitedRequests = new Counter('rate_limited_requests'); // HTTP 429 responses +const successfulRequests = new Counter('successful_requests'); // HTTP 200 responses +const errorRequests = new Counter('error_requests'); // Other status codes (errors) + +// ============================================================================= +// Configuration +// ============================================================================= + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8085'; +const VUS = parseInt(__ENV.VUS) || 100; // 100 virtual users +const ITERATIONS = parseInt(__ENV.ITERATIONS) || 10000; // 10,000 iterations per VU = 1M total + +// ============================================================================= +// Test Options +// ============================================================================= + +export const options = { + scenarios: { + stress_test: { + // 'per-vu-iterations': Each VU runs exactly N iterations + // Total requests = VUS * ITERATIONS = 100 * 10,000 = 1,000,000 + executor: 'per-vu-iterations', + + // Number of concurrent virtual users + vus: VUS, + + // Each VU runs this many iterations + iterations: ITERATIONS, + + // Maximum time allowed (adjust if needed for slower systems) + maxDuration: '30m', + }, + }, + + // Pass/fail thresholds + thresholds: { + // 95% of requests should complete within 1 second + http_req_duration: ['p(95)<1000'], + + // Less than 1% of requests should fail (network errors, timeouts) + // Note: HTTP 429 is NOT counted as a failure here + http_req_failed: ['rate<0.01'], + }, +}; + +// ============================================================================= +// Main Test Function +// ============================================================================= +// Each VU executes this function repeatedly + +export default function () { + // Send request + const res = http.get(`${BASE_URL}/api/test`); + + // Categorize response and increment appropriate counter + if (res.status === 200) { + successfulRequests.add(1); + } else if (res.status === 429) { + // Rate limited - this is expected behavior, not an error + rateLimitedRequests.add(1); + } else { + // Unexpected status (5xx, connection errors, etc.) + errorRequests.add(1); + } + + // Verify expected behavior + check(res, { + // Valid responses are either success (200) or rate-limited (429) + 'status is 200 or 429': (r) => r.status === 200 || r.status === 429, + + // Individual request should be fast + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + // No sleep for maximum throughput in stress test + // Remove or reduce this value to increase request rate +} + +// ============================================================================= +// Summary Handler +// ============================================================================= + +export function handleSummary(data) { + // Extract metrics + const successful = data.metrics.successful_requests?.values?.count || 0; + const rateLimited = data.metrics.rate_limited_requests?.values?.count || 0; + const errors = data.metrics.error_requests?.values?.count || 0; + const total = successful + rateLimited + errors; + + // Calculate error rate (excluding rate-limited responses) + const errorRate = total > 0 ? ((errors / total) * 100).toFixed(2) : '0.00'; + + // Print human-readable summary + console.log(`\n========== Stress Test Summary ==========`); + console.log(`Total Requests: ${total}`); + console.log(`Successful (200): ${successful}`); + console.log(`Rate Limited (429): ${rateLimited}`); + console.log(`Errors: ${errors}`); + console.log(`Error Rate: ${errorRate}%`); + console.log(`==========================================\n`); + + // Generate output files + return { + '/results/stress-test-result.html': htmlReport(data), + '/results/stress-test-result.json': JSON.stringify(data, null, 2), + }; +} diff --git a/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java b/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java index 9f7515f..5849ced 100644 --- a/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java +++ b/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java @@ -200,6 +200,8 @@ private void handleRateLimitExceeded(HttpServletResponse response, RateLimitResp throws IOException { long retryAfterSeconds = (result.getRetryAfterMillis() + 999) / 1000; response.setHeader(Headers.RETRY_AFTER, String.valueOf(retryAfterSeconds)); + response.setHeader( + Headers.RATE_LIMIT_REMAINING, String.valueOf(Math.max(0, result.getRemainingTokens()))); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType("application/json"); response diff --git a/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java b/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java index adc9db7..223c6b0 100644 --- a/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java +++ b/fluxgate-spring-boot2-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java @@ -420,6 +420,8 @@ private void handleRateLimitExceeded(HttpServletResponse response, RateLimitResp long retryAfterSeconds = (result.getRetryAfterMillis() + 999) / 1000; response.setHeader(Headers.RETRY_AFTER, String.valueOf(retryAfterSeconds)); + response.setHeader( + Headers.RATE_LIMIT_REMAINING, String.valueOf(Math.max(0, result.getRemainingTokens()))); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType("application/json"); diff --git a/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java b/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java index 67afdfc..dc262be 100644 --- a/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java +++ b/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/aop/RateLimitAspect.java @@ -200,6 +200,8 @@ private void handleRateLimitExceeded(HttpServletResponse response, RateLimitResp throws IOException { long retryAfterSeconds = (result.getRetryAfterMillis() + 999) / 1000; response.setHeader(Headers.RETRY_AFTER, String.valueOf(retryAfterSeconds)); + response.setHeader( + Headers.RATE_LIMIT_REMAINING, String.valueOf(Math.max(0, result.getRemainingTokens()))); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType("application/json"); response diff --git a/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java b/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java index 6532974..1f6819b 100644 --- a/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java +++ b/fluxgate-spring-boot3-starter/src/main/java/org/fluxgate/spring/filter/FluxgateRateLimitFilter.java @@ -420,6 +420,8 @@ private void handleRateLimitExceeded(HttpServletResponse response, RateLimitResp long retryAfterSeconds = (result.getRetryAfterMillis() + 999) / 1000; response.setHeader(Headers.RETRY_AFTER, String.valueOf(retryAfterSeconds)); + response.setHeader( + Headers.RATE_LIMIT_REMAINING, String.valueOf(Math.max(0, result.getRemainingTokens()))); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType("application/json");