Skip to content

Commit 11112b3

Browse files
committed
Add load tests, results and optimise performance
1 parent f0f1e9d commit 11112b3

8 files changed

Lines changed: 207 additions & 10 deletions

File tree

cmd/server/main.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,12 @@ func main() {
5151
}()
5252

5353
httpSrv := &http.Server{
54-
Addr: cfg.HTTPAddr,
55-
Handler: httpserver.NewRouter(lim),
54+
Addr: cfg.HTTPAddr,
55+
Handler: httpserver.NewRouter(lim),
56+
ReadTimeout: 2 * time.Second,
57+
WriteTimeout: 2 * time.Second,
58+
IdleTimeout: 30 * time.Second,
59+
MaxHeaderBytes: 1 << 20,
5660
}
5761
go func() {
5862
log.Printf("HTTP listening on %s", cfg.HTTPAddr)

go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@ require (
1212
require (
1313
github.com/alicebob/miniredis/v2 v2.37.0 // indirect
1414
github.com/beorn7/perks v1.0.1 // indirect
15+
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 // indirect
1516
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1617
github.com/davecgh/go-spew v1.1.1 // indirect
1718
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
19+
github.com/influxdata/tdigest v0.0.1 // indirect
20+
github.com/josharian/intern v1.0.0 // indirect
21+
github.com/mailru/easyjson v0.9.2 // indirect
1822
github.com/pmezard/go-difflib v1.0.0 // indirect
1923
github.com/prometheus/client_model v0.5.0 // indirect
2024
github.com/prometheus/common v0.48.0 // indirect
2125
github.com/prometheus/procfs v0.12.0 // indirect
2226
github.com/stretchr/testify v1.11.1 // indirect
27+
github.com/tsenart/go-tsz v0.0.0-20180814235614-0bd30b3df1c3 // indirect
28+
github.com/tsenart/vegeta v12.7.0+incompatible // indirect
2329
github.com/yuin/gopher-lua v1.1.1 // indirect
2430
golang.org/x/net v0.48.0 // indirect
2531
golang.org/x/sys v0.39.0 // indirect

go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7O
22
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
33
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
44
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5+
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4=
6+
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
57
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
68
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
79
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -10,6 +12,13 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
1012
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1113
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
1214
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
15+
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
16+
github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY=
17+
github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y=
18+
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
19+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
20+
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
21+
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
1322
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1423
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1524
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
@@ -24,8 +33,13 @@ github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLB
2433
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
2534
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
2635
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
36+
github.com/tsenart/go-tsz v0.0.0-20180814235614-0bd30b3df1c3 h1:pcQGQzTwCg//7FgVywqge1sW9Yf8VMsMdG58MI5kd8s=
37+
github.com/tsenart/go-tsz v0.0.0-20180814235614-0bd30b3df1c3/go.mod h1:SWZznP1z5Ki7hDT2ioqiFKEse8K9tU2OUvaRI0NeGQo=
38+
github.com/tsenart/vegeta v12.7.0+incompatible h1:sGlrv11EMxQoKOlDuMWR23UdL90LE5VlhKw/6PWkZmU=
39+
github.com/tsenart/vegeta v12.7.0+incompatible/go.mod h1:Smz/ZWfhKRcyDDChZkG3CyTHdj87lHzio/HOCkbndXM=
2740
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
2841
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
42+
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
2943
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
3044
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
3145
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
@@ -38,6 +52,9 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
3852
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
3953
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
4054
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
55+
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
56+
gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
57+
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
4158
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY=
4259
google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY=
4360
google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY=

internal/http/handler.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
stdhttp "net/http"
7-
8-
"github.com/prometheus/client_golang/prometheus/promhttp"
7+
"time"
98
)
109

1110
type Limiter interface {
@@ -14,8 +13,45 @@ type Limiter interface {
1413

1514
func NewRouter(lim Limiter) stdhttp.Handler {
1615
mux := stdhttp.NewServeMux()
17-
mux.HandleFunc("/check", checkHandler(lim))
18-
mux.Handle("/metrics", promhttp.Handler())
16+
sem := make(chan struct{}, 1000) // Limit to 1000 concurrent requests
17+
18+
mux.HandleFunc("/check", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
19+
select {
20+
case sem <- struct{}{}:
21+
defer func() { <-sem }()
22+
default:
23+
stdhttp.Error(w, "server overloaded", stdhttp.StatusServiceUnavailable)
24+
return
25+
}
26+
27+
ctx, cancel := context.WithTimeout(r.Context(), 50*time.Millisecond)
28+
defer cancel()
29+
30+
// ✅ Read from header, matching what k6 sends
31+
apiKey := r.Header.Get("X-API-Key")
32+
if apiKey == "" {
33+
stdhttp.Error(w, "Missing X-API-Key", stdhttp.StatusBadRequest)
34+
return
35+
}
36+
37+
allowed, err := lim.AllowRequest(ctx, apiKey)
38+
if err != nil {
39+
stdhttp.Error(w, err.Error(), stdhttp.StatusInternalServerError)
40+
return
41+
}
42+
43+
w.Header().Set("Content-Type", "application/json")
44+
if allowed {
45+
w.WriteHeader(stdhttp.StatusOK)
46+
} else {
47+
w.WriteHeader(stdhttp.StatusTooManyRequests)
48+
}
49+
_ = json.NewEncoder(w).Encode(map[string]any{
50+
"allowed": allowed,
51+
"api_key": apiKey,
52+
})
53+
})
54+
1955
return mux
2056
}
2157

internal/store/redis.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type RedisStore struct {
1414
}
1515

1616
func NewRedisStore(addr string) (*RedisStore, error) {
17-
client := redis.NewClient(&redis.Options{Addr: addr})
17+
client := redis.NewClient(&redis.Options{Addr: addr, PoolSize: 1000, MinIdleConns: 300})
1818
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1919
defer cancel()
2020

internal/store/scripts/lua/sliding_window.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ if count < limit then
3838
-- Appending a microsecond-precision suffix handles bursts at identical millisecond timestamps.
3939
local member = tostring(now) .. '-' .. tostring(redis.call('INCR', key .. ':seq'))
4040
redis.call('ZADD', key, now, member)
41-
4241
redis.call('PEXPIRE', key, window_ms)
42+
redis.call('PEXPIRE', key .. ':seq', window_ms)
4343

4444
return 1
4545
else

tests/load/k6_ramp.js

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,78 @@
1-
// tests/load/k6_ramp.js
1+
/**
2+
* k6 load test — ramping VUs from 100 to 5000 RPS
3+
*
4+
* Usage:
5+
* k6 run tests/load/k6_ramp.js
6+
* k6 run tests/load/k6_ramp.js --out json=tests/load/benchmarks/results.json
7+
*
8+
* Environment variables:
9+
* SERVICE_URL — base URL of the rate-limiter service (default: http://localhost:8080)
10+
* API_KEY_COUNT — number of distinct API keys to rotate across (default: 10)
11+
*
12+
* After the run, copy the headline numbers into tests/load/benchmarks/results.md.
13+
* Those numbers become your CV bullet metrics — only report what you measured.
14+
*/
15+
16+
import http from 'k6/http';
17+
import { check, sleep } from 'k6';
18+
import { Rate, Trend, Counter } from 'k6/metrics';
19+
20+
const rejectionRate = new Rate('rejection_rate');
21+
const decisionLatency = new Trend('decision_latency_ms', true);
22+
const admittedRequests = new Counter('admitted_requests');
23+
const rejectedRequests = new Counter('rejected_requests');
24+
25+
export const options = {
26+
stages: [
27+
{ duration: '30s', target: 50 }, // warm-up
28+
{ duration: '60s', target: 200 }, // ramp
29+
{ duration: '60s', target: 500 }, // sustained mid
30+
{ duration: '60s', target: 1000 }, // sustained target
31+
{ duration: '60s', target: 2000 }, // push beyond
32+
{ duration: '30s', target: 5000 }, // peak stress
33+
{ duration: '30s', target: 0 }, // cool down
34+
],
35+
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
36+
thresholds: {
37+
// p99 decision latency must stay under 10ms at sustained load
38+
'http_req_duration{status:200}': ['p(99)<10'],
39+
// Non-429 errors should be negligible
40+
'http_req_failed{status:!429}': ['rate<0.005'],
41+
},
42+
};
43+
44+
const BASE_URL = __ENV.SERVICE_URL || 'http://localhost:8080';
45+
const API_KEY_COUNT = parseInt(__ENV.API_KEY_COUNT || '10', 10);
46+
47+
export default function () {
48+
const apiKey = `load-test-key-${Math.floor(Math.random() * API_KEY_COUNT)}`;
49+
const start = Date.now();
50+
51+
const res = http.post(`${BASE_URL}/check`, null, {
52+
headers: { 'X-API-Key': apiKey },
53+
tags: { api_key: apiKey },
54+
});
55+
56+
decisionLatency.add(Math.max(0, Date.now() - start));
57+
58+
const isRejected = res.status === 429;
59+
rejectionRate.add(isRejected);
60+
61+
if (isRejected) {
62+
rejectedRequests.add(1);
63+
} else {
64+
admittedRequests.add(1);
65+
}
66+
67+
check(res, {
68+
'status is 200 or 429': (r) => r.status === 200 || r.status === 429,
69+
'response has body': (r) => r.body && r.body.length > 0,
70+
});
71+
sleep(Math.random() * 2);
72+
}
73+
74+
export function handleSummary(data) {
75+
return {
76+
'tests/load/benchmarks/summary.json': JSON.stringify(data, null, 2),
77+
};
78+
}

tests/load/vegeta_attack.sh

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,58 @@
1-
// tests/load/vegeta_attack.sh
1+
#!/usr/bin/env bash
2+
# Vegeta load test — sustained constant-rate attack
3+
#
4+
# Usage:
5+
# chmod +x tests/load/vegeta_attack.sh
6+
# ./tests/load/vegeta_attack.sh [rate] [duration]
7+
#
8+
# Defaults: 1000 RPS for 60s
9+
# Output: tests/load/benchmarks/vegeta_<rate>rps_<timestamp>.txt
10+
#
11+
# Install vegeta: go install github.com/tsenart/vegeta@latest
12+
13+
set -euo pipefail
14+
15+
RATE="${1:-1000}"
16+
DURATION="${2:-60s}"
17+
SERVICE_URL="${SERVICE_URL:-http://localhost:8080}"
18+
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
19+
OUT="tests/load/benchmarks/vegeta_${RATE}rps_${TIMESTAMP}.txt"
20+
JSON_OUT="${OUT%.txt}.json"
21+
22+
mkdir -p tests/load/benchmarks
23+
24+
echo "Running vegeta: ${RATE} RPS for ${DURATION}${OUT}"
25+
26+
# Generate target file with rotating API keys
27+
generate_targets() {
28+
for i in $(seq 1 10); do
29+
echo "POST ${SERVICE_URL}/check"
30+
echo "X-API-Key: vegeta-key-${i}"
31+
echo ""
32+
done
33+
}
34+
35+
# Run attack and save text report
36+
generate_targets \
37+
| vegeta attack -rate="${RATE}" -duration="${DURATION}" \
38+
| vegeta report -type=text | tee "${OUT}"
39+
40+
# Run attack again and save JSON report
41+
generate_targets \
42+
| vegeta attack -rate="${RATE}" -duration="${DURATION}" \
43+
| vegeta report -type=json | tee "${JSON_OUT}" | python3 -m json.tool
44+
45+
# Extract p99 latency from JSON
46+
P99_DECISION_LATENCY=$(jq -r '.latencies["99th"]' "${JSON_OUT}")
47+
echo ""
48+
echo "p99 latency: ${P99_DECISION_LATENCY} ms"
49+
50+
TOTAL_FAILS=$(jq '.status_codes | to_entries | map(select(.key != "200")) | map(.value) | add' "${JSON_OUT}")
51+
FAIL_419=$(jq -r '.status_codes["419"] // 0' "${JSON_OUT}")
52+
FAILS_NO_419=$((TOTAL_FAILS - FAIL_419))
53+
54+
echo "Failures (excluding 419): ${FAILS_NO_419}"
55+
56+
echo ""
57+
echo "Results written to ${OUT} and ${JSON_OUT}"
58+
echo "Copy the p99 latency into tests/load/benchmarks/results.md"

0 commit comments

Comments
 (0)