Simple tool to put a specific load on clio.
This commit is contained in:
Sergey Kuznetsov
2024-05-24 13:01:36 +01:00
committed by GitHub
parent c56998477c
commit ff4bc5b0aa
10 changed files with 300 additions and 0 deletions

1
tools/requests_gun/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
requests_gun

View File

@@ -0,0 +1,10 @@
# 🔫 Requests gun
Requests gun is a simple tool that allows you to send multiple requests to a server with specific rps (requests per second) rate.
This tool is useful for testing the server behaviour under a specific load.
It takes a file that contains json request per line and sends them to the server in a loop with the specified rps rate.
The tool checks http status code of each request and whether the response body contains field 'error' or not.
It prints statistics for each second while running.
Run `requests_gun --help` to see the available options.

View File

@@ -0,0 +1 @@
{ "method": "server_definitions", "params": [ {} ] }

View File

@@ -0,0 +1,5 @@
module requests_gun
go 1.22.2
require github.com/spf13/pflag v1.0.5 // indirect

View File

@@ -0,0 +1,2 @@
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=

View File

@@ -0,0 +1,36 @@
package ammo_provider
import (
"bufio"
"io"
"sync/atomic"
)
type AmmoProvider struct {
ammo []string
currentBullet atomic.Uint64
}
func (ap *AmmoProvider) getIndex() uint64 {
if ap.currentBullet.Load() >= uint64(len(ap.ammo)) {
ap.currentBullet.Store(1)
return 0
}
result := ap.currentBullet.Load()
ap.currentBullet.Add(1)
return result
}
func (ap *AmmoProvider) GetBullet() string {
return ap.ammo[ap.getIndex()]
}
func New(reader io.Reader) *AmmoProvider {
scanner := bufio.NewScanner(reader)
var ammo []string
for scanner.Scan() {
ammo = append(ammo, scanner.Text())
}
return &AmmoProvider{ammo: ammo}
}

View File

@@ -0,0 +1,40 @@
package parse_args
import (
"fmt"
flag "github.com/spf13/pflag"
)
type CliArgs struct {
Url string
Port uint
TargetLoad uint
Ammo string
PrintErrors bool
Help bool
}
func Parse() (*CliArgs, error) {
flag.Usage = PrintUsage
url := flag.StringP("url", "u", "localhost", "URL to send the request to")
port := flag.UintP("port", "p", 51233, "Port to send the request to")
target_load := flag.UintP("load", "l", 100, "Target requests per second load")
print_errors := flag.BoolP("print-errors", "e", false, "Print errors")
help := flag.BoolP("help", "h", false, "Print help message")
flag.Parse()
if flag.NArg() == 0 {
return nil, fmt.Errorf("No ammo file provided")
}
return &CliArgs{*url, *port, *target_load, flag.Arg(0), *print_errors, *help}, nil
}
func PrintUsage() {
fmt.Println("Usage: requests_gun [options] <ammo_file>")
fmt.Println(" <ammo_file> Path to file with requests to use")
fmt.Println("Options:")
flag.PrintDefaults()
}

View File

@@ -0,0 +1,74 @@
package request_maker
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type RequestMaker interface {
MakeRequest(request string) (*ResponseData, error)
}
type HttpRequestMaker struct {
url string
transport *http.Transport
client *http.Client
}
type JsonMap map[string]interface{}
type StatusCode int
type ResponseData struct {
Body JsonMap
StatusCode StatusCode
StatusStr string
Duration time.Duration
}
func (h *HttpRequestMaker) MakeRequest(request string) (*ResponseData, error) {
startTime := time.Now()
req, err := http.NewRequest("POST", h.url, strings.NewReader(request))
if err != nil {
return nil, errors.New("Error creating request: " + err.Error())
}
response, err := h.client.Do(req)
requestDuration := time.Since(startTime)
if err != nil {
return nil, errors.New("Error making request: " + err.Error())
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
if err != nil {
return nil, errors.New("Error reading response body: " + err.Error())
}
if response.StatusCode != 200 {
return &ResponseData{StatusCode: StatusCode(response.StatusCode), StatusStr: response.Status + ": " + string(body)}, nil
}
var bodyParsed JsonMap
err = json.Unmarshal(body, &bodyParsed)
if err != nil {
return nil, errors.New("Error parsing response '" + string(body) + "': " + err.Error())
}
return &ResponseData{bodyParsed, StatusCode(response.StatusCode), response.Status, requestDuration}, nil
}
func NewHttp(host string, port uint) *HttpRequestMaker {
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "http://" + host
}
transport := http.DefaultTransport.(*http.Transport).Clone()
client := &http.Client{Transport: transport}
return &HttpRequestMaker{host + ":" + fmt.Sprintf("%d", port), transport, client}
}

