Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,7 @@ replay_pid*
*.temp

# Secret
private.key
private.key

# k6-result
docker/k6/results
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
23 changes: 23 additions & 0 deletions docker/k6/README.md
Original file line number Diff line number Diff line change
@@ -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
```

28 changes: 28 additions & 0 deletions docker/k6/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
147 changes: 147 additions & 0 deletions docker/k6/scripts/rate-limit-test.js
Original file line number Diff line number Diff line change
@@ -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),
};
}
140 changes: 140 additions & 0 deletions docker/k6/scripts/stress-test.js
Original file line number Diff line number Diff line change
@@ -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),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down