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 <noreply@anthropic.com>
This commit is contained in:
Eumir Santiago 2026-03-12 10:19:20 +01:00
commit 60785a52f5
9 changed files with 491 additions and 0 deletions

14
.env.example Normal file
View File

@ -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

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
/bin/

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module cast-loadtest
go 1.22
require github.com/joho/godotenv v1.5.1

2
go.sum Normal file
View File

@ -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=

59
internal/config/config.go Normal file
View File

@ -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
}

128
internal/runner/runner.go Normal file
View File

@ -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))
}

154
internal/sender/sender.go Normal file
View File

@ -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()
}

116
internal/stats/stats.go Normal file
View File

@ -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
}

11
main.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"cast-loadtest/internal/config"
"cast-loadtest/internal/runner"
)
func main() {
cfg := config.Load()
runner.Run(cfg)
}