View File

@@ -0,0 +1,100 @@
package trigger
import (
"log"
"os"
"os/signal"
"requests_gun/internal/ammo_provider"
"requests_gun/internal/parse_args"
"requests_gun/internal/request_maker"
"sync"
"sync/atomic"
"time"
)
func Fire(ammoProvider *ammo_provider.AmmoProvider, args *parse_args.CliArgs) {
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
wg := sync.WaitGroup{}
ticker := time.NewTicker(time.Second)
for {
select {
case s := <-interrupt:
log.Println("Got signal:", s)
log.Println("Stopping...")
ticker.Stop()
return
case <-ticker.C:
statistics := statistics{startTime: time.Now(), printErrors: args.PrintErrors}
doShot := func() {
defer wg.Done()
bullet := ammoProvider.GetBullet()
requestMaker := request_maker.NewHttp(args.Url, args.Port)
responseData, err := requestMaker.MakeRequest(bullet)
statistics.add(responseData, err)
}
secondStart := time.Now()
requestsNumber := uint(0)
for requestsNumber < args.TargetLoad && time.Since(secondStart) < time.Second {
wg.Add(1)
go doShot()
requestsNumber++
}
wg.Wait()
if time.Since(secondStart) > time.Second {
log.Println("Requests took longer than a second, probably need to decrease the load.")
}
statistics.print()
}
}
}
type counters struct {
totalRequests atomic.Uint64
errors atomic.Uint64
badReply atomic.Uint64
goodReply atomic.Uint64
}
type statistics struct {
counters counters
startTime time.Time
printErrors bool
}
func (s *statistics) add(response *request_maker.ResponseData, err error) {
s.counters.totalRequests.Add(1)
if err != nil {
if s.printErrors {
log.Println("Error making request:", err)
}
s.counters.errors.Add(1)
return
}
if response.StatusCode != 200 || response.Body["error"] != nil {
if s.printErrors {
log.Print("Response contains error: ", response.StatusStr)
if response.Body["error"] != nil {
log.Println(" ", response.Body["error"])
} else {
log.Println()
}
}
s.counters.badReply.Add(1)
} else {
s.counters.goodReply.Add(1)
}
}
func (s *statistics) print() {
elapsed := time.Since(s.startTime)
if elapsed < time.Second {
elapsed = time.Second
}
log.Printf("Speed: %.1f rps, Errors: %.1f%%, Bad response: %.1f%%, Good response: %.1f%%\n",
float64(s.counters.totalRequests.Load())/elapsed.Seconds(),
float64(s.counters.errors.Load())/float64(s.counters.totalRequests.Load())*100,
float64(s.counters.badReply.Load())/float64(s.counters.totalRequests.Load())*100,
float64(s.counters.goodReply.Load())/float64(s.counters.totalRequests.Load())*100)
}

View File

@@ -0,0 +1,31 @@
package main
import (
"fmt"
"os"
"requests_gun/internal/ammo_provider"
"requests_gun/internal/parse_args"
"requests_gun/internal/trigger"
)
func main() {
args, err := parse_args.Parse()
if err != nil {
fmt.Fprintln(os.Stderr, "Error: ", err)
parse_args.PrintUsage()
os.Exit(1)
}
fmt.Print("Loading ammo... ")
f, err := os.Open(args.Ammo)
if err != nil {
fmt.Println("Error opening file '", args.Ammo, "': ", err)
os.Exit(1)
}
ammoProvider := ammo_provider.New(f)
f.Close()
fmt.Println("Done")
fmt.Println("Firing requests...")
trigger.Fire(ammoProvider, args)
}