From 60785a52f55eb57a56cbfe1a46eee7c2c8859ee2 Mon Sep 17 00:00:00 2001 From: Cast AID Agent Date: Thu, 12 Mar 2026 10:19:20 +0100 Subject: [PATCH] Add SMS API load testing service for Cast Go service that load tests the Cast SMS/OTP API with configurable concurrent threads, target RPS, ramp-up period, and message parts. Features live stats, final report with SMS parts tracking, and config warnings for suboptimal test parameters. Co-Authored-By: Claude Opus 4.6 --- .env.example | 14 ++++ .gitignore | 2 + go.mod | 5 ++ go.sum | 2 + internal/config/config.go | 59 +++++++++++++++ internal/runner/runner.go | 128 +++++++++++++++++++++++++++++++ internal/sender/sender.go | 154 ++++++++++++++++++++++++++++++++++++++ internal/stats/stats.go | 116 ++++++++++++++++++++++++++++ main.go | 11 +++ 9 files changed, 491 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/runner/runner.go create mode 100644 internal/sender/sender.go create mode 100644 internal/stats/stats.go create mode 100644 main.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9454f47 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# SMS API Configuration +SMS_API_URL=https://api.cast.ph/api/sms/send +SMS_API_KEY=cast_your_api_key_here +SMS_SENDER_ID=CAST +SMS_RECIPIENT=09171234567 + +# Message Configuration +SMS_MESSAGE_PARTS=1 + +# Load Test Parameters +LOAD_TEST_THREADS=10 +LOAD_TEST_RPS=50 +LOAD_TEST_DURATION_SECS=30 +LOAD_TEST_RAMP_UP_SECS=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..579c657 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +/bin/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb5b033 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module cast-loadtest + +go 1.22 + +require github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..96deb91 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + APIURL string + APIKey string + SenderID string + Recipient string + MsgParts int + Threads int + RPS int + DurationSec int + RampUpSec int +} + +func Load() Config { + if err := godotenv.Load(); err != nil { + log.Println("Warning: .env file not found, using environment variables") + } + + return Config{ + APIURL: mustEnv("SMS_API_URL"), + APIKey: mustEnv("SMS_API_KEY"), + SenderID: mustEnv("SMS_SENDER_ID"), + Recipient: mustEnv("SMS_RECIPIENT"), + MsgParts: envInt("SMS_MESSAGE_PARTS", 1), + Threads: envInt("LOAD_TEST_THREADS", 10), + RPS: envInt("LOAD_TEST_RPS", 50), + DurationSec: envInt("LOAD_TEST_DURATION_SECS", 30), + RampUpSec: envInt("LOAD_TEST_RAMP_UP_SECS", 5), + } +} + +func mustEnv(key string) string { + val := os.Getenv(key) + if val == "" { + log.Fatalf("Required environment variable %s is not set", key) + } + return val +} + +func envInt(key string, defaultVal int) int { + val := os.Getenv(key) + if val == "" { + return defaultVal + } + n, err := strconv.Atoi(val) + if err != nil { + log.Fatalf("Invalid integer for %s: %s", key, val) + } + return n +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..71d3a46 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,128 @@ +package runner + +import ( + "fmt" + "sync" + "time" + + "cast-loadtest/internal/config" + "cast-loadtest/internal/sender" + "cast-loadtest/internal/stats" +) + +func Run(cfg config.Config) { + fmt.Println("========================================") + fmt.Println(" SMS API LOAD TESTER (Go) ") + fmt.Println("========================================") + fmt.Println() + fmt.Println("--- API Configuration ---") + fmt.Printf(" Target: %s\n", cfg.APIURL) + fmt.Printf(" API Key: %s...%s\n", cfg.APIKey[:9], cfg.APIKey[len(cfg.APIKey)-4:]) + fmt.Printf(" Sender ID: %s\n", cfg.SenderID) + fmt.Printf(" Recipient: %s\n", cfg.Recipient) + fmt.Println() + fmt.Println("--- Message Configuration ---") + fmt.Printf(" SMS Parts: %d\n", cfg.MsgParts) + fmt.Printf(" Max Chars: %d\n", cfg.MsgParts*140) + fmt.Println() + fmt.Println("--- Test Parameters ---") + fmt.Printf(" Threads: %d\n", cfg.Threads) + fmt.Printf(" Target RPS: %d\n", cfg.RPS) + fmt.Printf(" Duration: %ds\n", cfg.DurationSec) + fmt.Printf(" Ramp-up: %ds\n", cfg.RampUpSec) + fmt.Println() + + // Warnings + hasWarning := false + if cfg.RampUpSec >= cfg.DurationSec { + fmt.Println("WARNING: Ramp-up >= duration. The test will never reach full RPS.") + fmt.Printf(" Reduce LOAD_TEST_RAMP_UP_SECS (currently %ds) below duration (%ds).\n", cfg.RampUpSec, cfg.DurationSec) + hasWarning = true + } + // Estimate: need threads >= RPS * avg_latency_secs. Assume ~500ms avg latency. + estimatedLatency := 0.5 + minThreads := int(float64(cfg.RPS)*estimatedLatency) + 1 + if cfg.Threads < minThreads { + fmt.Printf("WARNING: Threads (%d) may be too low for target RPS (%d).\n", cfg.Threads, cfg.RPS) + fmt.Printf(" Recommended: at least %d threads (based on ~%.0fms estimated latency).\n", minThreads, estimatedLatency*1000) + hasWarning = true + } + if hasWarning { + fmt.Println() + } + + fmt.Println("========================================") + fmt.Println() + + client := sender.NewHTTPClient(cfg.Threads) + st := &stats.Stats{} + duration := time.Duration(cfg.DurationSec) * time.Second + rampUp := time.Duration(cfg.RampUpSec) * time.Second + + // Ticker for live stats + statsTicker := time.NewTicker(500 * time.Millisecond) + defer statsTicker.Stop() + + testStart := time.Now() + done := make(chan struct{}) + + // Live stats printer + go func() { + for { + select { + case <-statsTicker.C: + st.PrintLive(time.Since(testStart)) + case <-done: + return + } + } + }() + + // Rate limiter with ramp-up + var wg sync.WaitGroup + sem := make(chan struct{}, cfg.Threads) + + targetRPS := cfg.RPS + ticker := time.NewTicker(time.Second / time.Duration(targetRPS)) + defer ticker.Stop() + + fmt.Println("Starting load test...") + fmt.Println() + + for { + elapsed := time.Since(testStart) + if elapsed >= duration { + break + } + + // Ramp-up: scale RPS linearly + currentRPS := targetRPS + if elapsed < rampUp && rampUp > 0 { + rampFraction := float64(elapsed) / float64(rampUp) + currentRPS = int(float64(targetRPS) * rampFraction) + if currentRPS < 1 { + currentRPS = 1 + } + ticker.Reset(time.Second / time.Duration(currentRPS)) + } else if elapsed >= rampUp { + ticker.Reset(time.Second / time.Duration(targetRPS)) + } + + <-ticker.C + + sem <- struct{}{} + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-sem }() + sender.SendSMS(client, cfg, st) + }() + } + + // Wait for in-flight requests + fmt.Println("\n\nWaiting for in-flight requests to complete...") + wg.Wait() + close(done) + + st.PrintFinalReport(time.Since(testStart)) +} diff --git a/internal/sender/sender.go b/internal/sender/sender.go new file mode 100644 index 0000000..2c8dfa8 --- /dev/null +++ b/internal/sender/sender.go @@ -0,0 +1,154 @@ +package sender + +import ( + "bytes" + "encoding/json" + "io" + "log" + "math/rand" + "net/http" + "strings" + "time" + + "cast-loadtest/internal/config" + "cast-loadtest/internal/stats" +) + +type SMSPayload struct { + Destination string `json:"destination"` + Message string `json:"message"` + SenderID string `json:"sender_id,omitempty"` +} + +var poemLines = []string{ + "The sun sets low on distant hills,", + "A river hums and slowly spills,", + "The stars align in silver rows,", + "A gentle wind forever blows,", + "Through ancient woods the shadows creep,", + "Where mountains rise and valleys sleep,", + "The moon ascends with quiet grace,", + "And lights the earth from outer space,", + "A bird sings out its evening call,", + "While autumn leaves begin to fall,", + "The ocean roars with timeless might,", + "Beneath the cloak of endless night,", + "A flame ignites in frozen lands,", + "Where time slips through like grains of sand,", + "The dawn arrives on golden wings,", + "And with it hope and promise brings,", + "A flower blooms in morning dew,", + "Its petals kissed by skies of blue,", + "The thunder rolls across the plain,", + "A world reborn through summer rain,", + "The forest whispers ancient tales,", + "Of ships that sailed through mighty gales,", + "A lighthouse stands on rocky shores,", + "Its beacon bright forevermore,", + "The snow falls soft on silent ground,", + "A hush descends without a sound,", + "The wolf howls at the crescent moon,", + "A wanderer hums a broken tune,", + "The clock ticks on through dust and age,", + "Each moment writes another page,", +} + +func NewHTTPClient(threads int) *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: threads * 2, + MaxIdleConnsPerHost: threads * 2, + IdleConnTimeout: 90 * time.Second, + }, + } +} + +func SendSMS(client *http.Client, cfg config.Config, st *stats.Stats) { + payload := SMSPayload{ + Destination: cfg.Recipient, + Message: generateMessage(cfg.MsgParts), + SenderID: cfg.SenderID, + } + + body, err := json.Marshal(payload) + if err != nil { + st.FailCount.Add(1) + st.TotalRequests.Add(1) + log.Printf("ERROR marshal: %v", err) + return + } + + req, err := http.NewRequest("POST", cfg.APIURL, bytes.NewReader(body)) + if err != nil { + st.FailCount.Add(1) + st.TotalRequests.Add(1) + log.Printf("ERROR request: %v", err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", cfg.APIKey) + + start := time.Now() + resp, err := client.Do(req) + latencyMs := time.Since(start).Milliseconds() + + st.TotalRequests.Add(1) + st.RecordLatency(latencyMs) + + if err != nil { + st.FailCount.Add(1) + log.Printf("ERROR send: %v (latency: %dms)", err, latencyMs) + return + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + st.RecordStatusCode(resp.StatusCode) + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + st.SuccessCount.Add(1) + // Parse response to extract SMS parts + var apiResp struct { + Parts int `json:"parts"` + } + if json.Unmarshal(respBody, &apiResp) == nil && apiResp.Parts > 0 { + st.TotalSMSParts.Add(int64(apiResp.Parts)) + } + } else { + st.FailCount.Add(1) + log.Printf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } +} + +// generateMessage builds a message sized to fit within the target SMS parts. +// GSM-7: single part = 160 chars, multipart = 153 chars/part. +// Square brackets cost 2 chars each in GSM-7, so we use "LOADTEST:" instead. +// We cap at 140 chars/part to leave safe headroom. +func generateMessage(parts int) string { + prefix := "LOADTEST: " + maxChars := parts * 140 + budget := maxChars - len(prefix) + + // Shuffle poem lines and pick lines that fit within budget + lines := make([]string, len(poemLines)) + copy(lines, poemLines) + rand.Shuffle(len(lines), func(i, j int) { lines[i], lines[j] = lines[j], lines[i] }) + + var b strings.Builder + for _, line := range lines { + needed := len(line) + if b.Len() > 0 { + needed++ // space separator + } + if b.Len()+needed > budget { + break + } + if b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteString(line) + } + + return prefix + b.String() +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..934d801 --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,116 @@ +package stats + +import ( + "fmt" + "sync" + "sync/atomic" + "time" +) + +type Stats struct { + TotalRequests atomic.Int64 + SuccessCount atomic.Int64 + FailCount atomic.Int64 + TotalLatencyMs atomic.Int64 + MinLatencyMs atomic.Int64 + MaxLatencyMs atomic.Int64 + TotalSMSParts atomic.Int64 + StatusCodes sync.Map +} + +func (s *Stats) RecordLatency(latencyMs int64) { + s.TotalLatencyMs.Add(latencyMs) + + // Update min latency + for { + cur := s.MinLatencyMs.Load() + if cur != 0 && cur <= latencyMs { + break + } + if s.MinLatencyMs.CompareAndSwap(cur, latencyMs) { + break + } + } + + // Update max latency + for { + cur := s.MaxLatencyMs.Load() + if cur >= latencyMs { + break + } + if s.MaxLatencyMs.CompareAndSwap(cur, latencyMs) { + break + } + } +} + +func (s *Stats) RecordStatusCode(code int) { + key := fmt.Sprintf("%d", code) + if val, ok := s.StatusCodes.Load(key); ok { + counter := val.(*atomic.Int64) + counter.Add(1) + } else { + counter := &atomic.Int64{} + counter.Add(1) + s.StatusCodes.Store(key, counter) + } +} + +func (s *Stats) PrintLive(elapsed time.Duration) { + total := s.TotalRequests.Load() + success := s.SuccessCount.Load() + fail := s.FailCount.Load() + avgLatency := int64(0) + if total > 0 { + avgLatency = s.TotalLatencyMs.Load() / total + } + actualRPS := float64(total) / elapsed.Seconds() + + fmt.Printf("\r[%s] Reqs: %d | OK: %d | Fail: %d | Avg: %dms | Min: %dms | Max: %dms | RPS: %.1f ", + elapsed.Round(time.Second), + total, success, fail, + avgLatency, + s.MinLatencyMs.Load(), + s.MaxLatencyMs.Load(), + actualRPS, + ) +} + +func (s *Stats) PrintFinalReport(duration time.Duration) { + total := s.TotalRequests.Load() + success := s.SuccessCount.Load() + fail := s.FailCount.Load() + avgLatency := int64(0) + if total > 0 { + avgLatency = s.TotalLatencyMs.Load() / total + } + + fmt.Println("\n") + fmt.Println("========================================") + fmt.Println(" LOAD TEST FINAL REPORT ") + fmt.Println("========================================") + fmt.Printf("Duration: %s\n", duration.Round(time.Millisecond)) + fmt.Printf("Total Requests: %d\n", total) + fmt.Printf("Successful: %d (%.1f%%)\n", success, pct(success, total)) + fmt.Printf("Failed: %d (%.1f%%)\n", fail, pct(fail, total)) + fmt.Printf("Avg Latency: %d ms\n", avgLatency) + fmt.Printf("Min Latency: %d ms\n", s.MinLatencyMs.Load()) + fmt.Printf("Max Latency: %d ms\n", s.MaxLatencyMs.Load()) + fmt.Printf("Actual RPS: %.1f\n", float64(total)/duration.Seconds()) + fmt.Printf("Total SMS Parts: %d\n", s.TotalSMSParts.Load()) + fmt.Println("----------------------------------------") + fmt.Println("Status Code Breakdown:") + s.StatusCodes.Range(func(key, value any) bool { + counter := value.(*atomic.Int64) + fmt.Printf(" HTTP %s: %d\n", key.(string), counter.Load()) + return true + }) + fmt.Println("========================================") +} + +func pct(part, total int64) float64 { + if total == 0 { + return 0 + } + return float64(part) / float64(total) * 100 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..05a034b --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "cast-loadtest/internal/config" + "cast-loadtest/internal/runner" +) + +func main() { + cfg := config.Load() + runner.Run(cfg) +}