Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
276 lines
7.1 KiB
Go
276 lines
7.1 KiB
Go
// Command api-load-smoke: short, low-concurrency HTTP smoke against critical API paths.
|
|
//
|
|
// Default profile is intentionally gentle (local laptop / shared Postgres).
|
|
// It is NOT a capacity test — see README.md for larger profiles and RPS assumptions.
|
|
//
|
|
// Usage (API listening — default HTTP_ADDR :28471):
|
|
//
|
|
// cd scripts/api-load-smoke && go run .
|
|
// go run . -base http://127.0.0.1:28471 -c 2 -d 5s -rate 6
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const demoAPIKey = "dk_demo_local_descrybe_test_key_v1"
|
|
|
|
// Smoke defaults — capped aggregate RPS so local Postgres is not hammered.
|
|
const (
|
|
defaultConcurrency = 2
|
|
defaultDuration = 5 * time.Second
|
|
defaultTimeout = 10 * time.Second
|
|
defaultRateRPS = 6.0 // aggregate across all workers/endpoints
|
|
defaultLimitQuery = "limit=10&offset=0"
|
|
)
|
|
|
|
type endpoint struct {
|
|
name string
|
|
path string
|
|
auth bool
|
|
}
|
|
|
|
type stats struct {
|
|
ok atomic.Int64
|
|
fail atomic.Int64
|
|
latMu sync.Mutex
|
|
latNanos []int64
|
|
}
|
|
|
|
func (s *stats) record(ok bool, d time.Duration) {
|
|
if ok {
|
|
s.ok.Add(1)
|
|
} else {
|
|
s.fail.Add(1)
|
|
}
|
|
s.latMu.Lock()
|
|
s.latNanos = append(s.latNanos, d.Nanoseconds())
|
|
s.latMu.Unlock()
|
|
}
|
|
|
|
func (s *stats) snapshot() (ok, fail int64, min, avg, p95, max time.Duration) {
|
|
ok, fail = s.ok.Load(), s.fail.Load()
|
|
s.latMu.Lock()
|
|
defer s.latMu.Unlock()
|
|
n := len(s.latNanos)
|
|
if n == 0 {
|
|
return ok, fail, 0, 0, 0, 0
|
|
}
|
|
cp := append([]int64(nil), s.latNanos...)
|
|
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
|
|
var sum int64
|
|
for _, v := range cp {
|
|
sum += v
|
|
}
|
|
min = time.Duration(cp[0])
|
|
max = time.Duration(cp[n-1])
|
|
avg = time.Duration(sum / int64(n))
|
|
idx := (n * 95) / 100
|
|
if idx >= n {
|
|
idx = n - 1
|
|
}
|
|
p95 = time.Duration(cp[idx])
|
|
return ok, fail, min, avg, p95, max
|
|
}
|
|
|
|
func main() {
|
|
base := flag.String("base", envOr("API_BASE", "http://127.0.0.1:28471"), "API base URL (no trailing slash)")
|
|
key := flag.String("key", envOr("API_KEY", demoAPIKey), "API key for /api/v1 feeds+products (Bearer)")
|
|
c := flag.Int("c", defaultConcurrency, "worker concurrency (smoke: 2; load: raise carefully)")
|
|
d := flag.Duration("d", defaultDuration, "test duration (smoke: 5s)")
|
|
rate := flag.Float64("rate", defaultRateRPS, "max aggregate requests/sec (0 = unlimited; smoke default 6)")
|
|
timeout := flag.Duration("timeout", defaultTimeout, "per-request timeout")
|
|
mode := flag.String("mode", "smoke", "smoke|load (banner + safety hint; still honor -c/-d/-rate)")
|
|
failOnErr := flag.Bool("fail-on-error", true, "exit 1 if any request fails")
|
|
flag.Parse()
|
|
|
|
if *c < 1 {
|
|
fmt.Fprintln(os.Stderr, "-c must be >= 1")
|
|
os.Exit(2)
|
|
}
|
|
if *d <= 0 {
|
|
fmt.Fprintln(os.Stderr, "-d must be > 0")
|
|
os.Exit(2)
|
|
}
|
|
if *rate < 0 {
|
|
fmt.Fprintln(os.Stderr, "-rate must be >= 0")
|
|
os.Exit(2)
|
|
}
|
|
if strings.EqualFold(*mode, "smoke") && *rate == 0 {
|
|
fmt.Fprintln(os.Stderr, "refusing -rate 0 in smoke mode (would DDOS local stack); use -mode load -rate 0 intentionally")
|
|
os.Exit(2)
|
|
}
|
|
|
|
baseURL := strings.TrimRight(strings.TrimSpace(*base), "/")
|
|
endpoints := []endpoint{
|
|
{name: "healthz", path: "/healthz", auth: false},
|
|
{name: "feeds", path: "/api/v1/feeds?" + defaultLimitQuery, auth: true},
|
|
{name: "products", path: "/api/v1/products?" + defaultLimitQuery, auth: true},
|
|
}
|
|
|
|
client := &http.Client{Timeout: *timeout}
|
|
st := make([]*stats, len(endpoints))
|
|
for i := range st {
|
|
st[i] = &stats{}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), *d)
|
|
defer cancel()
|
|
|
|
tokens := startRateGate(ctx, *rate)
|
|
|
|
fmt.Printf("api-load-smoke mode=%s base=%s c=%d d=%s rate=%.1f\n", *mode, baseURL, *c, d.String(), *rate)
|
|
fmt.Println("ASSUMPTION: local smoke defaults to ~6 aggregate RPS (c=2, 5s); raise -rate/-c only on dedicated/staging.")
|
|
if strings.EqualFold(*mode, "load") {
|
|
fmt.Println("NOTE: prefer staging. GET lists are not under RateLimitV1Process (mutations only); DB is still the bottleneck.")
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
start := time.Now()
|
|
for w := 0; w < *c; w++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
i := 0
|
|
for {
|
|
if !waitToken(ctx, tokens) {
|
|
return
|
|
}
|
|
ep := endpoints[i%len(endpoints)]
|
|
si := st[i%len(endpoints)]
|
|
i++
|
|
ok, lat, cancelled := doRequest(ctx, client, baseURL, *key, ep)
|
|
if cancelled {
|
|
return
|
|
}
|
|
si.record(ok, lat)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
elapsed := time.Since(start)
|
|
|
|
var totalOK, totalFail int64
|
|
fmt.Println()
|
|
fmt.Printf("%-10s %8s %8s %10s %10s %10s %10s\n", "endpoint", "ok", "fail", "min", "avg", "p95", "max")
|
|
for i, ep := range endpoints {
|
|
ok, fail, min, avg, p95, max := st[i].snapshot()
|
|
totalOK += ok
|
|
totalFail += fail
|
|
fmt.Printf("%-10s %8d %8d %10s %10s %10s %10s\n",
|
|
ep.name, ok, fail, fmtDur(min), fmtDur(avg), fmtDur(p95), fmtDur(max))
|
|
}
|
|
total := totalOK + totalFail
|
|
rps := 0.0
|
|
if elapsed > 0 {
|
|
rps = float64(total) / elapsed.Seconds()
|
|
}
|
|
fmt.Printf("\ntotal_ok=%d total_fail=%d elapsed=%s approx_rps=%.1f\n", totalOK, totalFail, elapsed.Round(time.Millisecond), rps)
|
|
|
|
if totalFail > 0 && *failOnErr {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// startRateGate returns nil for unlimited; otherwise a token channel paced at rate RPS.
|
|
func startRateGate(ctx context.Context, rate float64) <-chan struct{} {
|
|
if rate <= 0 {
|
|
return nil
|
|
}
|
|
ch := make(chan struct{})
|
|
interval := time.Duration(float64(time.Second) / rate)
|
|
if interval < time.Millisecond {
|
|
interval = time.Millisecond
|
|
}
|
|
go func() {
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
// First token immediately so smoke does not wait a full interval.
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case ch <- struct{}{}:
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case ch <- struct{}{}:
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return ch
|
|
}
|
|
|
|
func waitToken(ctx context.Context, tokens <-chan struct{}) bool {
|
|
if tokens == nil {
|
|
return ctx.Err() == nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-tokens:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func doRequest(ctx context.Context, client *http.Client, base, key string, ep endpoint) (ok bool, lat time.Duration, cancelled bool) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+ep.path, nil)
|
|
if err != nil {
|
|
return false, 0, ctx.Err() != nil
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if ep.auth {
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
}
|
|
t0 := time.Now()
|
|
res, err := client.Do(req)
|
|
lat = time.Since(t0)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return false, lat, true
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%s error: %v\n", ep.name, err)
|
|
return false, lat, false
|
|
}
|
|
defer res.Body.Close()
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
fmt.Fprintf(os.Stderr, "%s status=%d\n", ep.name, res.StatusCode)
|
|
return false, lat, false
|
|
}
|
|
return true, lat, false
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func fmtDur(d time.Duration) string {
|
|
if d <= 0 {
|
|
return "-"
|
|
}
|
|
if d < time.Millisecond {
|
|
return d.Round(time.Microsecond).String()
|
|
}
|
|
return d.Round(time.Millisecond).String()
|
|
}
|