├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .semaphore └── semaphore.yml ├── LICENSE ├── README.md ├── args_parser.go ├── args_parser_test.go ├── bombardier.go ├── bombardier_performance_test.go ├── bombardier_test.go ├── build.py ├── client_cert.go ├── client_cert_test.go ├── clients.go ├── clients_test.go ├── cmd └── utils │ └── simplebenchserver │ ├── doc.go │ └── main.go ├── common.go ├── completion_barriers.go ├── completion_barriers_test.go ├── config.go ├── config_test.go ├── dialer.go ├── doc.go ├── docs └── CONTRIBUTING.md ├── error_map.go ├── error_map_test.go ├── flags.go ├── flags_test.go ├── format.go ├── format_test.go ├── go.mod ├── go.sum ├── headers.go ├── headers_test.go ├── img └── logo.png ├── internal └── test_info.go ├── limiter.go ├── limiter_barrier_test.go ├── limiter_test.go ├── proxy_reader.go ├── rateestimator.go ├── rateestimator_test.go ├── template └── doc.go ├── templates.go ├── testbody.txt ├── testclient.cert ├── testclient.key ├── testserver.cert └── testserver.key /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | This is an example of what a **bug report** can look like. Please, feel free to also provide any other information relevant to the issue. 2 | 3 | ### What version of bombardier are you using? 4 | Hash of the commit, like 5 | 6 | [00d7965d6cae34c62042abb0f6c45c45b870dcf3](https://github.com/codesenberg/bombardier/commit/00d7965d6cae34c62042abb0f6c45c45b870dcf3) 7 | 8 | in case you've built _bombardier_ yourself or version obtained by 9 | ``` 10 | bombardier --version 11 | ``` 12 | in case you are using binaries. 13 | 14 | ### What operating system and processor architecture are you using (if relevant)? 15 | Examples are `windows/amd64`, `linux/amd64`, `darwin/amd64`, etc. 16 | 17 | ### What did you do? 18 | 19 | Describe steps that can be used to reproduce the error. 20 | 21 | ### What you expected to happen? 22 | 23 | ### What actually happened? 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Before submitting a pull request be sure to check the code with [gometalinter](https://github.com/alecthomas/gometalinter). 2 | This can save a considerable amount of time during code review. 3 | 4 | Generally, try to follow this format when writing commit messages: 5 | ``` 6 | : 7 | 8 | 9 | 10 | , updates #, closes #. If applicable.> 11 | ``` 12 | 13 | Examples of such commit messages can be found in the commit log of this project or 14 | [in this section](https://golang.org/doc/contribute.html#commit_changes) of Go's Contribution Guidelines, 15 | from which this format was adopted. 16 | 17 | The pull request itself can contain a short description of changes made, questions or provide some other information, etc. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | cover.* 26 | bombardier 27 | bombardier-* -------------------------------------------------------------------------------- /.semaphore/semaphore.yml: -------------------------------------------------------------------------------- 1 | version: v1.0 2 | name: codesenberg/bombardier 3 | agent: 4 | machine: 5 | type: e1-standard-2 6 | os_image: ubuntu2004 7 | blocks: 8 | - name: Test 9 | task: 10 | prologue: 11 | commands: 12 | - checkout 13 | - go install gotest.tools/gotestsum@latest 14 | jobs: 15 | - name: Test go 1.21 16 | commands: 17 | - sem-version go 1.21 18 | - gotestsum --junitfile report.xml ./... 19 | - name: Test go 1.22 20 | commands: 21 | - sem-version go 1.22 22 | - gotestsum --junitfile report.xml ./... 23 | epilogue: 24 | always: 25 | commands: 26 | - '[[ -f report.xml ]] && test-results publish report.xml' 27 | after_pipeline: 28 | task: 29 | jobs: 30 | - name: Publish test results 31 | commands: 32 | - test-results gen-pipeline-report 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Максим Федосеев 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bombardier [![Build Status](https://codesenberg.semaphoreci.com/badges/bombardier/branches/master.svg?key=249c678c-eb2a-441e-8128-1bdcfb9aaca6)](https://codesenberg.semaphoreci.com/projects/bombardier) [![Go Report Card](https://goreportcard.com/badge/github.com/codesenberg/bombardier)](https://goreportcard.com/report/github.com/codesenberg/bombardier) [![GoDoc](https://godoc.org/github.com/codesenberg/bombardier?status.svg)](http://godoc.org/github.com/codesenberg/bombardier) 2 | ![Logo](https://raw.githubusercontent.com/codesenberg/bombardier/master/img/logo.png) 3 | bombardier is a HTTP(S) benchmarking tool. It is written in Go programming language and uses excellent [fasthttp](https://github.com/valyala/fasthttp) instead of Go's default http library, because of its lightning fast performance. 4 | 5 | With `bombardier v1.1` and higher you can now use `net/http` client if you need to test HTTP/2.x services or want to use a more RFC-compliant HTTP client. 6 | 7 | ## Installation 8 | You can grab binaries in the [releases](https://github.com/codesenberg/bombardier/releases) section. 9 | Alternatively, to get latest and greatest run: 10 | 11 | Go 1.18+: `go install github.com/codesenberg/bombardier@latest` 12 | 13 | ## Usage 14 | ``` 15 | bombardier [] 16 | ``` 17 | 18 | For a more detailed information about flags consult [GoDoc](http://godoc.org/github.com/codesenberg/bombardier). 19 | 20 | ## Known issues 21 | AFAIK, it's impossible to pass Host header correctly with `fasthttp`, you can use `net/http`(`--http1`/`--http2` flags) to workaround this issue. 22 | 23 | ## Examples 24 | Example of running `bombardier` against [this server](https://godoc.org/github.com/codesenberg/bombardier/cmd/utils/simplebenchserver): 25 | ``` 26 | > bombardier -c 125 -n 10000000 http://localhost:8080 27 | Bombarding http://localhost:8080 with 10000000 requests using 125 connections 28 | 10000000 / 10000000 [============================================] 100.00% 37s Done! 29 | Statistics Avg Stdev Max 30 | Reqs/sec 264560.00 10733.06 268434 31 | Latency 471.00us 522.34us 51.00ms 32 | HTTP codes: 33 | 1xx - 0, 2xx - 10000000, 3xx - 0, 4xx - 0, 5xx - 0 34 | others - 0 35 | Throughput: 292.92MB/s 36 | ``` 37 | Or, against a realworld server(with latency distribution): 38 | ``` 39 | > bombardier -c 200 -d 10s -l http://ya.ru 40 | Bombarding http://ya.ru for 10s using 200 connections 41 | [=========================================================================] 10s Done! 42 | Statistics Avg Stdev Max 43 | Reqs/sec 6607.00 524.56 7109 44 | Latency 29.86ms 5.36ms 305.02ms 45 | Latency Distribution 46 | 50% 28.00ms 47 | 75% 32.00ms 48 | 90% 34.00ms 49 | 99% 48.00ms 50 | HTTP codes: 51 | 1xx - 0, 2xx - 0, 3xx - 66561, 4xx - 0, 5xx - 0 52 | others - 5 53 | Errors: 54 | dialing to the given TCP address timed out - 5 55 | Throughput: 3.06MB/s 56 | ``` 57 | -------------------------------------------------------------------------------- /args_parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/alecthomas/kingpin" 11 | "github.com/goware/urlx" 12 | ) 13 | 14 | type argsParser interface { 15 | parse([]string) (config, error) 16 | } 17 | 18 | type kingpinParser struct { 19 | app *kingpin.Application 20 | 21 | url string 22 | 23 | numReqs *nullableUint64 24 | duration *nullableDuration 25 | headers *headersList 26 | numConns uint64 27 | timeout time.Duration 28 | latencies bool 29 | insecure bool 30 | disableKeepAlives bool 31 | method string 32 | body string 33 | bodyFilePath string 34 | stream bool 35 | certPath string 36 | keyPath string 37 | rate *nullableUint64 38 | clientType clientTyp 39 | 40 | printSpec *nullableString 41 | noPrint bool 42 | 43 | formatSpec string 44 | } 45 | 46 | func newKingpinParser() argsParser { 47 | kparser := &kingpinParser{ 48 | numReqs: new(nullableUint64), 49 | duration: new(nullableDuration), 50 | headers: new(headersList), 51 | numConns: defaultNumberOfConns, 52 | timeout: defaultTimeout, 53 | latencies: false, 54 | method: "GET", 55 | body: "", 56 | bodyFilePath: "", 57 | stream: false, 58 | certPath: "", 59 | keyPath: "", 60 | insecure: false, 61 | url: "", 62 | rate: new(nullableUint64), 63 | clientType: fhttp, 64 | printSpec: new(nullableString), 65 | noPrint: false, 66 | formatSpec: "plain-text", 67 | } 68 | 69 | app := kingpin.New("", "Fast cross-platform HTTP benchmarking tool"). 70 | Version("bombardier version " + version + " " + runtime.GOOS + "/" + 71 | runtime.GOARCH) 72 | app.Flag("connections", "Maximum number of concurrent connections"). 73 | Short('c'). 74 | PlaceHolder(strconv.FormatUint(defaultNumberOfConns, decBase)). 75 | Uint64Var(&kparser.numConns) 76 | app.Flag("timeout", "Socket/request timeout"). 77 | PlaceHolder(defaultTimeout.String()). 78 | Short('t'). 79 | DurationVar(&kparser.timeout) 80 | app.Flag("latencies", "Print latency statistics"). 81 | Short('l'). 82 | BoolVar(&kparser.latencies) 83 | app.Flag("method", "Request method"). 84 | PlaceHolder("GET"). 85 | Short('m'). 86 | StringVar(&kparser.method) 87 | app.Flag("body", "Request body"). 88 | Default(""). 89 | Short('b'). 90 | StringVar(&kparser.body) 91 | app.Flag("body-file", "File to use as request body"). 92 | Default(""). 93 | Short('f'). 94 | StringVar(&kparser.bodyFilePath) 95 | app.Flag("stream", "Specify whether to stream body using "+ 96 | "chunked transfer encoding or to serve it from memory"). 97 | Short('s'). 98 | BoolVar(&kparser.stream) 99 | app.Flag("cert", "Path to the client's TLS Certificate"). 100 | Default(""). 101 | StringVar(&kparser.certPath) 102 | app.Flag("key", "Path to the client's TLS Certificate Private Key"). 103 | Default(""). 104 | StringVar(&kparser.keyPath) 105 | app.Flag("insecure", 106 | "Controls whether a client verifies the server's certificate"+ 107 | " chain and host name"). 108 | Short('k'). 109 | BoolVar(&kparser.insecure) 110 | app.Flag("disableKeepAlives", 111 | "Disable HTTP keep-alive. For fasthttp use -H 'Connection: close'"). 112 | Short('a'). 113 | BoolVar(&kparser.disableKeepAlives) 114 | 115 | app.Flag("header", "HTTP headers to use(can be repeated)"). 116 | PlaceHolder("\"K: V\""). 117 | Short('H'). 118 | SetValue(kparser.headers) 119 | app.Flag("requests", "Number of requests"). 120 | PlaceHolder("[pos. int.]"). 121 | Short('n'). 122 | SetValue(kparser.numReqs) 123 | app.Flag("duration", "Duration of test"). 124 | PlaceHolder(defaultTestDuration.String()). 125 | Short('d'). 126 | SetValue(kparser.duration) 127 | 128 | app.Flag("rate", "Rate limit in requests per second"). 129 | PlaceHolder("[pos. int.]"). 130 | Short('r'). 131 | SetValue(kparser.rate) 132 | 133 | app.Flag("fasthttp", "Use fasthttp client"). 134 | Action(func(*kingpin.ParseContext) error { 135 | kparser.clientType = fhttp 136 | return nil 137 | }). 138 | Bool() 139 | app.Flag("http1", "Use net/http client with forced HTTP/1.x"). 140 | Action(func(*kingpin.ParseContext) error { 141 | kparser.clientType = nhttp1 142 | return nil 143 | }). 144 | Bool() 145 | app.Flag("http2", "Use net/http client with enabled HTTP/2.0"). 146 | Action(func(*kingpin.ParseContext) error { 147 | kparser.clientType = nhttp2 148 | return nil 149 | }). 150 | Bool() 151 | 152 | app.Flag( 153 | "print", "Specifies what to output. Comma-separated list of values"+ 154 | " 'intro' (short: 'i'), 'progress' (short: 'p'),"+ 155 | " 'result' (short: 'r'). Examples:"+ 156 | "\n\t* i,p,r (prints everything)"+ 157 | "\n\t* intro,result (intro & result)"+ 158 | "\n\t* r (result only)"+ 159 | "\n\t* result (same as above)"). 160 | PlaceHolder(""). 161 | Short('p'). 162 | SetValue(kparser.printSpec) 163 | app.Flag("no-print", "Don't output anything"). 164 | Short('q'). 165 | BoolVar(&kparser.noPrint) 166 | 167 | app.Flag("format", "Which format to use to output the result. "+ 168 | " is either a name (or its shorthand) of some format "+ 169 | "understood by bombardier or a path to the user-defined template, "+ 170 | "which uses Go's text/template syntax, prefixed with 'path:' string "+ 171 | "(without single quotes), i.e. \"path:/some/path/to/your.template\" "+ 172 | " or \"path:C:\\some\\path\\to\\your.template\" in case of Windows. "+ 173 | "Formats understood by bombardier are:"+ 174 | "\n\t* plain-text (short: pt)"+ 175 | "\n\t* json (short: j)"). 176 | PlaceHolder(""). 177 | Short('o'). 178 | StringVar(&kparser.formatSpec) 179 | 180 | app.Arg("url", "Target's URL").Required(). 181 | StringVar(&kparser.url) 182 | 183 | kparser.app = app 184 | return argsParser(kparser) 185 | } 186 | 187 | func (k *kingpinParser) parse(args []string) (config, error) { 188 | k.app.Name = args[0] 189 | _, err := k.app.Parse(args[1:]) 190 | if err != nil { 191 | return emptyConf, err 192 | } 193 | pi, pp, pr := true, true, true 194 | if k.printSpec.val != nil { 195 | pi, pp, pr, err = parsePrintSpec(*k.printSpec.val) 196 | if err != nil { 197 | return emptyConf, err 198 | } 199 | } 200 | if k.noPrint { 201 | pi, pp, pr = false, false, false 202 | } 203 | format := formatFromString(k.formatSpec) 204 | if format == nil { 205 | return emptyConf, fmt.Errorf( 206 | "unknown format or invalid format spec %q", k.formatSpec, 207 | ) 208 | } 209 | url, err := urlx.Parse(k.url) 210 | if err != nil { 211 | return emptyConf, err 212 | } 213 | return config{ 214 | numConns: k.numConns, 215 | numReqs: k.numReqs.val, 216 | duration: k.duration.val, 217 | url: url, 218 | headers: k.headers, 219 | timeout: k.timeout, 220 | method: k.method, 221 | body: k.body, 222 | bodyFilePath: k.bodyFilePath, 223 | stream: k.stream, 224 | keyPath: k.keyPath, 225 | certPath: k.certPath, 226 | printLatencies: k.latencies, 227 | insecure: k.insecure, 228 | disableKeepAlives: k.disableKeepAlives, 229 | rate: k.rate.val, 230 | clientType: k.clientType, 231 | printIntro: pi, 232 | printProgress: pp, 233 | printResult: pr, 234 | format: format, 235 | }, nil 236 | } 237 | 238 | func parsePrintSpec(spec string) (bool, bool, bool, error) { 239 | pi, pp, pr := false, false, false 240 | if spec == "" { 241 | return false, false, false, errEmptyPrintSpec 242 | } 243 | parts := strings.Split(spec, ",") 244 | partsCount := 0 245 | for _, p := range parts { 246 | switch p { 247 | case "i", "intro": 248 | pi = true 249 | case "p", "progress": 250 | pp = true 251 | case "r", "result": 252 | pr = true 253 | default: 254 | return false, false, false, 255 | fmt.Errorf("%q is not a valid part of print spec", p) 256 | } 257 | partsCount++ 258 | } 259 | if partsCount < 1 || partsCount > 3 { 260 | return false, false, false, 261 | fmt.Errorf( 262 | "spec %q has too many parts, at most 3 are allowed", spec, 263 | ) 264 | } 265 | return pi, pp, pr, nil 266 | } 267 | -------------------------------------------------------------------------------- /args_parser_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | const ( 12 | programName = "bombardier" 13 | ) 14 | 15 | func TestInvalidArgsParsing(t *testing.T) { 16 | expectations := []struct { 17 | in []string 18 | out string 19 | }{ 20 | { 21 | []string{programName}, 22 | "required argument 'url' not provided", 23 | }, 24 | { 25 | []string{programName, "http://google.com", "http://yahoo.com"}, 26 | "unexpected http://yahoo.com", 27 | }, 28 | } 29 | for _, e := range expectations { 30 | p := newKingpinParser() 31 | if _, err := p.parse(e.in); err == nil || 32 | err.Error() != e.out { 33 | t.Error(err, e.out) 34 | } 35 | } 36 | } 37 | 38 | func TestUnspecifiedArgParsing(t *testing.T) { 39 | p := newKingpinParser() 40 | args := []string{programName, "--someunspecifiedflag"} 41 | _, err := p.parse(args) 42 | if err == nil { 43 | t.Fail() 44 | } 45 | } 46 | 47 | func TestArgsParsing(t *testing.T) { 48 | ten := uint64(10) 49 | expectations := []struct { 50 | in [][]string 51 | out config 52 | }{ 53 | { 54 | [][]string{ 55 | {programName, "localhost:8080"}, 56 | }, 57 | config{ 58 | numConns: defaultNumberOfConns, 59 | timeout: defaultTimeout, 60 | headers: new(headersList), 61 | method: "GET", 62 | url: ParseURLOrPanic("http://localhost:8080"), 63 | printIntro: true, 64 | printProgress: true, 65 | printResult: true, 66 | format: knownFormat("plain-text"), 67 | }, 68 | }, 69 | { 70 | [][]string{ 71 | {programName, "https://localhost"}, 72 | }, 73 | config{ 74 | numConns: defaultNumberOfConns, 75 | timeout: defaultTimeout, 76 | headers: new(headersList), 77 | method: "GET", 78 | url: ParseURLOrPanic("https://localhost"), 79 | printIntro: true, 80 | printProgress: true, 81 | printResult: true, 82 | format: knownFormat("plain-text"), 83 | }, 84 | }, 85 | { 86 | [][]string{{programName, "https://somehost.somedomain"}}, 87 | config{ 88 | numConns: defaultNumberOfConns, 89 | timeout: defaultTimeout, 90 | headers: new(headersList), 91 | method: "GET", 92 | url: ParseURLOrPanic("https://somehost.somedomain"), 93 | printIntro: true, 94 | printProgress: true, 95 | printResult: true, 96 | format: knownFormat("plain-text"), 97 | }, 98 | }, 99 | { 100 | [][]string{ 101 | { 102 | programName, 103 | "-c", "10", 104 | "-n", strconv.FormatUint(defaultNumberOfReqs, decBase), 105 | "-t", "10s", 106 | "https://somehost.somedomain", 107 | }, 108 | { 109 | programName, 110 | "-c10", 111 | "-n" + strconv.FormatUint(defaultNumberOfReqs, decBase), 112 | "-t10s", 113 | "https://somehost.somedomain", 114 | }, 115 | { 116 | programName, 117 | "--connections", "10", 118 | "--requests", strconv.FormatUint(defaultNumberOfReqs, decBase), 119 | "--timeout", "10s", 120 | "https://somehost.somedomain", 121 | }, 122 | { 123 | programName, 124 | "--connections=10", 125 | "--requests=" + strconv.FormatUint(defaultNumberOfReqs, decBase), 126 | "--timeout=10s", 127 | "https://somehost.somedomain", 128 | }, 129 | }, 130 | config{ 131 | numConns: 10, 132 | timeout: 10 * time.Second, 133 | headers: new(headersList), 134 | method: "GET", 135 | numReqs: &defaultNumberOfReqs, 136 | url: ParseURLOrPanic("https://somehost.somedomain"), 137 | printIntro: true, 138 | printProgress: true, 139 | printResult: true, 140 | format: knownFormat("plain-text"), 141 | }, 142 | }, 143 | { 144 | [][]string{ 145 | { 146 | programName, 147 | "--latencies", 148 | "https://somehost.somedomain", 149 | }, 150 | { 151 | programName, 152 | "-l", 153 | "https://somehost.somedomain", 154 | }, 155 | }, 156 | config{ 157 | numConns: defaultNumberOfConns, 158 | timeout: defaultTimeout, 159 | headers: new(headersList), 160 | printLatencies: true, 161 | method: "GET", 162 | url: ParseURLOrPanic("https://somehost.somedomain"), 163 | printIntro: true, 164 | printProgress: true, 165 | printResult: true, 166 | format: knownFormat("plain-text"), 167 | }, 168 | }, 169 | { 170 | [][]string{ 171 | { 172 | programName, 173 | "--insecure", 174 | "https://somehost.somedomain", 175 | }, 176 | { 177 | programName, 178 | "-k", 179 | "https://somehost.somedomain", 180 | }, 181 | }, 182 | config{ 183 | numConns: defaultNumberOfConns, 184 | timeout: defaultTimeout, 185 | headers: new(headersList), 186 | insecure: true, 187 | method: "GET", 188 | url: ParseURLOrPanic("https://somehost.somedomain"), 189 | printIntro: true, 190 | printProgress: true, 191 | printResult: true, 192 | format: knownFormat("plain-text"), 193 | }, 194 | }, 195 | { 196 | [][]string{ 197 | { 198 | programName, 199 | "--key", "testclient.key", 200 | "--cert", "testclient.cert", 201 | "https://somehost.somedomain", 202 | }, 203 | { 204 | programName, 205 | "--key=testclient.key", 206 | "--cert=testclient.cert", 207 | "https://somehost.somedomain", 208 | }, 209 | }, 210 | config{ 211 | numConns: defaultNumberOfConns, 212 | timeout: defaultTimeout, 213 | headers: new(headersList), 214 | method: "GET", 215 | keyPath: "testclient.key", 216 | certPath: "testclient.cert", 217 | url: ParseURLOrPanic("https://somehost.somedomain"), 218 | printIntro: true, 219 | printProgress: true, 220 | printResult: true, 221 | format: knownFormat("plain-text"), 222 | }, 223 | }, 224 | { 225 | [][]string{ 226 | { 227 | programName, 228 | "--method", "POST", 229 | "--body", "reqbody", 230 | "https://somehost.somedomain", 231 | }, 232 | { 233 | programName, 234 | "--method=POST", 235 | "--body=reqbody", 236 | "https://somehost.somedomain", 237 | }, 238 | { 239 | programName, 240 | "-m", "POST", 241 | "-b", "reqbody", 242 | "https://somehost.somedomain", 243 | }, 244 | { 245 | programName, 246 | "-mPOST", 247 | "-breqbody", 248 | "https://somehost.somedomain", 249 | }, 250 | }, 251 | config{ 252 | numConns: defaultNumberOfConns, 253 | timeout: defaultTimeout, 254 | headers: new(headersList), 255 | method: "POST", 256 | body: "reqbody", 257 | url: ParseURLOrPanic("https://somehost.somedomain"), 258 | printIntro: true, 259 | printProgress: true, 260 | printResult: true, 261 | format: knownFormat("plain-text"), 262 | }, 263 | }, 264 | { 265 | [][]string{ 266 | { 267 | programName, 268 | "--header", "One: Value one", 269 | "--header", "Two: Value two", 270 | "https://somehost.somedomain", 271 | }, 272 | { 273 | programName, 274 | "-H", "One: Value one", 275 | "-H", "Two: Value two", 276 | "https://somehost.somedomain", 277 | }, 278 | { 279 | programName, 280 | "--header=One: Value one", 281 | "--header=Two: Value two", 282 | "https://somehost.somedomain", 283 | }, 284 | }, 285 | config{ 286 | numConns: defaultNumberOfConns, 287 | timeout: defaultTimeout, 288 | headers: &headersList{ 289 | {"One", "Value one"}, 290 | {"Two", "Value two"}, 291 | }, 292 | method: "GET", 293 | url: ParseURLOrPanic("https://somehost.somedomain"), 294 | printIntro: true, 295 | printProgress: true, 296 | printResult: true, 297 | format: knownFormat("plain-text"), 298 | }, 299 | }, 300 | { 301 | [][]string{ 302 | { 303 | programName, 304 | "--rate", "10", 305 | "https://somehost.somedomain", 306 | }, 307 | { 308 | programName, 309 | "-r", "10", 310 | "https://somehost.somedomain", 311 | }, 312 | { 313 | programName, 314 | "--rate=10", 315 | "https://somehost.somedomain", 316 | }, 317 | { 318 | programName, 319 | "-r10", 320 | "https://somehost.somedomain", 321 | }, 322 | }, 323 | config{ 324 | numConns: defaultNumberOfConns, 325 | timeout: defaultTimeout, 326 | headers: new(headersList), 327 | method: "GET", 328 | url: ParseURLOrPanic("https://somehost.somedomain"), 329 | rate: &ten, 330 | printIntro: true, 331 | printProgress: true, 332 | printResult: true, 333 | format: knownFormat("plain-text"), 334 | }, 335 | }, 336 | { 337 | [][]string{ 338 | { 339 | programName, 340 | "--fasthttp", 341 | "https://somehost.somedomain", 342 | }, 343 | { 344 | programName, 345 | "https://somehost.somedomain", 346 | }, 347 | }, 348 | config{ 349 | numConns: defaultNumberOfConns, 350 | timeout: defaultTimeout, 351 | headers: new(headersList), 352 | method: "GET", 353 | url: ParseURLOrPanic("https://somehost.somedomain"), 354 | clientType: fhttp, 355 | printIntro: true, 356 | printProgress: true, 357 | printResult: true, 358 | format: knownFormat("plain-text"), 359 | }, 360 | }, 361 | { 362 | [][]string{ 363 | { 364 | programName, 365 | "--http1", 366 | "https://somehost.somedomain", 367 | }, 368 | }, 369 | config{ 370 | numConns: defaultNumberOfConns, 371 | timeout: defaultTimeout, 372 | headers: new(headersList), 373 | method: "GET", 374 | url: ParseURLOrPanic("https://somehost.somedomain"), 375 | clientType: nhttp1, 376 | printIntro: true, 377 | printProgress: true, 378 | printResult: true, 379 | format: knownFormat("plain-text"), 380 | }, 381 | }, 382 | { 383 | [][]string{ 384 | { 385 | programName, 386 | "--http2", 387 | "https://somehost.somedomain", 388 | }, 389 | }, 390 | config{ 391 | numConns: defaultNumberOfConns, 392 | timeout: defaultTimeout, 393 | headers: new(headersList), 394 | method: "GET", 395 | url: ParseURLOrPanic("https://somehost.somedomain"), 396 | clientType: nhttp2, 397 | printIntro: true, 398 | printProgress: true, 399 | printResult: true, 400 | format: knownFormat("plain-text"), 401 | }, 402 | }, 403 | { 404 | [][]string{ 405 | { 406 | programName, 407 | "--body-file=testbody.txt", 408 | "https://somehost.somedomain", 409 | }, 410 | { 411 | programName, 412 | "--body-file", "testbody.txt", 413 | "https://somehost.somedomain", 414 | }, 415 | { 416 | programName, 417 | "-f", "testbody.txt", 418 | "https://somehost.somedomain", 419 | }, 420 | }, 421 | config{ 422 | numConns: defaultNumberOfConns, 423 | timeout: defaultTimeout, 424 | headers: new(headersList), 425 | method: "GET", 426 | bodyFilePath: "testbody.txt", 427 | url: ParseURLOrPanic("https://somehost.somedomain"), 428 | printIntro: true, 429 | printProgress: true, 430 | printResult: true, 431 | format: knownFormat("plain-text"), 432 | }, 433 | }, 434 | { 435 | [][]string{ 436 | { 437 | programName, 438 | "--stream", 439 | "https://somehost.somedomain", 440 | }, 441 | { 442 | programName, 443 | "-s", 444 | "https://somehost.somedomain", 445 | }, 446 | }, 447 | config{ 448 | numConns: defaultNumberOfConns, 449 | timeout: defaultTimeout, 450 | headers: new(headersList), 451 | method: "GET", 452 | stream: true, 453 | url: ParseURLOrPanic("https://somehost.somedomain"), 454 | printIntro: true, 455 | printProgress: true, 456 | printResult: true, 457 | format: knownFormat("plain-text"), 458 | }, 459 | }, 460 | { 461 | [][]string{ 462 | { 463 | programName, 464 | "https://somehost.somedomain", 465 | }, 466 | }, 467 | config{ 468 | numConns: defaultNumberOfConns, 469 | timeout: defaultTimeout, 470 | headers: new(headersList), 471 | method: "GET", 472 | url: ParseURLOrPanic("https://somehost.somedomain"), 473 | printIntro: true, 474 | printProgress: true, 475 | printResult: true, 476 | format: knownFormat("plain-text"), 477 | }, 478 | }, 479 | { 480 | [][]string{ 481 | { 482 | programName, 483 | "--print=r,i,p", 484 | "https://somehost.somedomain", 485 | }, 486 | { 487 | programName, 488 | "--print", "r,i,p", 489 | "https://somehost.somedomain", 490 | }, 491 | { 492 | programName, 493 | "-p", "r,i,p", 494 | "https://somehost.somedomain", 495 | }, 496 | { 497 | programName, 498 | "--print=result,i,p", 499 | "https://somehost.somedomain", 500 | }, 501 | { 502 | programName, 503 | "--print", "r,intro,p", 504 | "https://somehost.somedomain", 505 | }, 506 | { 507 | programName, 508 | "-p", "r,i,progress", 509 | "https://somehost.somedomain", 510 | }, 511 | }, 512 | config{ 513 | numConns: defaultNumberOfConns, 514 | timeout: defaultTimeout, 515 | headers: new(headersList), 516 | method: "GET", 517 | url: ParseURLOrPanic("https://somehost.somedomain"), 518 | printIntro: true, 519 | printProgress: true, 520 | printResult: true, 521 | format: knownFormat("plain-text"), 522 | }, 523 | }, 524 | { 525 | [][]string{ 526 | { 527 | programName, 528 | "--print=i,r", 529 | "https://somehost.somedomain", 530 | }, 531 | { 532 | programName, 533 | "--print", "i,r", 534 | "https://somehost.somedomain", 535 | }, 536 | { 537 | programName, 538 | "-p", "i,r", 539 | "https://somehost.somedomain", 540 | }, 541 | { 542 | programName, 543 | "--print=intro,r", 544 | "https://somehost.somedomain", 545 | }, 546 | { 547 | programName, 548 | "--print", "i,result", 549 | "https://somehost.somedomain", 550 | }, 551 | { 552 | programName, 553 | "-p", "intro,r", 554 | "https://somehost.somedomain", 555 | }, 556 | }, 557 | config{ 558 | numConns: defaultNumberOfConns, 559 | timeout: defaultTimeout, 560 | headers: new(headersList), 561 | method: "GET", 562 | url: ParseURLOrPanic("https://somehost.somedomain"), 563 | printIntro: true, 564 | printProgress: false, 565 | printResult: true, 566 | format: knownFormat("plain-text"), 567 | }, 568 | }, 569 | { 570 | [][]string{ 571 | { 572 | programName, 573 | "--no-print", 574 | "https://somehost.somedomain", 575 | }, 576 | { 577 | programName, 578 | "-q", 579 | "https://somehost.somedomain", 580 | }, 581 | }, 582 | config{ 583 | numConns: defaultNumberOfConns, 584 | timeout: defaultTimeout, 585 | headers: new(headersList), 586 | method: "GET", 587 | url: ParseURLOrPanic("https://somehost.somedomain"), 588 | printIntro: false, 589 | printProgress: false, 590 | printResult: false, 591 | format: knownFormat("plain-text"), 592 | }, 593 | }, 594 | { 595 | [][]string{ 596 | { 597 | programName, 598 | "--format", "plain-text", 599 | "https://somehost.somedomain", 600 | }, 601 | { 602 | programName, 603 | "--format", "pt", 604 | "https://somehost.somedomain", 605 | }, 606 | { 607 | programName, 608 | "--format=plain-text", 609 | "https://somehost.somedomain", 610 | }, 611 | { 612 | programName, 613 | "--format=pt", 614 | "https://somehost.somedomain", 615 | }, 616 | { 617 | programName, 618 | "-o", "plain-text", 619 | "https://somehost.somedomain", 620 | }, 621 | { 622 | programName, 623 | "-o", "pt", 624 | "https://somehost.somedomain", 625 | }, 626 | }, 627 | config{ 628 | numConns: defaultNumberOfConns, 629 | timeout: defaultTimeout, 630 | headers: new(headersList), 631 | method: "GET", 632 | url: ParseURLOrPanic("https://somehost.somedomain"), 633 | printIntro: true, 634 | printProgress: true, 635 | printResult: true, 636 | format: knownFormat("plain-text"), 637 | }, 638 | }, 639 | { 640 | [][]string{ 641 | { 642 | programName, 643 | "--format", "json", 644 | "https://somehost.somedomain", 645 | }, 646 | { 647 | programName, 648 | "--format", "j", 649 | "https://somehost.somedomain", 650 | }, 651 | { 652 | programName, 653 | "--format=json", 654 | "https://somehost.somedomain", 655 | }, 656 | { 657 | programName, 658 | "--format=j", 659 | "https://somehost.somedomain", 660 | }, 661 | { 662 | programName, 663 | "-o", "json", 664 | "https://somehost.somedomain", 665 | }, 666 | { 667 | programName, 668 | "-o", "j", 669 | "https://somehost.somedomain", 670 | }, 671 | }, 672 | config{ 673 | numConns: defaultNumberOfConns, 674 | timeout: defaultTimeout, 675 | headers: new(headersList), 676 | method: "GET", 677 | url: ParseURLOrPanic("https://somehost.somedomain"), 678 | printIntro: true, 679 | printProgress: true, 680 | printResult: true, 681 | format: knownFormat("json"), 682 | }, 683 | }, 684 | { 685 | [][]string{ 686 | { 687 | programName, 688 | "--format", "path:/path/to/tmpl.txt", 689 | "https://somehost.somedomain", 690 | }, 691 | { 692 | programName, 693 | "--format=path:/path/to/tmpl.txt", 694 | "https://somehost.somedomain", 695 | }, 696 | { 697 | programName, 698 | "-o", "path:/path/to/tmpl.txt", 699 | "https://somehost.somedomain", 700 | }, 701 | }, 702 | config{ 703 | numConns: defaultNumberOfConns, 704 | timeout: defaultTimeout, 705 | headers: new(headersList), 706 | method: "GET", 707 | url: ParseURLOrPanic("https://somehost.somedomain"), 708 | printIntro: true, 709 | printProgress: true, 710 | printResult: true, 711 | format: userDefinedTemplate("/path/to/tmpl.txt"), 712 | }, 713 | }, 714 | } 715 | for _, e := range expectations { 716 | for _, args := range e.in { 717 | p := newKingpinParser() 718 | cfg, err := p.parse(args) 719 | if err != nil { 720 | t.Error(err) 721 | continue 722 | } 723 | if !reflect.DeepEqual(cfg, e.out) { 724 | t.Logf("Expected: %#v", e.out) 725 | t.Logf("Got: %#v", cfg) 726 | t.Fail() 727 | } 728 | } 729 | } 730 | } 731 | 732 | func TestParsePrintSpec(t *testing.T) { 733 | exps := []struct { 734 | spec string 735 | results [3]bool 736 | err error 737 | }{ 738 | { 739 | "", 740 | [3]bool{}, 741 | errEmptyPrintSpec, 742 | }, 743 | { 744 | "a,b,c", 745 | [3]bool{}, 746 | fmt.Errorf("%q is not a valid part of print spec", "a"), 747 | }, 748 | { 749 | "i,p,r,i", 750 | [3]bool{}, 751 | fmt.Errorf( 752 | "spec %q has too many parts, at most 3 are allowed", "i,p,r,i", 753 | ), 754 | }, 755 | { 756 | "i", 757 | [3]bool{true, false, false}, 758 | nil, 759 | }, 760 | { 761 | "p", 762 | [3]bool{false, true, false}, 763 | nil, 764 | }, 765 | { 766 | "r", 767 | [3]bool{false, false, true}, 768 | nil, 769 | }, 770 | { 771 | "i,p,r", 772 | [3]bool{true, true, true}, 773 | nil, 774 | }, 775 | } 776 | for _, e := range exps { 777 | var ( 778 | act = [3]bool{} 779 | err error 780 | ) 781 | act[0], act[1], act[2], err = parsePrintSpec(e.spec) 782 | if !reflect.DeepEqual(err, e.err) { 783 | t.Errorf("For %q, expected err = %q, but got %q", 784 | e.spec, e.err, err, 785 | ) 786 | continue 787 | } 788 | if !reflect.DeepEqual(e.results, act) { 789 | t.Errorf("For %q, expected result = %+v, but got %+v", 790 | e.spec, e.results, act, 791 | ) 792 | } 793 | } 794 | } 795 | 796 | func TestArgsParsingWithEmptyPrintSpec(t *testing.T) { 797 | p := newKingpinParser() 798 | c, err := p.parse( 799 | []string{programName, "--print=", "somehost.somedomain"}) 800 | if err == nil { 801 | t.Fail() 802 | } 803 | if c != emptyConf { 804 | t.Fail() 805 | } 806 | } 807 | 808 | func TestArgsParsingWithInvalidPrintSpec(t *testing.T) { 809 | invalidSpecs := [][]string{ 810 | {programName, "--format", "noprefix.txt", "somehost.somedomain"}, 811 | {programName, "--format=noprefix.txt", "somehost.somedomain"}, 812 | {programName, "-o", "noprefix.txt", "somehost.somedomain"}, 813 | {programName, "--format", "unknown-format", "somehost.somedomain"}, 814 | {programName, "--format=unknown-format", "somehost.somedomain"}, 815 | {programName, "-o", "unknown-format", "somehost.somedomain"}, 816 | } 817 | p := newKingpinParser() 818 | for _, is := range invalidSpecs { 819 | c, err := p.parse(is) 820 | if err == nil || c != emptyConf { 821 | t.Errorf("invalid print spec %q parsed correctly", is) 822 | } 823 | } 824 | } 825 | 826 | func TestEmbeddedURLParsing(t *testing.T) { 827 | p := newKingpinParser() 828 | url := "http://127.0.0.1:8080/to?url=http://10.100.99.41:38667" 829 | c, err := p.parse([]string{programName, url}) 830 | if err != nil { 831 | t.Error(err) 832 | } 833 | if c.url.String() != url { 834 | t.Errorf("got %q, wanted %q", c.url, url) 835 | } 836 | } 837 | -------------------------------------------------------------------------------- /bombardier.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "sync" 11 | "sync/atomic" 12 | "text/template" 13 | "time" 14 | 15 | "github.com/codesenberg/bombardier/internal" 16 | 17 | "github.com/cheggaaa/pb" 18 | fhist "github.com/codesenberg/concurrent/float64/histogram" 19 | uhist "github.com/codesenberg/concurrent/uint64/histogram" 20 | uuid "github.com/satori/go.uuid" 21 | ) 22 | 23 | type bombardier struct { 24 | bytesRead, bytesWritten int64 25 | 26 | // HTTP codes 27 | req1xx uint64 28 | req2xx uint64 29 | req3xx uint64 30 | req4xx uint64 31 | req5xx uint64 32 | others uint64 33 | 34 | conf config 35 | barrier completionBarrier 36 | ratelimiter limiter 37 | wg sync.WaitGroup 38 | 39 | timeTaken time.Duration 40 | latencies *uhist.Histogram 41 | requests *fhist.Histogram 42 | 43 | client client 44 | doneChan chan struct{} 45 | 46 | // RPS metrics 47 | rpl sync.Mutex 48 | reqs int64 49 | start time.Time 50 | 51 | // Errors 52 | errors *errorMap 53 | 54 | // Progress bar 55 | bar *pb.ProgressBar 56 | 57 | // Output 58 | out io.Writer 59 | template *template.Template 60 | } 61 | 62 | func newBombardier(c config) (*bombardier, error) { 63 | if err := c.checkArgs(); err != nil { 64 | return nil, err 65 | } 66 | b := new(bombardier) 67 | b.conf = c 68 | b.latencies = uhist.Default() 69 | b.requests = fhist.Default() 70 | 71 | if b.conf.testType() == counted { 72 | b.bar = pb.New64(int64(*b.conf.numReqs)) 73 | b.bar.ShowSpeed = true 74 | } else if b.conf.testType() == timed { 75 | b.bar = pb.New64(b.conf.duration.Nanoseconds() / 1e9) 76 | b.bar.ShowCounters = false 77 | b.bar.ShowPercent = false 78 | } 79 | b.bar.ManualUpdate = true 80 | 81 | if b.conf.testType() == counted { 82 | b.barrier = newCountingCompletionBarrier(*b.conf.numReqs) 83 | } else { 84 | b.barrier = newTimedCompletionBarrier(*b.conf.duration) 85 | } 86 | 87 | if b.conf.rate != nil { 88 | b.ratelimiter = newBucketLimiter(*b.conf.rate) 89 | } else { 90 | b.ratelimiter = &nooplimiter{} 91 | } 92 | 93 | b.out = os.Stdout 94 | 95 | tlsConfig, err := generateTLSConfig(c) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | var ( 101 | pbody *string 102 | bsp bodyStreamProducer 103 | ) 104 | if c.stream { 105 | if c.bodyFilePath != "" { 106 | bsp = func() (io.ReadCloser, error) { 107 | return os.Open(c.bodyFilePath) 108 | } 109 | } else { 110 | bsp = func() (io.ReadCloser, error) { 111 | return ioutil.NopCloser( 112 | proxyReader{strings.NewReader(c.body)}, 113 | ), nil 114 | } 115 | } 116 | } else { 117 | pbody = &c.body 118 | if c.bodyFilePath != "" { 119 | var bodyBytes []byte 120 | bodyBytes, err = ioutil.ReadFile(c.bodyFilePath) 121 | if err != nil { 122 | return nil, err 123 | } 124 | sbody := string(bodyBytes) 125 | pbody = &sbody 126 | } 127 | } 128 | 129 | cc := &clientOpts{ 130 | HTTP2: false, 131 | maxConns: c.numConns, 132 | timeout: c.timeout, 133 | tlsConfig: tlsConfig, 134 | disableKeepAlives: c.disableKeepAlives, 135 | 136 | headers: c.headers, 137 | requestURL: c.url, 138 | method: c.method, 139 | body: pbody, 140 | bodProd: bsp, 141 | bytesRead: &b.bytesRead, 142 | bytesWritten: &b.bytesWritten, 143 | } 144 | b.client = makeHTTPClient(c.clientType, cc) 145 | 146 | if !b.conf.printProgress { 147 | b.bar.Output = ioutil.Discard 148 | b.bar.NotPrint = true 149 | } 150 | 151 | b.template, err = b.prepareTemplate() 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | b.wg.Add(int(c.numConns)) 157 | b.errors = newErrorMap() 158 | b.doneChan = make(chan struct{}, 2) 159 | return b, nil 160 | } 161 | 162 | func makeHTTPClient(clientType clientTyp, cc *clientOpts) client { 163 | var cl client 164 | switch clientType { 165 | case nhttp1: 166 | cl = newHTTPClient(cc) 167 | case nhttp2: 168 | cc.HTTP2 = true 169 | cl = newHTTPClient(cc) 170 | case fhttp: 171 | fallthrough 172 | default: 173 | cl = newFastHTTPClient(cc) 174 | } 175 | return cl 176 | } 177 | 178 | func (b *bombardier) prepareTemplate() (*template.Template, error) { 179 | var ( 180 | templateBytes []byte 181 | err error 182 | ) 183 | switch f := b.conf.format.(type) { 184 | case knownFormat: 185 | templateBytes = f.template() 186 | case userDefinedTemplate: 187 | templateBytes, err = ioutil.ReadFile(string(f)) 188 | if err != nil { 189 | return nil, err 190 | } 191 | default: 192 | panic("format can't be nil at this point, this is a bug") 193 | } 194 | outputTemplate, err := template.New("output-template"). 195 | Funcs(template.FuncMap{ 196 | "WithLatencies": func() bool { 197 | return b.conf.printLatencies 198 | }, 199 | "FormatBinary": formatBinary, 200 | "FormatTimeUs": formatTimeUs, 201 | "FormatTimeUsUint64": func(us uint64) string { 202 | return formatTimeUs(float64(us)) 203 | }, 204 | "FloatsToArray": func(ps ...float64) []float64 { 205 | return ps 206 | }, 207 | "Multiply": func(num, coeff float64) float64 { 208 | return num * coeff 209 | }, 210 | "StringToBytes": func(s string) []byte { 211 | return []byte(s) 212 | }, 213 | "UUIDV1": uuid.NewV1, 214 | "UUIDV2": uuid.NewV2, 215 | "UUIDV3": uuid.NewV3, 216 | "UUIDV4": uuid.NewV4, 217 | "UUIDV5": uuid.NewV5, 218 | }).Parse(string(templateBytes)) 219 | 220 | if err != nil { 221 | return nil, err 222 | } 223 | return outputTemplate, nil 224 | } 225 | 226 | func (b *bombardier) writeStatistics( 227 | code int, usTaken uint64, 228 | ) { 229 | b.latencies.Increment(usTaken) 230 | b.rpl.Lock() 231 | b.reqs++ 232 | b.rpl.Unlock() 233 | var counter *uint64 234 | switch code / 100 { 235 | case 1: 236 | counter = &b.req1xx 237 | case 2: 238 | counter = &b.req2xx 239 | case 3: 240 | counter = &b.req3xx 241 | case 4: 242 | counter = &b.req4xx 243 | case 5: 244 | counter = &b.req5xx 245 | default: 246 | counter = &b.others 247 | } 248 | atomic.AddUint64(counter, 1) 249 | } 250 | 251 | func (b *bombardier) performSingleRequest() { 252 | code, usTaken, err := b.client.do() 253 | if err != nil { 254 | b.errors.add(err) 255 | } 256 | b.writeStatistics(code, usTaken) 257 | } 258 | 259 | func (b *bombardier) worker() { 260 | done := b.barrier.done() 261 | for b.barrier.tryGrabWork() { 262 | if b.ratelimiter.pace(done) == brk { 263 | break 264 | } 265 | b.performSingleRequest() 266 | b.barrier.jobDone() 267 | } 268 | } 269 | 270 | func (b *bombardier) barUpdater() { 271 | done := b.barrier.done() 272 | for { 273 | select { 274 | case <-done: 275 | b.bar.Set64(b.bar.Total) 276 | b.bar.Update() 277 | b.bar.Finish() 278 | if b.conf.printProgress { 279 | fmt.Fprintln(b.out, "Done!") 280 | } 281 | b.doneChan <- struct{}{} 282 | return 283 | default: 284 | current := int64(b.barrier.completed() * float64(b.bar.Total)) 285 | b.bar.Set64(current) 286 | b.bar.Update() 287 | time.Sleep(b.bar.RefreshRate) 288 | } 289 | } 290 | } 291 | 292 | func (b *bombardier) rateMeter() { 293 | requestsInterval := 10 * time.Millisecond 294 | if b.conf.rate != nil { 295 | requestsInterval, _ = estimate(*b.conf.rate, rateLimitInterval) 296 | } 297 | requestsInterval += 10 * time.Millisecond 298 | ticker := time.NewTicker(requestsInterval) 299 | defer ticker.Stop() 300 | done := b.barrier.done() 301 | for { 302 | select { 303 | case <-ticker.C: 304 | b.recordRps() 305 | continue 306 | case <-done: 307 | b.wg.Wait() 308 | b.recordRps() 309 | b.doneChan <- struct{}{} 310 | return 311 | } 312 | } 313 | } 314 | 315 | func (b *bombardier) recordRps() { 316 | b.rpl.Lock() 317 | duration := time.Since(b.start) 318 | reqs := b.reqs 319 | b.reqs = 0 320 | b.start = time.Now() 321 | b.rpl.Unlock() 322 | 323 | reqsf := float64(reqs) / duration.Seconds() 324 | b.requests.Increment(reqsf) 325 | } 326 | 327 | func (b *bombardier) bombard() { 328 | if b.conf.printIntro { 329 | b.printIntro() 330 | } 331 | b.bar.Start() 332 | bombardmentBegin := time.Now() 333 | b.start = time.Now() 334 | for i := uint64(0); i < b.conf.numConns; i++ { 335 | go func() { 336 | defer b.wg.Done() 337 | b.worker() 338 | }() 339 | } 340 | go b.rateMeter() 341 | go b.barUpdater() 342 | b.wg.Wait() 343 | b.timeTaken = time.Since(bombardmentBegin) 344 | <-b.doneChan 345 | <-b.doneChan 346 | } 347 | 348 | func (b *bombardier) printIntro() { 349 | if b.conf.testType() == counted { 350 | fmt.Fprintf(b.out, 351 | "Bombarding %v with %v request(s) using %v connection(s)\n", 352 | b.conf.url, *b.conf.numReqs, b.conf.numConns) 353 | } else if b.conf.testType() == timed { 354 | fmt.Fprintf(b.out, "Bombarding %v for %v using %v connection(s)\n", 355 | b.conf.url, *b.conf.duration, b.conf.numConns) 356 | } 357 | } 358 | 359 | func (b *bombardier) gatherInfo() internal.TestInfo { 360 | info := internal.TestInfo{ 361 | Spec: internal.Spec{ 362 | NumberOfConnections: b.conf.numConns, 363 | 364 | Method: b.conf.method, 365 | URL: b.conf.url, 366 | 367 | Body: b.conf.body, 368 | BodyFilePath: b.conf.bodyFilePath, 369 | 370 | CertPath: b.conf.certPath, 371 | KeyPath: b.conf.keyPath, 372 | 373 | Stream: b.conf.stream, 374 | Timeout: b.conf.timeout, 375 | ClientType: internal.ClientType(b.conf.clientType), 376 | 377 | Rate: b.conf.rate, 378 | }, 379 | Result: internal.Results{ 380 | BytesRead: b.bytesRead, 381 | BytesWritten: b.bytesWritten, 382 | TimeTaken: b.timeTaken, 383 | 384 | Req1XX: b.req1xx, 385 | Req2XX: b.req2xx, 386 | Req3XX: b.req3xx, 387 | Req4XX: b.req4xx, 388 | Req5XX: b.req5xx, 389 | Others: b.others, 390 | 391 | Latencies: b.latencies, 392 | Requests: b.requests, 393 | }, 394 | } 395 | 396 | testType := b.conf.testType() 397 | info.Spec.TestType = internal.TestType(testType) 398 | if testType == timed { 399 | info.Spec.TestDuration = *b.conf.duration 400 | } else if testType == counted { 401 | info.Spec.NumberOfRequests = *b.conf.numReqs 402 | } 403 | 404 | if b.conf.headers != nil { 405 | for _, h := range *b.conf.headers { 406 | info.Spec.Headers = append(info.Spec.Headers, 407 | internal.Header{ 408 | Key: h.key, 409 | Value: h.value, 410 | }) 411 | } 412 | } 413 | 414 | for _, ewc := range b.errors.byFrequency() { 415 | info.Result.Errors = append(info.Result.Errors, 416 | internal.ErrorWithCount{ 417 | Error: ewc.error, 418 | Count: ewc.count, 419 | }) 420 | } 421 | 422 | return info 423 | } 424 | 425 | func (b *bombardier) printStats() { 426 | info := b.gatherInfo() 427 | err := b.template.Execute(b.out, info) 428 | if err != nil { 429 | fmt.Fprintln(os.Stderr, err) 430 | } 431 | } 432 | 433 | func (b *bombardier) redirectOutputTo(out io.Writer) { 434 | b.bar.Output = out 435 | b.out = out 436 | } 437 | 438 | func (b *bombardier) disableOutput() { 439 | b.redirectOutputTo(ioutil.Discard) 440 | b.bar.NotPrint = true 441 | } 442 | 443 | func main() { 444 | cfg, err := parser.parse(os.Args) 445 | if err != nil { 446 | fmt.Println("Error parsing the arguments:", err) 447 | os.Exit(exitFailure) 448 | } 449 | bombardier, err := newBombardier(cfg) 450 | if err != nil { 451 | fmt.Println("Error initializing bombardier:", err) 452 | os.Exit(exitFailure) 453 | } 454 | c := make(chan os.Signal, 1) 455 | signal.Notify(c, os.Interrupt) 456 | go func() { 457 | <-c 458 | bombardier.barrier.cancel() 459 | }() 460 | bombardier.bombard() 461 | if bombardier.conf.printResult { 462 | bombardier.printStats() 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /bombardier_performance_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "runtime" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var ( 11 | serverPort = flag.String("port", "8080", "port to use for benchmarks") 12 | clientType = flag.String("client-type", "fasthttp", 13 | "client to use in benchmarks") 14 | ) 15 | 16 | var ( 17 | longDuration = 9001 * time.Hour 18 | highRate = uint64(1000000) 19 | ) 20 | 21 | func BenchmarkBombardierSingleReqPerf(b *testing.B) { 22 | addr := "localhost:" + *serverPort 23 | benchmarkFireRequest(config{ 24 | numConns: defaultNumberOfConns, 25 | numReqs: nil, 26 | duration: &longDuration, 27 | url: ParseURLOrPanic("http://" + addr), 28 | headers: new(headersList), 29 | timeout: defaultTimeout, 30 | method: "GET", 31 | body: "", 32 | printLatencies: false, 33 | clientType: clientTypeFromString(*clientType), 34 | format: knownFormat("json"), 35 | }, b) 36 | } 37 | 38 | func BenchmarkBombardierRateLimitPerf(b *testing.B) { 39 | addr := "localhost:" + *serverPort 40 | benchmarkFireRequest(config{ 41 | numConns: defaultNumberOfConns, 42 | numReqs: nil, 43 | duration: &longDuration, 44 | url: ParseURLOrPanic("http://" + addr), 45 | headers: new(headersList), 46 | timeout: defaultTimeout, 47 | method: "GET", 48 | body: "", 49 | printLatencies: false, 50 | rate: &highRate, 51 | clientType: clientTypeFromString(*clientType), 52 | format: knownFormat("json"), 53 | }, b) 54 | } 55 | 56 | func benchmarkFireRequest(c config, bm *testing.B) { 57 | b, e := newBombardier(c) 58 | if e != nil { 59 | bm.Error(e) 60 | } 61 | b.disableOutput() 62 | bm.SetParallelism(int(defaultNumberOfConns) / runtime.NumCPU()) 63 | bm.ResetTimer() 64 | bm.RunParallel(func(pb *testing.PB) { 65 | done := b.barrier.done() 66 | for pb.Next() { 67 | b.ratelimiter.pace(done) 68 | b.performSingleRequest() 69 | } 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /bombardier_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "container/ring" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "errors" 9 | "io/ioutil" 10 | "net/http" 11 | "net/http/httptest" 12 | "os" 13 | "reflect" 14 | "sync" 15 | "sync/atomic" 16 | "testing" 17 | "time" 18 | ) 19 | 20 | func TestBombardierShouldFireSpecifiedNumberOfRequests(t *testing.T) { 21 | testAllClients(t, testBombardierShouldFireSpecifiedNumberOfRequests) 22 | } 23 | 24 | func testBombardierShouldFireSpecifiedNumberOfRequests( 25 | clientType clientTyp, t *testing.T, 26 | ) { 27 | reqsReceived := uint64(0) 28 | s := httptest.NewServer( 29 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 30 | atomic.AddUint64(&reqsReceived, 1) 31 | }), 32 | ) 33 | defer s.Close() 34 | numReqs := uint64(100) 35 | noHeaders := new(headersList) 36 | b, e := newBombardier(config{ 37 | numConns: defaultNumberOfConns, 38 | numReqs: &numReqs, 39 | url: ParseURLOrPanic(s.URL), 40 | headers: noHeaders, 41 | timeout: defaultTimeout, 42 | method: "GET", 43 | body: "", 44 | clientType: clientType, 45 | format: knownFormat("plain-text"), 46 | }) 47 | if e != nil { 48 | t.Error(e) 49 | } 50 | b.disableOutput() 51 | b.bombard() 52 | if reqsReceived != numReqs { 53 | t.Fail() 54 | } 55 | } 56 | 57 | func TestBombardierShouldFinish(t *testing.T) { 58 | testAllClients(t, testBombardierShouldFinish) 59 | } 60 | 61 | func testBombardierShouldFinish(clientType clientTyp, t *testing.T) { 62 | reqsReceived := uint64(0) 63 | s := httptest.NewServer( 64 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 65 | atomic.AddUint64(&reqsReceived, 1) 66 | }), 67 | ) 68 | defer s.Close() 69 | noHeaders := new(headersList) 70 | desiredTestDuration := 1 * time.Second 71 | b, e := newBombardier(config{ 72 | numConns: defaultNumberOfConns, 73 | duration: &desiredTestDuration, 74 | url: ParseURLOrPanic(s.URL), 75 | headers: noHeaders, 76 | timeout: defaultTimeout, 77 | method: "GET", 78 | body: "", 79 | clientType: clientType, 80 | format: knownFormat("plain-text"), 81 | }) 82 | if e != nil { 83 | t.Error(e) 84 | } 85 | b.disableOutput() 86 | waitCh := make(chan struct{}) 87 | go func() { 88 | b.bombard() 89 | waitCh <- struct{}{} 90 | }() 91 | select { 92 | case <-waitCh: 93 | // Do nothing here 94 | case <-time.After(desiredTestDuration + 5*time.Second): 95 | t.Fail() 96 | } 97 | if reqsReceived == 0 { 98 | t.Fail() 99 | } 100 | } 101 | 102 | func TestBombardierShouldSendHeaders(t *testing.T) { 103 | testAllClients(t, testBombardierShouldSendHeaders) 104 | } 105 | 106 | func testBombardierShouldSendHeaders(clientType clientTyp, t *testing.T) { 107 | requestHeaders := headersList([]header{ 108 | {"Header1", "Value1"}, 109 | {"Header-Two", "value-two"}, 110 | }) 111 | 112 | // It's a bit hacky, but FastHTTP can't send Host header correctly 113 | // as of now 114 | if clientType != fhttp { 115 | requestHeaders = append(requestHeaders, header{"Host", "web"}) 116 | } 117 | 118 | s := httptest.NewServer( 119 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 120 | for _, h := range requestHeaders { 121 | av := r.Header.Get(h.key) 122 | if h.key == "Host" { 123 | av = r.Host 124 | } 125 | if av != h.value { 126 | t.Logf("%q <-> %q", av, h.value) 127 | t.Fail() 128 | } 129 | } 130 | }), 131 | ) 132 | defer s.Close() 133 | numReqs := uint64(1) 134 | b, e := newBombardier(config{ 135 | numConns: defaultNumberOfConns, 136 | numReqs: &numReqs, 137 | url: ParseURLOrPanic(s.URL), 138 | headers: &requestHeaders, 139 | timeout: defaultTimeout, 140 | method: "GET", 141 | body: "", 142 | clientType: clientType, 143 | format: knownFormat("plain-text"), 144 | }) 145 | if e != nil { 146 | t.Error(e) 147 | } 148 | b.disableOutput() 149 | b.bombard() 150 | } 151 | 152 | func TestBombardierHTTPCodeRecording(t *testing.T) { 153 | testAllClients(t, testBombardierHTTPCodeRecording) 154 | } 155 | 156 | func testBombardierHTTPCodeRecording(clientType clientTyp, t *testing.T) { 157 | cs := []int{200, 302, 404, 505, 606, 707} 158 | codes := ring.New(len(cs)) 159 | for _, v := range cs { 160 | codes.Value = v 161 | codes = codes.Next() 162 | } 163 | codes = codes.Next() 164 | var m sync.Mutex 165 | s := httptest.NewServer( 166 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 167 | m.Lock() 168 | nextCode := codes.Value.(int) 169 | codes = codes.Next() 170 | m.Unlock() 171 | if nextCode/100 == 3 { 172 | rw.Header().Set("Location", "http://localhost:666") 173 | } 174 | rw.WriteHeader(nextCode) 175 | }), 176 | ) 177 | defer s.Close() 178 | eachCodeCount := uint64(10) 179 | numReqs := uint64(len(cs)) * eachCodeCount 180 | b, e := newBombardier(config{ 181 | numConns: defaultNumberOfConns, 182 | numReqs: &numReqs, 183 | url: ParseURLOrPanic(s.URL), 184 | headers: new(headersList), 185 | timeout: defaultTimeout, 186 | method: "GET", 187 | body: "", 188 | clientType: clientType, 189 | format: knownFormat("plain-text"), 190 | }) 191 | if e != nil { 192 | t.Error(e) 193 | } 194 | b.disableOutput() 195 | b.bombard() 196 | expectation := []struct { 197 | name string 198 | reqsGot uint64 199 | expected uint64 200 | }{ 201 | {"errored", b.others, eachCodeCount * 2}, 202 | {"2xx", b.req2xx, eachCodeCount}, 203 | {"3xx", b.req3xx, eachCodeCount}, 204 | {"4xx", b.req4xx, eachCodeCount}, 205 | {"5xx", b.req5xx, eachCodeCount}, 206 | } 207 | for _, e := range expectation { 208 | if e.reqsGot != e.expected { 209 | t.Error(e.name, e.reqsGot, e.expected) 210 | } 211 | } 212 | t.Logf("%+v", b.errors.byFrequency()) 213 | } 214 | 215 | func TestBombardierTimeoutRecoding(t *testing.T) { 216 | testAllClients(t, testBombardierTimeoutRecoding) 217 | } 218 | 219 | func testBombardierTimeoutRecoding(clientType clientTyp, t *testing.T) { 220 | shortTimeout := 10 * time.Millisecond 221 | s := httptest.NewServer( 222 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 223 | time.Sleep(shortTimeout * 10) 224 | }), 225 | ) 226 | defer s.Close() 227 | numReqs := uint64(10) 228 | b, e := newBombardier(config{ 229 | numConns: defaultNumberOfConns, 230 | numReqs: &numReqs, 231 | duration: nil, 232 | url: ParseURLOrPanic(s.URL), 233 | headers: new(headersList), 234 | timeout: shortTimeout, 235 | method: "GET", 236 | body: "", 237 | clientType: clientType, 238 | format: knownFormat("plain-text"), 239 | }) 240 | if e != nil { 241 | t.Error(e) 242 | } 243 | b.disableOutput() 244 | b.bombard() 245 | if b.errors.sum() != numReqs { 246 | t.Fail() 247 | } 248 | } 249 | 250 | func TestBombardierThroughputRecording(t *testing.T) { 251 | testAllClients(t, testBombardierThroughputRecording) 252 | } 253 | 254 | func testBombardierThroughputRecording(clientType clientTyp, t *testing.T) { 255 | responseSize := 1024 256 | response := bytes.Repeat([]byte{'a'}, responseSize) 257 | s := httptest.NewServer( 258 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 259 | _, err := rw.Write(response) 260 | if err != nil { 261 | t.Error(err) 262 | } 263 | }), 264 | ) 265 | defer s.Close() 266 | numReqs := uint64(10) 267 | b, e := newBombardier(config{ 268 | numConns: defaultNumberOfConns, 269 | numReqs: &numReqs, 270 | url: ParseURLOrPanic(s.URL), 271 | headers: new(headersList), 272 | timeout: defaultTimeout, 273 | method: "GET", 274 | body: "", 275 | clientType: clientType, 276 | format: knownFormat("plain-text"), 277 | }) 278 | if e != nil { 279 | t.Error(e) 280 | } 281 | b.disableOutput() 282 | b.bombard() 283 | if b.bytesRead == 0 || b.bytesWritten == 0 { 284 | t.Error(b.bytesRead, b.bytesWritten) 285 | } 286 | } 287 | 288 | func TestBombardierStatsPrinting(t *testing.T) { 289 | responseSize := 1024 290 | response := bytes.Repeat([]byte{'a'}, responseSize) 291 | s := httptest.NewServer( 292 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 293 | _, err := rw.Write(response) 294 | if err != nil { 295 | t.Error(err) 296 | } 297 | }), 298 | ) 299 | defer s.Close() 300 | numReqs := uint64(10) 301 | b, e := newBombardier(config{ 302 | numConns: defaultNumberOfConns, 303 | numReqs: &numReqs, 304 | url: ParseURLOrPanic(s.URL), 305 | headers: new(headersList), 306 | timeout: defaultTimeout, 307 | method: "GET", 308 | body: "", 309 | printLatencies: true, 310 | printIntro: true, 311 | printProgress: true, 312 | printResult: true, 313 | format: knownFormat("plain-text"), 314 | }) 315 | if e != nil { 316 | t.Error(e) 317 | return 318 | } 319 | dummy := errors.New("dummy error") 320 | b.errors.add(dummy) 321 | 322 | out := new(bytes.Buffer) 323 | b.redirectOutputTo(out) 324 | b.bombard() 325 | 326 | b.printStats() 327 | l := out.Len() 328 | // Here we only test if anything is written 329 | if l == 0 { 330 | t.Fail() 331 | } 332 | } 333 | 334 | func TestBombardierErrorIfFailToReadClientCert(t *testing.T) { 335 | numReqs := uint64(10) 336 | _, e := newBombardier(config{ 337 | numConns: defaultNumberOfConns, 338 | numReqs: &numReqs, 339 | url: ParseURLOrPanic("http://localhost"), 340 | headers: new(headersList), 341 | timeout: defaultTimeout, 342 | method: "GET", 343 | body: "", 344 | printLatencies: true, 345 | certPath: "certPath", 346 | keyPath: "keyPath", 347 | format: knownFormat("plain-text"), 348 | }) 349 | if e == nil { 350 | t.Fail() 351 | } 352 | } 353 | 354 | func TestBombardierClientCerts(t *testing.T) { 355 | testAllClients(t, testBombardierClientCerts) 356 | } 357 | 358 | func testBombardierClientCerts(clientType clientTyp, t *testing.T) { 359 | clientCert, err := tls.LoadX509KeyPair("testclient.cert", "testclient.key") 360 | if err != nil { 361 | t.Error(err) 362 | return 363 | } 364 | 365 | clientX509Cert, err := x509.ParseCertificate(clientCert.Certificate[0]) 366 | if err != nil { 367 | t.Error(err) 368 | return 369 | } 370 | 371 | servertCert, err := tls.LoadX509KeyPair("testserver.cert", "testserver.key") 372 | if err != nil { 373 | t.Error(err) 374 | return 375 | } 376 | 377 | tlsConfig := &tls.Config{ 378 | ClientAuth: tls.RequireAnyClientCert, 379 | Certificates: []tls.Certificate{servertCert}, 380 | } 381 | 382 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 383 | certs := r.TLS.PeerCertificates 384 | if numCerts := len(certs); numCerts != 1 { 385 | t.Errorf("expected 1 cert, but got %v", numCerts) 386 | rw.WriteHeader(http.StatusBadRequest) 387 | return 388 | } 389 | cert := certs[0] 390 | if !cert.Equal(clientX509Cert) { 391 | t.Error("certificates don't match") 392 | rw.WriteHeader(http.StatusBadRequest) 393 | return 394 | } 395 | rw.WriteHeader(http.StatusOK) 396 | })) 397 | 398 | server.TLS = tlsConfig 399 | server.StartTLS() 400 | 401 | singleRequest := uint64(1) 402 | b, e := newBombardier(config{ 403 | numConns: defaultNumberOfConns, 404 | numReqs: &singleRequest, 405 | url: ParseURLOrPanic(server.URL), 406 | headers: new(headersList), 407 | timeout: defaultTimeout, 408 | method: "GET", 409 | body: "", 410 | printLatencies: true, 411 | certPath: "testclient.cert", 412 | keyPath: "testclient.key", 413 | insecure: true, 414 | clientType: clientType, 415 | format: knownFormat("plain-text"), 416 | }) 417 | if e != nil { 418 | t.Error(e) 419 | return 420 | } 421 | b.disableOutput() 422 | 423 | b.bombard() 424 | if b.req2xx != 1 { 425 | t.Error("no 2xx responses, total =", b.reqs, ", 1xx/2xx/3xx/4xx/5xx =", b.req1xx, b.req2xx, b.req3xx, b.req4xx, b.req5xx) 426 | } 427 | 428 | server.Close() 429 | } 430 | 431 | func TestBombardierRateLimiting(t *testing.T) { 432 | testAllClients(t, testBombardierRateLimiting) 433 | } 434 | 435 | func testBombardierRateLimiting(clientType clientTyp, t *testing.T) { 436 | responseSize := 1024 437 | response := bytes.Repeat([]byte{'a'}, responseSize) 438 | s := httptest.NewServer( 439 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 440 | _, err := rw.Write(response) 441 | if err != nil { 442 | t.Error(err) 443 | } 444 | }), 445 | ) 446 | defer s.Close() 447 | rate := uint64(5000) 448 | testDuration := 1 * time.Second 449 | b, e := newBombardier(config{ 450 | numConns: defaultNumberOfConns, 451 | duration: &testDuration, 452 | url: ParseURLOrPanic(s.URL), 453 | headers: new(headersList), 454 | timeout: defaultTimeout, 455 | method: "GET", 456 | body: "", 457 | rate: &rate, 458 | clientType: clientType, 459 | format: knownFormat("plain-text"), 460 | }) 461 | if e != nil { 462 | t.Error(e) 463 | return 464 | } 465 | b.disableOutput() 466 | b.bombard() 467 | if float64(b.req2xx) < float64(rate)*0.75 || 468 | float64(b.req2xx) > float64(rate)*1.25 { 469 | t.Error(rate, b.req2xx) 470 | } 471 | } 472 | 473 | func testAllClients(parent *testing.T, testFun func(clientTyp, *testing.T)) { 474 | clients := []clientTyp{fhttp, nhttp1, nhttp2} 475 | for _, ct := range clients { 476 | parent.Run(ct.String(), func(t *testing.T) { 477 | testFun(ct, t) 478 | }) 479 | } 480 | } 481 | 482 | func TestBombardierSendsBody(t *testing.T) { 483 | testAllClients(t, testBombardierSendsBody) 484 | } 485 | 486 | func testBombardierSendsBody(clientType clientTyp, t *testing.T) { 487 | response := []byte("OK") 488 | requestBody := "abracadabra" 489 | s := httptest.NewServer( 490 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 491 | body, err := ioutil.ReadAll(r.Body) 492 | if err != nil { 493 | t.Error(err) 494 | return 495 | } 496 | if string(body) != requestBody { 497 | t.Errorf("Expected %v, but got %v", requestBody, string(body)) 498 | } 499 | _, err = rw.Write(response) 500 | if err != nil { 501 | t.Error(err) 502 | } 503 | }), 504 | ) 505 | defer s.Close() 506 | one := uint64(1) 507 | b, e := newBombardier(config{ 508 | numConns: defaultNumberOfConns, 509 | numReqs: &one, 510 | url: ParseURLOrPanic(s.URL), 511 | headers: new(headersList), 512 | timeout: defaultTimeout, 513 | method: "POST", 514 | body: requestBody, 515 | clientType: clientType, 516 | format: knownFormat("plain-text"), 517 | }) 518 | if e != nil { 519 | t.Error(e) 520 | return 521 | } 522 | b.disableOutput() 523 | b.bombard() 524 | } 525 | 526 | func TestBombardierSendsBodyFromFile(t *testing.T) { 527 | testAllClients(t, testBombardierSendsBodyFromFile) 528 | } 529 | 530 | func testBombardierSendsBodyFromFile(clientType clientTyp, t *testing.T) { 531 | response := []byte("OK") 532 | bodyPath := "testbody.txt" 533 | requestBody, err := ioutil.ReadFile(bodyPath) 534 | if err != nil { 535 | t.Error(err) 536 | return 537 | } 538 | s := httptest.NewServer( 539 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 540 | body, err := ioutil.ReadAll(r.Body) 541 | if err != nil { 542 | t.Error(err) 543 | return 544 | } 545 | if string(body) != string(requestBody) { 546 | t.Errorf("Expected %v, but got %v", string(requestBody), string(body)) 547 | } 548 | _, err = rw.Write(response) 549 | if err != nil { 550 | t.Error(err) 551 | } 552 | }), 553 | ) 554 | defer s.Close() 555 | one := uint64(1) 556 | b, e := newBombardier(config{ 557 | numConns: defaultNumberOfConns, 558 | numReqs: &one, 559 | url: ParseURLOrPanic(s.URL), 560 | headers: new(headersList), 561 | timeout: defaultTimeout, 562 | method: "POST", 563 | bodyFilePath: bodyPath, 564 | clientType: clientType, 565 | format: knownFormat("plain-text"), 566 | }) 567 | if e != nil { 568 | t.Error(e) 569 | return 570 | } 571 | b.disableOutput() 572 | b.bombard() 573 | } 574 | 575 | func TestBombardierFileDoesntExist(t *testing.T) { 576 | bodyPath := "/does/not/exist.forreal" 577 | _, e := newBombardier(config{ 578 | numConns: defaultNumberOfConns, 579 | url: ParseURLOrPanic("http://example.com"), 580 | headers: new(headersList), 581 | timeout: defaultTimeout, 582 | method: "POST", 583 | bodyFilePath: bodyPath, 584 | format: knownFormat("plain-text"), 585 | }) 586 | _, ok := e.(*os.PathError) 587 | if !ok { 588 | t.Errorf("Expected to get PathError, but got %v", e) 589 | } 590 | } 591 | 592 | func TestBombardierStreamsBody(t *testing.T) { 593 | testAllClients(t, testBombardierStreamsBody) 594 | } 595 | 596 | func testBombardierStreamsBody(clientType clientTyp, t *testing.T) { 597 | response := []byte("OK") 598 | requestBody := "abracadabra" 599 | s := httptest.NewServer( 600 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 601 | if te := r.TransferEncoding; !reflect.DeepEqual(te, []string{"chunked"}) { 602 | t.Errorf("Expected chunked transfer encoding, but got %v", te) 603 | } 604 | body, err := ioutil.ReadAll(r.Body) 605 | if err != nil { 606 | t.Error(err) 607 | return 608 | } 609 | if string(body) != requestBody { 610 | t.Errorf("Expected %v, but got %v", requestBody, string(body)) 611 | } 612 | _, err = rw.Write(response) 613 | if err != nil { 614 | t.Error(err) 615 | } 616 | }), 617 | ) 618 | defer s.Close() 619 | one := uint64(1) 620 | b, e := newBombardier(config{ 621 | numConns: defaultNumberOfConns, 622 | numReqs: &one, 623 | url: ParseURLOrPanic(s.URL), 624 | headers: new(headersList), 625 | timeout: defaultTimeout, 626 | method: "POST", 627 | body: requestBody, 628 | stream: true, 629 | clientType: clientType, 630 | format: knownFormat("plain-text"), 631 | }) 632 | if e != nil { 633 | t.Error(e) 634 | return 635 | } 636 | b.disableOutput() 637 | b.bombard() 638 | } 639 | 640 | func TestBombardierStreamsBodyFromFile(t *testing.T) { 641 | testAllClients(t, testBombardierStreamsBodyFromFile) 642 | } 643 | 644 | func testBombardierStreamsBodyFromFile(clientType clientTyp, t *testing.T) { 645 | response := []byte("OK") 646 | bodyPath := "testbody.txt" 647 | requestBody, err := ioutil.ReadFile(bodyPath) 648 | if err != nil { 649 | t.Error(err) 650 | return 651 | } 652 | s := httptest.NewServer( 653 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 654 | if te := r.TransferEncoding; !reflect.DeepEqual(te, []string{"chunked"}) { 655 | t.Errorf("Expected chunked transfer encoding, but got %v", te) 656 | } 657 | body, err := ioutil.ReadAll(r.Body) 658 | if err != nil { 659 | t.Error(err) 660 | return 661 | } 662 | if string(body) != string(requestBody) { 663 | t.Errorf("Expected %v, but got %v", string(requestBody), string(body)) 664 | } 665 | _, err = rw.Write(response) 666 | if err != nil { 667 | t.Error(err) 668 | } 669 | }), 670 | ) 671 | defer s.Close() 672 | one := uint64(1) 673 | b, e := newBombardier(config{ 674 | numConns: defaultNumberOfConns, 675 | numReqs: &one, 676 | url: ParseURLOrPanic(s.URL), 677 | headers: new(headersList), 678 | timeout: defaultTimeout, 679 | method: "POST", 680 | bodyFilePath: bodyPath, 681 | stream: true, 682 | clientType: clientType, 683 | format: knownFormat("plain-text"), 684 | }) 685 | if e != nil { 686 | t.Error(e) 687 | return 688 | } 689 | b.disableOutput() 690 | b.bombard() 691 | } 692 | 693 | func TestBombardierShouldSendCustomHostHeader(t *testing.T) { 694 | testAllClients(t, testBombardierShouldSendCustomHostHeader) 695 | } 696 | 697 | func testBombardierShouldSendCustomHostHeader( 698 | clientType clientTyp, t *testing.T, 699 | ) { 700 | host := "custom-host" 701 | s := httptest.NewServer( 702 | http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 703 | if r.Host != host { 704 | t.Errorf("Host must be %q, but it's %q", host, r.Host) 705 | } 706 | }), 707 | ) 708 | defer s.Close() 709 | numReqs := uint64(100) 710 | headers := headersList([]header{ 711 | {"Host", host}, 712 | }) 713 | b, e := newBombardier(config{ 714 | numConns: defaultNumberOfConns, 715 | numReqs: &numReqs, 716 | url: ParseURLOrPanic(s.URL), 717 | headers: &headers, 718 | timeout: defaultTimeout, 719 | method: "GET", 720 | body: "", 721 | clientType: clientType, 722 | format: knownFormat("plain-text"), 723 | }) 724 | if e != nil { 725 | t.Error(e) 726 | } 727 | b.disableOutput() 728 | b.bombard() 729 | } 730 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import subprocess 4 | 5 | platforms = [ 6 | ("darwin", "amd64"), 7 | ("darwin", "arm64"), 8 | ("freebsd", "386"), 9 | ("freebsd", "amd64"), 10 | ("freebsd", "arm"), 11 | ("linux", "386"), 12 | ("linux", "amd64"), 13 | ("linux", "arm"), 14 | ("linux", "arm64"), 15 | ("netbsd", "386"), 16 | ("netbsd", "amd64"), 17 | ("netbsd", "arm"), 18 | ("openbsd", "386"), 19 | ("openbsd", "amd64"), 20 | ("openbsd", "arm"), 21 | ("openbsd", "arm64"), 22 | ("windows", "386"), 23 | ("windows", "amd64"), 24 | ("windows", "arm64"), 25 | ] 26 | 27 | 28 | if __name__ == "__main__": 29 | parser = argparse.ArgumentParser(description="Auxilary build script.") 30 | parser.add_argument("-v", "--version", default="unspecified", 31 | type=str, help="string used as a version when building binaries") 32 | args = parser.parse_args() 33 | version = args.version 34 | for (build_os, build_arch) in platforms: 35 | ext = "" 36 | if build_os == "windows": 37 | ext = ".exe" 38 | build_env = os.environ.copy() 39 | build_env["GOOS"] = build_os 40 | build_env["GOARCH"] = build_arch 41 | subprocess.run(["go", "build", "-ldflags", "-s -w -X main.version=%s" % 42 | version, "-o", "bombardier-%s-%s%s" % (build_os, build_arch, ext)], env=build_env) 43 | -------------------------------------------------------------------------------- /client_cert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | ) 6 | 7 | // readClientCert - helper function to read client certificate 8 | // from pem formatted certPath and keyPath files 9 | func readClientCert(certPath, keyPath string) ([]tls.Certificate, error) { 10 | // load keypair 11 | cert, err := tls.LoadX509KeyPair(certPath, keyPath) 12 | return []tls.Certificate{cert}, err 13 | } 14 | 15 | // generateTLSConfig - helper function to generate a TLS configuration based on 16 | // config 17 | func generateTLSConfig(c config) (*tls.Config, error) { 18 | var ( 19 | certs []tls.Certificate 20 | err error 21 | ) 22 | // This assumes that the caller has validated that either both or none of 23 | // the c.certPath and c.keyPath are set. 24 | if c.certPath != "" && c.keyPath != "" { 25 | certs, err = readClientCert(c.certPath, c.keyPath) 26 | if err != nil { 27 | return nil, err 28 | } 29 | } 30 | 31 | // Disable gas warning, because InsecureSkipVerify may be set to true 32 | // for the purpose of testing 33 | /* #nosec */ 34 | tlsConfig := &tls.Config{ 35 | InsecureSkipVerify: c.insecure, 36 | Certificates: certs, 37 | } 38 | return tlsConfig, nil 39 | } 40 | -------------------------------------------------------------------------------- /client_cert_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGenerateTLSConfig(t *testing.T) { 8 | expectations := []struct { 9 | certPath string 10 | keyPath string 11 | errIsNil bool 12 | }{ 13 | { 14 | certPath: "testclient.cert", 15 | keyPath: "testclient.key", 16 | errIsNil: true, 17 | }, 18 | { 19 | certPath: "doesnotexist.pem", 20 | keyPath: "doesnotexist.pem", 21 | errIsNil: false, 22 | }, 23 | { 24 | certPath: "", 25 | keyPath: "", 26 | errIsNil: true, 27 | }, 28 | } 29 | for _, e := range expectations { 30 | _, r := generateTLSConfig( 31 | config{ 32 | url: ParseURLOrPanic("https://doesnt.exist.com"), 33 | certPath: e.certPath, 34 | keyPath: e.keyPath, 35 | }, 36 | ) 37 | if (r == nil) != e.errIsNil { 38 | t.Error(e.certPath, e.keyPath, r) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /clients.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | 12 | "github.com/valyala/fasthttp" 13 | ) 14 | 15 | type client interface { 16 | do() (code int, usTaken uint64, err error) 17 | } 18 | 19 | type bodyStreamProducer func() (io.ReadCloser, error) 20 | 21 | type clientOpts struct { 22 | HTTP2 bool 23 | 24 | maxConns uint64 25 | timeout time.Duration 26 | tlsConfig *tls.Config 27 | disableKeepAlives bool 28 | 29 | requestURL *url.URL 30 | headers *headersList 31 | method string 32 | 33 | body *string 34 | bodProd bodyStreamProducer 35 | 36 | bytesRead, bytesWritten *int64 37 | } 38 | 39 | type fasthttpClient struct { 40 | client *fasthttp.Client 41 | 42 | headers *fasthttp.RequestHeader 43 | uri *fasthttp.URI 44 | method string 45 | 46 | body *string 47 | bodProd bodyStreamProducer 48 | } 49 | 50 | func newFastHTTPClient(opts *clientOpts) client { 51 | c := new(fasthttpClient) 52 | uri := fasthttp.AcquireURI() 53 | if err := uri.Parse( 54 | []byte(opts.requestURL.Host), 55 | []byte(opts.requestURL.String()), 56 | ); err != nil { 57 | // opts.requestURL must always be valid 58 | panic(err) 59 | } 60 | c.uri = uri 61 | c.client = &fasthttp.Client{ 62 | MaxConnsPerHost: int(opts.maxConns), 63 | ReadTimeout: opts.timeout, 64 | WriteTimeout: opts.timeout, 65 | DisableHeaderNamesNormalizing: true, 66 | TLSConfig: opts.tlsConfig, 67 | Dial: fasthttpDialFunc( 68 | opts.bytesRead, opts.bytesWritten, 69 | opts.timeout, 70 | ), 71 | } 72 | c.headers = headersToFastHTTPHeaders(opts.headers) 73 | c.method, c.body = opts.method, opts.body 74 | c.bodProd = opts.bodProd 75 | return client(c) 76 | } 77 | 78 | func (c *fasthttpClient) do() ( 79 | code int, usTaken uint64, err error, 80 | ) { 81 | // prepare the request 82 | req := fasthttp.AcquireRequest() 83 | resp := fasthttp.AcquireResponse() 84 | if c.headers != nil { 85 | c.headers.CopyTo(&req.Header) 86 | } 87 | req.Header.SetMethod(c.method) 88 | req.SetURI(c.uri) 89 | req.UseHostHeader = true 90 | if c.body != nil { 91 | req.SetBodyString(*c.body) 92 | } else { 93 | bs, bserr := c.bodProd() 94 | if bserr != nil { 95 | return 0, 0, bserr 96 | } 97 | req.SetBodyStream(bs, -1) 98 | } 99 | 100 | // fire the request 101 | start := time.Now() 102 | err = c.client.Do(req, resp) 103 | if err != nil { 104 | code = -1 105 | } else { 106 | code = resp.StatusCode() 107 | } 108 | usTaken = uint64(time.Since(start).Nanoseconds() / 1000) 109 | 110 | // release resources 111 | fasthttp.ReleaseRequest(req) 112 | fasthttp.ReleaseResponse(resp) 113 | 114 | return 115 | } 116 | 117 | type httpClient struct { 118 | client *http.Client 119 | 120 | headers http.Header 121 | url *url.URL 122 | method string 123 | 124 | body *string 125 | bodProd bodyStreamProducer 126 | } 127 | 128 | func newHTTPClient(opts *clientOpts) client { 129 | c := new(httpClient) 130 | tr := &http.Transport{ 131 | TLSClientConfig: opts.tlsConfig, 132 | MaxIdleConnsPerHost: int(opts.maxConns), 133 | DisableKeepAlives: opts.disableKeepAlives, 134 | ForceAttemptHTTP2: opts.HTTP2, 135 | DialContext: httpDialContextFunc(opts.bytesRead, opts.bytesWritten, opts.timeout), 136 | } 137 | 138 | cl := &http.Client{ 139 | Transport: tr, 140 | Timeout: opts.timeout, 141 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 142 | return http.ErrUseLastResponse 143 | }, 144 | } 145 | c.client = cl 146 | 147 | c.headers = headersToHTTPHeaders(opts.headers) 148 | c.method, c.body, c.bodProd = opts.method, opts.body, opts.bodProd 149 | c.url = opts.requestURL 150 | 151 | return client(c) 152 | } 153 | 154 | func (c *httpClient) do() ( 155 | code int, usTaken uint64, err error, 156 | ) { 157 | req := &http.Request{} 158 | 159 | req.Header = c.headers 160 | req.Method = c.method 161 | req.URL = c.url 162 | 163 | if host := req.Header.Get("Host"); host != "" { 164 | req.Host = host 165 | } 166 | 167 | if c.body != nil { 168 | br := strings.NewReader(*c.body) 169 | req.ContentLength = int64(len(*c.body)) 170 | req.Body = ioutil.NopCloser(br) 171 | } else { 172 | bs, bserr := c.bodProd() 173 | if bserr != nil { 174 | return 0, 0, bserr 175 | } 176 | req.Body = bs 177 | } 178 | 179 | start := time.Now() 180 | resp, err := c.client.Do(req) 181 | if err != nil { 182 | code = -1 183 | } else { 184 | code = resp.StatusCode 185 | 186 | _, berr := io.Copy(ioutil.Discard, resp.Body) 187 | if berr != nil { 188 | err = berr 189 | } 190 | 191 | if cerr := resp.Body.Close(); cerr != nil { 192 | err = cerr 193 | } 194 | } 195 | usTaken = uint64(time.Since(start).Nanoseconds() / 1000) 196 | 197 | return 198 | } 199 | 200 | func headersToFastHTTPHeaders(h *headersList) *fasthttp.RequestHeader { 201 | if len(*h) == 0 { 202 | return nil 203 | } 204 | res := new(fasthttp.RequestHeader) 205 | for _, header := range *h { 206 | res.Set(header.key, header.value) 207 | } 208 | return res 209 | } 210 | 211 | func headersToHTTPHeaders(h *headersList) http.Header { 212 | if len(*h) == 0 { 213 | return http.Header{} 214 | } 215 | headers := http.Header{} 216 | 217 | for _, header := range *h { 218 | headers[header.key] = []string{header.value} 219 | } 220 | return headers 221 | } 222 | -------------------------------------------------------------------------------- /clients_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "net/http" 7 | "net/http/httptest" 8 | "sync/atomic" 9 | "testing" 10 | 11 | "github.com/goware/urlx" 12 | ) 13 | 14 | func TestShouldReturnNilIfNoHeadersWhereSet(t *testing.T) { 15 | h := new(headersList) 16 | if headersToFastHTTPHeaders(h) != nil { 17 | t.Fail() 18 | } 19 | } 20 | 21 | func TestShouldReturnEmptyHeadersIfNoHeaadersWhereSet(t *testing.T) { 22 | h := new(headersList) 23 | if len(headersToHTTPHeaders(h)) != 0 { 24 | t.Fail() 25 | } 26 | } 27 | 28 | func TestShouldProperlyConvertToHttpHeaders(t *testing.T) { 29 | h := new(headersList) 30 | for _, hs := range []string{ 31 | "Content-Type: application/json", "Custom-Header: xxx42xxx", 32 | } { 33 | if err := h.Set(hs); err != nil { 34 | t.Error(err) 35 | } 36 | } 37 | fh := headersToFastHTTPHeaders(h) 38 | { 39 | e, a := []byte("application/json"), fh.Peek("Content-Type") 40 | if !bytes.Equal(e, a) { 41 | t.Errorf("Expected %v, but got %v", e, a) 42 | } 43 | } 44 | if e, a := []byte("xxx42xxx"), fh.Peek("Custom-Header"); !bytes.Equal(e, a) { 45 | t.Errorf("Expected %v, but got %v", e, a) 46 | } 47 | 48 | nh := headersToHTTPHeaders(h) 49 | { 50 | e, a := "application/json", nh.Get("Content-Type") 51 | if e != a { 52 | t.Errorf("Expected %v, but got %v", e, a) 53 | } 54 | } 55 | if e, a := "xxx42xxx", nh.Get("Custom-Header"); e != a { 56 | t.Errorf("Expected %v, but got %v", e, a) 57 | } 58 | } 59 | 60 | func TestHTTP2Client(t *testing.T) { 61 | responseSize := 1024 62 | response := bytes.Repeat([]byte{'a'}, responseSize) 63 | s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 | if !r.ProtoAtLeast(2, 0) { 65 | t.Errorf("invalid HTTP proto version: %v", r.Proto) 66 | } 67 | 68 | w.WriteHeader(http.StatusOK) 69 | _, err := w.Write(response) 70 | if err != nil { 71 | t.Error(err) 72 | } 73 | })) 74 | s.EnableHTTP2 = true 75 | s.TLS = &tls.Config{ 76 | InsecureSkipVerify: true, 77 | } 78 | s.StartTLS() 79 | defer s.Close() 80 | 81 | bytesRead, bytesWritten := int64(0), int64(0) 82 | requestURL, err := urlx.Parse(s.URL) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | c := newHTTPClient(&clientOpts{ 87 | HTTP2: true, 88 | 89 | headers: new(headersList), 90 | requestURL: requestURL, 91 | method: "GET", 92 | tlsConfig: &tls.Config{ 93 | InsecureSkipVerify: true, 94 | }, 95 | 96 | body: new(string), 97 | 98 | bytesRead: &bytesRead, 99 | bytesWritten: &bytesWritten, 100 | }) 101 | code, _, err := c.do() 102 | if err != nil { 103 | t.Error(err) 104 | return 105 | } 106 | if code != http.StatusOK { 107 | t.Errorf("invalid response code: %v", code) 108 | } 109 | if atomic.LoadInt64(&bytesRead) == 0 { 110 | t.Errorf("invalid response size: %v", bytesRead) 111 | } 112 | if atomic.LoadInt64(&bytesWritten) == 0 { 113 | t.Errorf("empty request of size: %v", bytesWritten) 114 | } 115 | } 116 | 117 | func TestHTTP1Clients(t *testing.T) { 118 | responseSize := 1024 119 | response := bytes.Repeat([]byte{'a'}, responseSize) 120 | s := httptest.NewServer(http.HandlerFunc( 121 | func(w http.ResponseWriter, r *http.Request) { 122 | if r.ProtoMajor != 1 { 123 | t.Errorf("invalid HTTP proto version: %v", r.Proto) 124 | } 125 | 126 | w.WriteHeader(http.StatusOK) 127 | _, err := w.Write(response) 128 | if err != nil { 129 | t.Error(err) 130 | } 131 | }, 132 | )) 133 | defer s.Close() 134 | 135 | bytesRead, bytesWritten := int64(0), int64(0) 136 | requestURL, err := urlx.Parse(s.URL) 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | cc := &clientOpts{ 141 | HTTP2: false, 142 | 143 | headers: new(headersList), 144 | requestURL: requestURL, 145 | method: "GET", 146 | 147 | body: new(string), 148 | 149 | bytesRead: &bytesRead, 150 | bytesWritten: &bytesWritten, 151 | } 152 | clients := []client{ 153 | newHTTPClient(cc), 154 | newFastHTTPClient(cc), 155 | } 156 | for _, c := range clients { 157 | bytesRead, bytesWritten = 0, 0 158 | code, _, err := c.do() 159 | if err != nil { 160 | t.Error(err) 161 | return 162 | } 163 | if code != http.StatusOK { 164 | t.Errorf("invalid response code: %v", code) 165 | } 166 | if bytesRead == 0 { 167 | t.Errorf("invalid response size: %v", bytesRead) 168 | } 169 | if bytesWritten == 0 { 170 | t.Errorf("empty request of size: %v", bytesWritten) 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /cmd/utils/simplebenchserver/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Simple HTTP server used for benchmarking. 3 | 4 | Following options are available: 5 | 6 | --help Show context-sensitive help (also try --help-long and 7 | --help-man). 8 | -p, --port="8080" port to use for benchmarks 9 | -s, --size=1024 size of response in bytes 10 | */ 11 | package main 12 | -------------------------------------------------------------------------------- /cmd/utils/simplebenchserver/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/alecthomas/kingpin" 9 | "github.com/valyala/fasthttp" 10 | ) 11 | 12 | var serverPort = kingpin.Flag("port", "port to use for benchmarks"). 13 | Default("8080"). 14 | Short('p'). 15 | String() 16 | var responseSize = kingpin.Flag("size", "size of response in bytes"). 17 | Default("1024"). 18 | Short('s'). 19 | Uint() 20 | var stdHTTP = kingpin.Flag("std-http", "use standard http library"). 21 | Default("false"). 22 | Bool() 23 | 24 | func main() { 25 | kingpin.Parse() 26 | response := bytes.Repeat([]byte("a"), int(*responseSize)) 27 | addr := "localhost:" + *serverPort 28 | log.Println("Starting HTTP server on:", addr) 29 | var lserr error 30 | if *stdHTTP { 31 | lserr = http.ListenAndServe(addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | _, werr := w.Write(response) 33 | if werr != nil { 34 | log.Println(werr) 35 | } 36 | })) 37 | } else { 38 | lserr = fasthttp.ListenAndServe(addr, func(c *fasthttp.RequestCtx) { 39 | _, werr := c.Write(response) 40 | if werr != nil { 41 | log.Println(werr) 42 | } 43 | }) 44 | } 45 | if lserr != nil { 46 | log.Println(lserr) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "sort" 7 | "time" 8 | 9 | "github.com/goware/urlx" 10 | ) 11 | 12 | const ( 13 | decBase = 10 14 | 15 | rateLimitInterval = 10 * time.Millisecond 16 | oneSecond = 1 * time.Second 17 | 18 | exitFailure = 1 19 | ) 20 | 21 | var ( 22 | version = "unspecified" 23 | 24 | emptyConf = config{} 25 | parser = newKingpinParser() 26 | 27 | defaultTestDuration = 10 * time.Second 28 | defaultNumberOfConns = uint64(125) 29 | defaultTimeout = 2 * time.Second 30 | 31 | httpMethods = []string{ 32 | "GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", 33 | "PATCH", 34 | } 35 | cantHaveBody = []string{"HEAD"} 36 | 37 | errUnsupportedScheme = errors.New("unsupported scheme") 38 | errInvalidNumberOfConns = errors.New( 39 | "invalid number of connections(must be > 0)") 40 | errInvalidNumberOfRequests = errors.New( 41 | "invalid number of requests(must be > 0)") 42 | errInvalidTestDuration = errors.New( 43 | "invalid test duration(must be >= 1s)") 44 | errNegativeTimeout = errors.New( 45 | "timeout can't be negative") 46 | errBodyNotAllowed = errors.New( 47 | "HEAD requests cannot have body") 48 | errNoPathToCert = errors.New( 49 | "no Path to TLS Client Certificate") 50 | errNoPathToKey = errors.New( 51 | "no Path to TLS Client Certificate Private Key") 52 | errZeroRate = errors.New( 53 | "rate can't be less than 1") 54 | errBodyProvidedTwice = errors.New("use either --body or --body-file") 55 | 56 | errInvalidHeaderFormat = errors.New("invalid header format") 57 | errEmptyPrintSpec = errors.New( 58 | "empty print spec is not a valid print spec") 59 | ) 60 | 61 | func ParseURLOrPanic(s string) *url.URL { 62 | u, err := urlx.Parse(s) 63 | if err != nil { 64 | panic(err) 65 | } 66 | return u 67 | } 68 | 69 | func init() { 70 | sort.Strings(httpMethods) 71 | sort.Strings(cantHaveBody) 72 | } 73 | -------------------------------------------------------------------------------- /completion_barriers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | "time" 7 | ) 8 | 9 | type completionBarrier interface { 10 | completed() float64 11 | tryGrabWork() bool 12 | jobDone() 13 | done() <-chan struct{} 14 | cancel() 15 | } 16 | 17 | type countingCompletionBarrier struct { 18 | numReqs, reqsGrabbed, reqsDone uint64 19 | doneChan chan struct{} 20 | closeOnce sync.Once 21 | } 22 | 23 | func newCountingCompletionBarrier(numReqs uint64) completionBarrier { 24 | c := new(countingCompletionBarrier) 25 | c.reqsDone, c.reqsGrabbed, c.numReqs = 0, 0, numReqs 26 | c.doneChan = make(chan struct{}) 27 | return completionBarrier(c) 28 | } 29 | 30 | func (c *countingCompletionBarrier) tryGrabWork() bool { 31 | select { 32 | case <-c.doneChan: 33 | return false 34 | default: 35 | reqsDone := atomic.AddUint64(&c.reqsGrabbed, 1) 36 | return reqsDone <= c.numReqs 37 | } 38 | } 39 | 40 | func (c *countingCompletionBarrier) jobDone() { 41 | reqsDone := atomic.AddUint64(&c.reqsDone, 1) 42 | if reqsDone == c.numReqs { 43 | c.closeOnce.Do(func() { 44 | close(c.doneChan) 45 | }) 46 | } 47 | } 48 | 49 | func (c *countingCompletionBarrier) done() <-chan struct{} { 50 | return c.doneChan 51 | } 52 | 53 | func (c *countingCompletionBarrier) cancel() { 54 | c.closeOnce.Do(func() { 55 | close(c.doneChan) 56 | }) 57 | } 58 | 59 | func (c *countingCompletionBarrier) completed() float64 { 60 | select { 61 | case <-c.doneChan: 62 | return 1.0 63 | default: 64 | reqsDone := atomic.LoadUint64(&c.reqsDone) 65 | return float64(reqsDone) / float64(c.numReqs) 66 | } 67 | } 68 | 69 | type timedCompletionBarrier struct { 70 | doneChan chan struct{} 71 | closeOnce sync.Once 72 | start time.Time 73 | duration time.Duration 74 | } 75 | 76 | func newTimedCompletionBarrier(duration time.Duration) completionBarrier { 77 | if duration < 0 { 78 | panic("timedCompletionBarrier: negative duration") 79 | } 80 | c := new(timedCompletionBarrier) 81 | c.doneChan = make(chan struct{}) 82 | c.start = time.Now() 83 | c.duration = duration 84 | go func() { 85 | time.AfterFunc(duration, func() { 86 | c.closeOnce.Do(func() { 87 | close(c.doneChan) 88 | }) 89 | }) 90 | }() 91 | return completionBarrier(c) 92 | } 93 | 94 | func (c *timedCompletionBarrier) tryGrabWork() bool { 95 | select { 96 | case <-c.doneChan: 97 | return false 98 | default: 99 | return true 100 | } 101 | } 102 | 103 | func (c *timedCompletionBarrier) jobDone() { 104 | } 105 | 106 | func (c *timedCompletionBarrier) done() <-chan struct{} { 107 | return c.doneChan 108 | } 109 | 110 | func (c *timedCompletionBarrier) cancel() { 111 | c.closeOnce.Do(func() { 112 | close(c.doneChan) 113 | }) 114 | } 115 | 116 | func (c *timedCompletionBarrier) completed() float64 { 117 | select { 118 | case <-c.doneChan: 119 | return 1.0 120 | default: 121 | return float64(time.Since(c.start).Nanoseconds()) / 122 | float64(c.duration.Nanoseconds()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /completion_barriers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestCouintingCompletionBarrierWait(t *testing.T) { 10 | parties := uint64(10) 11 | b := newCountingCompletionBarrier(1000) 12 | for i := uint64(0); i < parties; i++ { 13 | go func() { 14 | for b.tryGrabWork() { 15 | b.jobDone() 16 | } 17 | }() 18 | } 19 | wc := make(chan struct{}) 20 | go func() { 21 | <-b.done() 22 | wc <- struct{}{} 23 | }() 24 | select { 25 | case <-wc: 26 | return 27 | case <-time.After(100 * time.Millisecond): 28 | t.Fail() 29 | } 30 | } 31 | 32 | func TestTimedCompletionBarrierWait(t *testing.T) { 33 | parties := uint64(10) 34 | duration := 100 * time.Millisecond 35 | timeout := duration * 2 36 | err := 15 * time.Millisecond 37 | sleepDuration := 2 * time.Millisecond 38 | b := newTimedCompletionBarrier(duration) 39 | for i := uint64(0); i < parties; i++ { 40 | go func() { 41 | for b.tryGrabWork() { 42 | time.Sleep(sleepDuration) 43 | b.jobDone() 44 | } 45 | }() 46 | } 47 | wc := make(chan time.Duration) 48 | go func() { 49 | start := time.Now() 50 | <-b.done() 51 | wc <- time.Since(start) 52 | }() 53 | select { 54 | case actual := <-wc: 55 | if !approximatelyEqual(duration, actual, sleepDuration+err) { 56 | t.Errorf("Expected to run %v, but ran %v instead", duration, actual) 57 | } 58 | case <-time.After(timeout): 59 | t.Error("Barrier hanged") 60 | } 61 | } 62 | 63 | func TestTimeBarrierCancel(t *testing.T) { 64 | b := newTimedCompletionBarrier(9000 * time.Second) 65 | sleepTime := 100 * time.Millisecond 66 | go func() { 67 | time.Sleep(sleepTime) 68 | b.cancel() 69 | }() 70 | select { 71 | case <-b.done(): 72 | if c := b.completed(); c != 1.0 { 73 | t.Error(c) 74 | } 75 | case <-time.After(sleepTime * 2): 76 | t.Fail() 77 | } 78 | } 79 | 80 | func TestCountedBarrierCancel(t *testing.T) { 81 | parties := uint64(10) 82 | b := newCountingCompletionBarrier(math.MaxUint64) 83 | sleepTime := 100 * time.Millisecond 84 | for i := uint64(0); i < parties; i++ { 85 | go func() { 86 | for b.tryGrabWork() { 87 | b.jobDone() 88 | } 89 | }() 90 | } 91 | go func() { 92 | time.Sleep(sleepTime) 93 | b.cancel() 94 | }() 95 | select { 96 | case <-b.done(): 97 | if c := b.completed(); c != 1.0 { 98 | t.Error(c) 99 | } 100 | case <-time.After(5 * time.Second): 101 | t.Fail() 102 | } 103 | } 104 | 105 | func TestTimeBarrierPanicOnBadDuration(t *testing.T) { 106 | defer func() { 107 | r := recover() 108 | if r == nil { 109 | t.Error("shouldn't be empty") 110 | t.Fail() 111 | } 112 | }() 113 | newTimedCompletionBarrier(-1 * time.Second) 114 | t.Error("unreachable") 115 | t.Fail() 116 | } 117 | 118 | func approximatelyEqual(expected, actual, err time.Duration) bool { 119 | return expected-err < actual && actual < expected+err 120 | } 121 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "sort" 7 | "time" 8 | ) 9 | 10 | type config struct { 11 | numConns uint64 12 | numReqs *uint64 13 | disableKeepAlives bool 14 | duration *time.Duration 15 | url *url.URL 16 | method, certPath, keyPath string 17 | body, bodyFilePath string 18 | stream bool 19 | headers *headersList 20 | timeout time.Duration 21 | // TODO(codesenberg): printLatencies should probably be 22 | // re(named&maked) into printPercentiles or even let 23 | // users provide their own percentiles and not just 24 | // calculate for [0.5, 0.75, 0.9, 0.99] 25 | printLatencies, insecure bool 26 | rate *uint64 27 | clientType clientTyp 28 | 29 | printIntro, printProgress, printResult bool 30 | 31 | format format 32 | } 33 | 34 | type testTyp int 35 | 36 | const ( 37 | none testTyp = iota 38 | timed 39 | counted 40 | ) 41 | 42 | type invalidHTTPMethodError struct { 43 | method string 44 | } 45 | 46 | func (i *invalidHTTPMethodError) Error() string { 47 | return fmt.Sprintf("Unknown HTTP method: %v", i.method) 48 | } 49 | 50 | func (c *config) checkArgs() error { 51 | c.checkOrSetDefaultTestType() 52 | 53 | checks := []func() error{ 54 | c.checkURL, 55 | c.checkRate, 56 | c.checkRunParameters, 57 | c.checkTimeoutDuration, 58 | c.checkHTTPParameters, 59 | c.checkCertPaths, 60 | } 61 | 62 | for _, check := range checks { 63 | if err := check(); err != nil { 64 | return err 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func (c *config) checkOrSetDefaultTestType() { 72 | if c.testType() == none { 73 | c.duration = &defaultTestDuration 74 | } 75 | } 76 | 77 | func (c *config) testType() testTyp { 78 | typ := none 79 | if c.numReqs != nil { 80 | typ = counted 81 | } else if c.duration != nil { 82 | typ = timed 83 | } 84 | return typ 85 | } 86 | 87 | func (c *config) checkURL() error { 88 | if c.url.Scheme != "http" && c.url.Scheme != "https" { 89 | return errUnsupportedScheme 90 | } 91 | return nil 92 | } 93 | 94 | func (c *config) checkRate() error { 95 | if c.rate != nil && *c.rate < 1 { 96 | return errZeroRate 97 | } 98 | return nil 99 | } 100 | 101 | func (c *config) checkRunParameters() error { 102 | if c.numConns < uint64(1) { 103 | return errInvalidNumberOfConns 104 | } 105 | if c.testType() == counted && *c.numReqs < uint64(1) { 106 | return errInvalidNumberOfRequests 107 | } 108 | if c.testType() == timed && *c.duration < time.Second { 109 | return errInvalidTestDuration 110 | } 111 | return nil 112 | } 113 | 114 | func (c *config) checkTimeoutDuration() error { 115 | if c.timeout < 0 { 116 | return errNegativeTimeout 117 | } 118 | return nil 119 | } 120 | 121 | func (c *config) checkHTTPParameters() error { 122 | if !allowedHTTPMethod(c.method) { 123 | return &invalidHTTPMethodError{method: c.method} 124 | } 125 | if !canHaveBody(c.method) && (c.body != "" || c.bodyFilePath != "") { 126 | return errBodyNotAllowed 127 | } 128 | if c.body != "" && c.bodyFilePath != "" { 129 | return errBodyProvidedTwice 130 | } 131 | return nil 132 | } 133 | 134 | func (c *config) checkCertPaths() error { 135 | if c.certPath != "" && c.keyPath == "" { 136 | return errNoPathToKey 137 | } else if c.certPath == "" && c.keyPath != "" { 138 | return errNoPathToCert 139 | } 140 | return nil 141 | } 142 | 143 | func (c *config) timeoutMillis() uint64 { 144 | return uint64(c.timeout.Nanoseconds() / 1000) 145 | } 146 | 147 | func allowedHTTPMethod(method string) bool { 148 | i := sort.SearchStrings(httpMethods, method) 149 | return i < len(httpMethods) && httpMethods[i] == method 150 | } 151 | 152 | func canHaveBody(method string) bool { 153 | i := sort.SearchStrings(cantHaveBody, method) 154 | return !(i < len(cantHaveBody) && cantHaveBody[i] == method) 155 | } 156 | 157 | type clientTyp int 158 | 159 | const ( 160 | fhttp clientTyp = iota 161 | nhttp1 162 | nhttp2 163 | ) 164 | 165 | func (ct clientTyp) String() string { 166 | switch ct { 167 | case fhttp: 168 | return "FastHTTP" 169 | case nhttp1: 170 | return "net/http v1.x" 171 | case nhttp2: 172 | return "net/http v2.0" 173 | } 174 | return "unknown client" 175 | } 176 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | var ( 9 | defaultNumberOfReqs = uint64(10000) 10 | ) 11 | 12 | func TestCanHaveBody(t *testing.T) { 13 | expectations := []struct { 14 | in string 15 | out bool 16 | }{ 17 | {"HEAD", false}, 18 | {"GET", true}, 19 | {"POST", true}, 20 | {"PUT", true}, 21 | {"DELETE", true}, 22 | {"OPTIONS", true}, 23 | } 24 | for _, e := range expectations { 25 | if r := canHaveBody(e.in); r != e.out { 26 | t.Error(e.in, e.out, r) 27 | } 28 | } 29 | } 30 | 31 | func TestAllowedHttpMethod(t *testing.T) { 32 | expectations := []struct { 33 | in string 34 | out bool 35 | }{ 36 | {"GET", true}, 37 | {"POST", true}, 38 | {"PUT", true}, 39 | {"DELETE", true}, 40 | {"HEAD", true}, 41 | {"OPTIONS", true}, 42 | {"TRUNCATE", false}, 43 | } 44 | for _, e := range expectations { 45 | if r := allowedHTTPMethod(e.in); r != e.out { 46 | t.Logf("Expected f(%v) = %v, but got %v", e.in, e.out, r) 47 | t.Fail() 48 | } 49 | } 50 | } 51 | 52 | func TestCheckArgs(t *testing.T) { 53 | invalidNumberOfReqs := uint64(0) 54 | smallTestDuration := 99 * time.Millisecond 55 | negativeTimeoutDuration := -1 * time.Second 56 | noHeaders := new(headersList) 57 | zeroRate := uint64(0) 58 | expectations := []struct { 59 | in config 60 | out error 61 | }{ 62 | { 63 | config{ 64 | numConns: 0, 65 | numReqs: &defaultNumberOfReqs, 66 | duration: &defaultTestDuration, 67 | url: ParseURLOrPanic("http://localhost:8080"), 68 | headers: noHeaders, 69 | timeout: defaultTimeout, 70 | method: "GET", 71 | body: "", 72 | format: knownFormat("plain-text"), 73 | }, 74 | errInvalidNumberOfConns, 75 | }, 76 | { 77 | config{ 78 | numConns: defaultNumberOfConns, 79 | numReqs: &invalidNumberOfReqs, 80 | duration: &defaultTestDuration, 81 | url: ParseURLOrPanic("http://localhost:8080"), 82 | headers: noHeaders, 83 | timeout: defaultTimeout, 84 | method: "GET", 85 | body: "", 86 | format: knownFormat("plain-text"), 87 | }, 88 | errInvalidNumberOfRequests, 89 | }, 90 | { 91 | config{ 92 | numConns: defaultNumberOfConns, 93 | numReqs: nil, 94 | duration: &smallTestDuration, 95 | url: ParseURLOrPanic("http://localhost:8080"), 96 | headers: noHeaders, 97 | timeout: defaultTimeout, 98 | method: "GET", 99 | body: "", 100 | format: knownFormat("plain-text"), 101 | }, 102 | errInvalidTestDuration, 103 | }, 104 | { 105 | config{ 106 | numConns: defaultNumberOfConns, 107 | numReqs: &defaultNumberOfReqs, 108 | duration: &defaultTestDuration, 109 | url: ParseURLOrPanic("http://localhost:8080"), 110 | headers: noHeaders, 111 | timeout: negativeTimeoutDuration, 112 | method: "GET", 113 | body: "", 114 | format: knownFormat("plain-text"), 115 | }, 116 | errNegativeTimeout, 117 | }, 118 | { 119 | config{ 120 | numConns: defaultNumberOfConns, 121 | numReqs: &defaultNumberOfReqs, 122 | duration: &defaultTestDuration, 123 | url: ParseURLOrPanic("http://localhost:8080"), 124 | headers: noHeaders, 125 | timeout: defaultTimeout, 126 | method: "HEAD", 127 | body: "BODY", 128 | format: knownFormat("plain-text"), 129 | }, 130 | errBodyNotAllowed, 131 | }, 132 | { 133 | config{ 134 | numConns: defaultNumberOfConns, 135 | numReqs: &defaultNumberOfReqs, 136 | duration: &defaultTestDuration, 137 | url: ParseURLOrPanic("http://localhost:8080"), 138 | headers: noHeaders, 139 | timeout: defaultTimeout, 140 | method: "HEAD", 141 | bodyFilePath: "testbody.txt", 142 | format: knownFormat("plain-text"), 143 | }, 144 | errBodyNotAllowed, 145 | }, 146 | { 147 | config{ 148 | numConns: defaultNumberOfConns, 149 | numReqs: &defaultNumberOfReqs, 150 | duration: &defaultTestDuration, 151 | url: ParseURLOrPanic("http://localhost:8080"), 152 | headers: noHeaders, 153 | timeout: defaultTimeout, 154 | method: "GET", 155 | body: "BODY", 156 | format: knownFormat("plain-text"), 157 | }, 158 | nil, 159 | }, 160 | { 161 | config{ 162 | numConns: defaultNumberOfConns, 163 | numReqs: &defaultNumberOfReqs, 164 | duration: &defaultTestDuration, 165 | url: ParseURLOrPanic("http://localhost:8080"), 166 | headers: noHeaders, 167 | timeout: defaultTimeout, 168 | method: "GET", 169 | bodyFilePath: "testbody.txt", 170 | format: knownFormat("plain-text"), 171 | }, 172 | nil, 173 | }, 174 | { 175 | config{ 176 | numConns: defaultNumberOfConns, 177 | numReqs: &defaultNumberOfReqs, 178 | duration: &defaultTestDuration, 179 | url: ParseURLOrPanic("http://localhost:8080"), 180 | headers: noHeaders, 181 | timeout: defaultTimeout, 182 | method: "GET", 183 | body: "", 184 | format: knownFormat("plain-text"), 185 | }, 186 | nil, 187 | }, 188 | { 189 | config{ 190 | numConns: defaultNumberOfConns, 191 | numReqs: &defaultNumberOfReqs, 192 | duration: &defaultTestDuration, 193 | url: ParseURLOrPanic("http://localhost:8080"), 194 | headers: noHeaders, 195 | timeout: defaultTimeout, 196 | method: "GET", 197 | body: "", 198 | certPath: "test_cert.pem", 199 | keyPath: "", 200 | format: knownFormat("plain-text"), 201 | }, 202 | errNoPathToKey, 203 | }, 204 | { 205 | config{ 206 | numConns: defaultNumberOfConns, 207 | numReqs: &defaultNumberOfReqs, 208 | duration: &defaultTestDuration, 209 | url: ParseURLOrPanic("http://localhost:8080"), 210 | headers: noHeaders, 211 | timeout: defaultTimeout, 212 | method: "GET", 213 | body: "", 214 | certPath: "", 215 | keyPath: "test_key.pem", 216 | format: knownFormat("plain-text"), 217 | }, 218 | errNoPathToCert, 219 | }, 220 | { 221 | config{ 222 | numConns: defaultNumberOfConns, 223 | numReqs: &defaultNumberOfReqs, 224 | duration: &defaultTestDuration, 225 | url: ParseURLOrPanic("http://localhost:8080"), 226 | headers: noHeaders, 227 | timeout: defaultTimeout, 228 | method: "GET", 229 | rate: &zeroRate, 230 | format: knownFormat("plain-text"), 231 | }, 232 | errZeroRate, 233 | }, 234 | { 235 | config{ 236 | numConns: defaultNumberOfConns, 237 | numReqs: &defaultNumberOfReqs, 238 | duration: &defaultTestDuration, 239 | url: ParseURLOrPanic("http://localhost:8080"), 240 | headers: noHeaders, 241 | timeout: defaultTimeout, 242 | method: "POST", 243 | body: "abracadabra", 244 | bodyFilePath: "testbody.txt", 245 | format: knownFormat("plain-text"), 246 | }, 247 | errBodyProvidedTwice, 248 | }, 249 | } 250 | for _, e := range expectations { 251 | if r := e.in.checkArgs(); r != e.out { 252 | t.Logf("Expected (%v).checkArgs to return %v, but got %v", e.in, e.out, r) 253 | t.Fail() 254 | } 255 | if _, r := newBombardier(e.in); r != e.out { 256 | t.Logf("Expected newBombardier(%v) to return %v, but got %v", e.in, e.out, r) 257 | t.Fail() 258 | } 259 | } 260 | } 261 | 262 | func TestCheckArgsUnsupportedURLScheme(t *testing.T) { 263 | c := config{ 264 | numConns: defaultNumberOfConns, 265 | numReqs: &defaultNumberOfReqs, 266 | duration: &defaultTestDuration, 267 | url: ParseURLOrPanic("ftp://localhost:8080"), 268 | headers: nil, 269 | timeout: defaultTimeout, 270 | method: "GET", 271 | body: "", 272 | } 273 | if c.checkArgs() != errUnsupportedScheme { 274 | t.Fail() 275 | } 276 | } 277 | 278 | func TestCheckArgsInvalidRequestMethod(t *testing.T) { 279 | c := config{ 280 | numConns: defaultNumberOfConns, 281 | numReqs: &defaultNumberOfReqs, 282 | duration: &defaultTestDuration, 283 | url: ParseURLOrPanic("http://localhost:8080"), 284 | headers: nil, 285 | timeout: defaultTimeout, 286 | method: "ABRACADABRA", 287 | body: "", 288 | } 289 | e := c.checkArgs() 290 | if e == nil { 291 | t.Fail() 292 | } 293 | if _, ok := e.(*invalidHTTPMethodError); !ok { 294 | t.Fail() 295 | } 296 | } 297 | 298 | func TestCheckArgsTestType(t *testing.T) { 299 | countedConfig := config{ 300 | numConns: defaultNumberOfConns, 301 | numReqs: &defaultNumberOfReqs, 302 | duration: nil, 303 | url: ParseURLOrPanic("http://localhost:8080"), 304 | headers: nil, 305 | timeout: defaultTimeout, 306 | method: "GET", 307 | body: "", 308 | } 309 | timedConfig := config{ 310 | numConns: defaultNumberOfConns, 311 | numReqs: nil, 312 | duration: &defaultTestDuration, 313 | url: ParseURLOrPanic("http://localhost:8080"), 314 | headers: nil, 315 | timeout: defaultTimeout, 316 | method: "GET", 317 | body: "", 318 | } 319 | both := config{ 320 | numConns: defaultNumberOfConns, 321 | numReqs: &defaultNumberOfReqs, 322 | duration: &defaultTestDuration, 323 | url: ParseURLOrPanic("http://localhost:8080"), 324 | headers: nil, 325 | timeout: defaultTimeout, 326 | method: "GET", 327 | body: "", 328 | } 329 | defaultConfig := config{ 330 | numConns: defaultNumberOfConns, 331 | numReqs: nil, 332 | duration: nil, 333 | url: ParseURLOrPanic("http://localhost:8080"), 334 | headers: nil, 335 | timeout: defaultTimeout, 336 | method: "GET", 337 | body: "", 338 | } 339 | if err := countedConfig.checkArgs(); err != nil || 340 | countedConfig.testType() != counted { 341 | t.Fail() 342 | } 343 | if err := timedConfig.checkArgs(); err != nil || 344 | timedConfig.testType() != timed { 345 | t.Fail() 346 | } 347 | if err := both.checkArgs(); err != nil || 348 | both.testType() != counted { 349 | t.Fail() 350 | } 351 | if err := defaultConfig.checkArgs(); err != nil || 352 | defaultConfig.testType() != timed || 353 | defaultConfig.duration != &defaultTestDuration { 354 | t.Fail() 355 | } 356 | } 357 | 358 | func TestTimeoutMillis(t *testing.T) { 359 | defaultConfig := config{ 360 | numConns: defaultNumberOfConns, 361 | numReqs: nil, 362 | duration: nil, 363 | url: ParseURLOrPanic("http://localhost:8080"), 364 | headers: nil, 365 | timeout: 2 * time.Second, 366 | method: "GET", 367 | body: "", 368 | } 369 | if defaultConfig.timeoutMillis() != 2000000 { 370 | t.Fail() 371 | } 372 | } 373 | 374 | func TestInvalidHTTPMethodError(t *testing.T) { 375 | invalidMethod := "NOSUCHMETHOD" 376 | want := "Unknown HTTP method: " + invalidMethod 377 | err := &invalidHTTPMethodError{invalidMethod} 378 | if got := err.Error(); got != want { 379 | t.Error(got, want) 380 | } 381 | } 382 | 383 | func TestClientTypToStringConversion(t *testing.T) { 384 | expectations := []struct { 385 | in clientTyp 386 | out string 387 | }{ 388 | {fhttp, "FastHTTP"}, 389 | {nhttp1, "net/http v1.x"}, 390 | {nhttp2, "net/http v2.0"}, 391 | {42, "unknown client"}, 392 | } 393 | for _, exp := range expectations { 394 | act := exp.in.String() 395 | if act != exp.out { 396 | t.Errorf("Expected %v, but got %v", exp.out, act) 397 | } 398 | } 399 | } 400 | 401 | func clientTypeFromString(s string) clientTyp { 402 | switch s { 403 | case "fasthttp": 404 | return fhttp 405 | case "http1": 406 | return nhttp1 407 | case "http2": 408 | return nhttp2 409 | default: 410 | return fhttp 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /dialer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "sync/atomic" 7 | "time" 8 | ) 9 | 10 | type countingConn struct { 11 | net.Conn 12 | bytesRead, bytesWritten *int64 13 | } 14 | 15 | func (cc *countingConn) Read(b []byte) (n int, err error) { 16 | n, err = cc.Conn.Read(b) 17 | 18 | if err == nil { 19 | atomic.AddInt64(cc.bytesRead, int64(n)) 20 | } 21 | 22 | return 23 | } 24 | 25 | func (cc *countingConn) Write(b []byte) (n int, err error) { 26 | n, err = cc.Conn.Write(b) 27 | 28 | if err == nil { 29 | atomic.AddInt64(cc.bytesWritten, int64(n)) 30 | } 31 | 32 | return 33 | } 34 | 35 | var fasthttpDialFunc = func( 36 | bytesRead, bytesWritten *int64, 37 | dialTimeout time.Duration, 38 | ) func(string) (net.Conn, error) { 39 | return func(address string) (net.Conn, error) { 40 | conn, err := net.DialTimeout("tcp", address, dialTimeout) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | wrappedConn := &countingConn{ 46 | Conn: conn, 47 | bytesRead: bytesRead, 48 | bytesWritten: bytesWritten, 49 | } 50 | 51 | return wrappedConn, nil 52 | } 53 | } 54 | 55 | var httpDialContextFunc = func( 56 | bytesRead, bytesWritten *int64, 57 | dialTimeout time.Duration, 58 | ) func(context.Context, string, string) (net.Conn, error) { 59 | dialer := &net.Dialer{Timeout: dialTimeout} 60 | return func(ctx context.Context, network, address string) (net.Conn, error) { 61 | conn, err := dialer.DialContext(ctx, network, address) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | wrappedConn := &countingConn{ 67 | Conn: conn, 68 | bytesRead: bytesRead, 69 | bytesWritten: bytesWritten, 70 | } 71 | 72 | return wrappedConn, nil 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Command line utility bombardier is a fast cross-platform HTTP 3 | benchmarking tool written in Go. 4 | 5 | Installation with Go 1.17+: 6 | 7 | go install github.com/codesenberg/bombardier@latest 8 | 9 | Installation with older versions of Go: 10 | 11 | go get -u github.com/codesenberg/bombardier 12 | 13 | Usage: 14 | 15 | bombardier [] 16 | 17 | Flags: 18 | 19 | --help Show context-sensitive help (also try --help-long 20 | and --help-man). 21 | --version Show application version. 22 | -c, --connections=125 Maximum number of concurrent connections 23 | -t, --timeout=2s Socket/request timeout 24 | -l, --latencies Print latency statistics 25 | -m, --method=GET Request method 26 | -b, --body="" Request body 27 | -f, --body-file="" File to use as request body 28 | -s, --stream Specify whether to stream body using chunked 29 | transfer encoding or to serve it from memory 30 | --cert="" Path to the client's TLS Certificate 31 | --key="" Path to the client's TLS Certificate Private Key 32 | -k, --insecure Controls whether a client verifies the server's 33 | certificate chain and host name 34 | -H, --header="K: V" ... HTTP headers to use(can be repeated) 35 | -n, --requests=[pos. int.] Number of requests 36 | -d, --duration=10s Duration of test 37 | -r, --rate=[pos. int.] Rate limit in requests per second 38 | --fasthttp Use fasthttp client 39 | --http1 Use net/http client with forced HTTP/1.x 40 | --http2 Use net/http client with enabled HTTP/2.0 41 | -p, --print= Specifies what to output. Comma-separated list of 42 | values 'intro' (short: 'i'), 'progress' (short: 43 | 'p'), 'result' (short: 'r'). Examples: 44 | 45 | * i,p,r (prints everything) 46 | * intro,result (intro & result) 47 | * r (result only) 48 | * result (same as above) 49 | -q, --no-print Don't output anything 50 | -o, --format= Which format to use to output the result. 51 | is either a name (or its shorthand) of some format 52 | understood by bombardier or a path to the 53 | user-defined template, which uses Go's 54 | text/template syntax, prefixed with 'path:' string 55 | (without single quotes), i.e. 56 | "path:/some/path/to/your.template" or 57 | "path:C:\some\path\to\your.template" in case of 58 | Windows. Formats understood by bombardier are: 59 | 60 | * plain-text (short: pt) 61 | * json (short: j) 62 | 63 | Args: 64 | 65 | Target's URL 66 | 67 | For detailed documentation on user-defined templates see 68 | documentation for package github.com/codesenberg/bombardier/template. 69 | Link (GoDoc): 70 | https://godoc.org/github.com/codesenberg/bombardier/template 71 | */ 72 | package main 73 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Contribution Guidelines 2 | For relevant info on how to format commit messages and check the code before submitting pull requests see [PULL_REQUEST_TEMPLATE](https://github.com/codesenberg/bombardier/blob/master/.github/PULL_REQUEST_TEMPLATE.md). 3 | 4 | ### Reporting issues 5 | Please open an issue if you would like to discuss anything that could be improved, have a suggestion or want to report a bug. 6 | In latter case refer to [ISSUE_TEMPLATE](https://github.com/codesenberg/bombardier/blob/master/.github/ISSUE_TEMPLATE.md). 7 | -------------------------------------------------------------------------------- /error_map.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sort" 5 | "strconv" 6 | "sync" 7 | "sync/atomic" 8 | ) 9 | 10 | type errorMap struct { 11 | mu sync.RWMutex 12 | m map[string]*uint64 13 | } 14 | 15 | func newErrorMap() *errorMap { 16 | em := new(errorMap) 17 | em.m = make(map[string]*uint64) 18 | return em 19 | } 20 | 21 | func (e *errorMap) add(err error) { 22 | s := err.Error() 23 | e.mu.RLock() 24 | c, ok := e.m[s] 25 | e.mu.RUnlock() 26 | if !ok { 27 | e.mu.Lock() 28 | c, ok = e.m[s] 29 | if !ok { 30 | c = new(uint64) 31 | e.m[s] = c 32 | } 33 | e.mu.Unlock() 34 | } 35 | atomic.AddUint64(c, 1) 36 | } 37 | 38 | func (e *errorMap) get(err error) uint64 { 39 | s := err.Error() 40 | e.mu.RLock() 41 | defer e.mu.RUnlock() 42 | c := e.m[s] 43 | if c == nil { 44 | return uint64(0) 45 | } 46 | return *c 47 | } 48 | 49 | func (e *errorMap) sum() uint64 { 50 | e.mu.RLock() 51 | defer e.mu.RUnlock() 52 | sum := uint64(0) 53 | for _, v := range e.m { 54 | sum += *v 55 | } 56 | return sum 57 | } 58 | 59 | type errorWithCount struct { 60 | error string 61 | count uint64 62 | } 63 | 64 | func (ewc *errorWithCount) String() string { 65 | return "<" + ewc.error + ":" + 66 | strconv.FormatUint(ewc.count, decBase) + ">" 67 | } 68 | 69 | type errorsByFrequency []*errorWithCount 70 | 71 | func (ebf errorsByFrequency) Len() int { 72 | return len(ebf) 73 | } 74 | 75 | func (ebf errorsByFrequency) Less(i, j int) bool { 76 | return ebf[i].count > ebf[j].count 77 | } 78 | 79 | func (ebf errorsByFrequency) Swap(i, j int) { 80 | ebf[i], ebf[j] = ebf[j], ebf[i] 81 | } 82 | 83 | func (e *errorMap) byFrequency() errorsByFrequency { 84 | e.mu.RLock() 85 | byFreq := make(errorsByFrequency, 0, len(e.m)) 86 | for err, count := range e.m { 87 | byFreq = append(byFreq, &errorWithCount{err, *count}) 88 | } 89 | e.mu.RUnlock() 90 | sort.Sort(byFreq) 91 | return byFreq 92 | } 93 | -------------------------------------------------------------------------------- /error_map_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestErrorMapAdd(t *testing.T) { 10 | m := newErrorMap() 11 | err := errors.New("add") 12 | m.add(err) 13 | if c := m.get(err); c != 1 { 14 | t.Error(c) 15 | } 16 | } 17 | 18 | func TestErrorMapGet(t *testing.T) { 19 | m := newErrorMap() 20 | err := errors.New("get") 21 | if c := m.get(err); c != 0 { 22 | t.Error(c) 23 | } 24 | } 25 | 26 | func TestByFrequency(t *testing.T) { 27 | m := newErrorMap() 28 | a := errors.New("A") 29 | b := errors.New("B") 30 | c := errors.New("C") 31 | m.add(a) 32 | m.add(a) 33 | m.add(b) 34 | m.add(b) 35 | m.add(b) 36 | m.add(c) 37 | e := errorsByFrequency{ 38 | {"B", 3}, 39 | {"A", 2}, 40 | {"C", 1}, 41 | } 42 | if a := m.byFrequency(); !reflect.DeepEqual(a, e) { 43 | t.Logf("Expected: %+v", e) 44 | t.Logf("Got: %+v", a) 45 | t.Fail() 46 | } 47 | } 48 | 49 | func TestErrorWithCountToStringConversion(t *testing.T) { 50 | ewc := errorWithCount{"A", 1} 51 | exp := "" 52 | if act := ewc.String(); act != exp { 53 | t.Logf("Expected: %+v", exp) 54 | t.Logf("Got: %+v", act) 55 | t.Fail() 56 | } 57 | } 58 | 59 | func BenchmarkErrorMapAdd(b *testing.B) { 60 | m := newErrorMap() 61 | err := errors.New("benchmark") 62 | b.ResetTimer() 63 | b.RunParallel(func(pb *testing.PB) { 64 | for pb.Next() { 65 | m.add(err) 66 | } 67 | }) 68 | } 69 | 70 | func BenchmarkErrorMapGet(b *testing.B) { 71 | m := newErrorMap() 72 | err := errors.New("benchmark") 73 | m.add(err) 74 | b.ResetTimer() 75 | b.RunParallel(func(pb *testing.PB) { 76 | for pb.Next() { 77 | m.get(err) 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | const ( 9 | nilStr = "nil" 10 | ) 11 | 12 | type nullableUint64 struct { 13 | val *uint64 14 | } 15 | 16 | func (n *nullableUint64) String() string { 17 | if n.val == nil { 18 | return nilStr 19 | } 20 | return strconv.FormatUint(*n.val, 10) 21 | } 22 | 23 | func (n *nullableUint64) Set(value string) error { 24 | res, err := strconv.ParseUint(value, 10, 64) 25 | if err != nil { 26 | return err 27 | } 28 | n.val = new(uint64) 29 | *n.val = res 30 | return nil 31 | } 32 | 33 | type nullableDuration struct { 34 | val *time.Duration 35 | } 36 | 37 | func (n *nullableDuration) String() string { 38 | if n.val == nil { 39 | return nilStr 40 | } 41 | return n.val.String() 42 | } 43 | 44 | func (n *nullableDuration) Set(value string) error { 45 | res, err := time.ParseDuration(value) 46 | if err != nil { 47 | return err 48 | } 49 | n.val = &res 50 | return nil 51 | } 52 | 53 | type nullableString struct { 54 | val *string 55 | } 56 | 57 | func (n *nullableString) String() string { 58 | if n.val == nil { 59 | return nilStr 60 | } 61 | return *n.val 62 | } 63 | 64 | func (n *nullableString) Set(value string) error { 65 | n.val = new(string) 66 | *n.val = value 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /flags_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "math/big" 6 | "strconv" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestNullableUint64ConversionToString(t *testing.T) { 12 | nilint := &nullableUint64{val: nil} 13 | if s := nilint.String(); s != "nil" { 14 | t.Errorf("Expected \"nil\", but got %v", s) 15 | } 16 | v := uint64(42) 17 | nonnilint := &nullableUint64{val: &v} 18 | if s, e := nonnilint.String(), strconv.FormatUint(v, 10); s != e { 19 | t.Errorf("Expected %v, but got %v", e, s) 20 | } 21 | } 22 | 23 | func TestNullableUint64Parsing(t *testing.T) { 24 | n := &nullableUint64{} 25 | if err := n.Set("-1"); err == nil { 26 | t.Error("Should fail on negative values") 27 | } 28 | if err := n.Set(""); err == nil { 29 | t.Error("Should fail on empty string") 30 | } 31 | b := big.NewInt(0) 32 | b.SetUint64(math.MaxUint64) 33 | b.Add(b, big.NewInt(1)) 34 | if err := n.Set(b.String()); err == nil { 35 | t.Error("Should fail on large values") 36 | } 37 | max := strconv.FormatUint(math.MaxUint64, 10) 38 | if err := n.Set(max); err != nil || *n.val != uint64(18446744073709551615) { 39 | t.Error("Shouldn't fail on max value") 40 | } 41 | } 42 | 43 | func TestNullableDurationConversionToString(t *testing.T) { 44 | nildur := &nullableDuration{val: nil} 45 | if s := nildur.String(); s != "nil" { 46 | t.Errorf("Expected \"nil\", but got %v", s) 47 | } 48 | d := time.Second 49 | nonnildir := &nullableDuration{val: &d} 50 | if s := nonnildir.String(); s != "1s" { 51 | t.Errorf("Expected 1s, but got %v", s) 52 | } 53 | } 54 | 55 | func TestNullableDurationParsing(t *testing.T) { 56 | d := &nullableDuration{} 57 | if err := d.Set(""); err == nil { 58 | t.Error("Should fail on empty string") 59 | } 60 | if err := d.Set("Wubba lubba dub dub!"); err == nil { 61 | t.Error("Should fail on incorrect values") 62 | } 63 | if err := d.Set("1s"); err != nil || *d.val != time.Second { 64 | t.Error("Shouldn't fail on correct values") 65 | } 66 | } 67 | 68 | func TestNullableStringConversionToString(t *testing.T) { 69 | ns := new(nullableString) 70 | if act := ns.String(); act != nilStr { 71 | t.Error("Unset nullableString should convert to \"nil\"") 72 | } 73 | someVal := "someval" 74 | if err := ns.Set(someVal); err != nil { 75 | t.Errorf("Couldn't set nullableString to %q", someVal) 76 | } 77 | if act := ns.String(); act != someVal { 78 | t.Errorf("Expected %q, but got %q", someVal, act) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /format.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type units struct { 8 | scale uint64 9 | base string 10 | units []string 11 | } 12 | 13 | var ( 14 | binaryUnits = &units{ 15 | scale: 1024, 16 | base: "", 17 | units: []string{"KB", "MB", "GB", "TB", "PB"}, 18 | } 19 | timeUnitsUs = &units{ 20 | scale: 1000, 21 | base: "us", 22 | units: []string{"ms", "s"}, 23 | } 24 | timeUnitsS = &units{ 25 | scale: 60, 26 | base: "s", 27 | units: []string{"m", "h"}, 28 | } 29 | ) 30 | 31 | func formatUnits(n float64, m *units, prec int) string { 32 | amt := n 33 | unit := m.base 34 | 35 | scale := float64(m.scale) * 0.85 36 | 37 | for i := 0; i < len(m.units) && amt >= scale; i++ { 38 | amt /= float64(m.scale) 39 | unit = m.units[i] 40 | } 41 | return fmt.Sprintf("%.*f%s", prec, amt, unit) 42 | } 43 | 44 | func formatBinary(n float64) string { 45 | return formatUnits(n, binaryUnits, 2) 46 | } 47 | 48 | func formatTimeUs(n float64) string { 49 | units := timeUnitsUs 50 | if n >= 1000000.0 { 51 | n /= 1000000.0 52 | units = timeUnitsS 53 | } 54 | return formatUnits(n, units, 2) 55 | } 56 | -------------------------------------------------------------------------------- /format_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | const ( 8 | KB = 1024 9 | MB = KB * 1024 10 | GB = MB * 1024 11 | 12 | K = 1000 13 | M = K * 1000 14 | ) 15 | 16 | func TestShouldFormatBinary(t *testing.T) { 17 | expectations := []struct { 18 | in float64 19 | out string 20 | }{ 21 | {10.0, "10.00"}, 22 | {10.001, "10.00"}, 23 | {1.0 * KB, "1.00KB"}, 24 | {1.2 * KB, "1.20KB"}, 25 | {1.202 * KB, "1.20KB"}, 26 | {5 * KB, "5.00KB"}, 27 | {1.0 * MB, "1.00MB"}, 28 | {1.3 * MB, "1.30MB"}, 29 | {1.302 * MB, "1.30MB"}, 30 | {6 * MB, "6.00MB"}, 31 | {1.0 * GB, "1.00GB"}, 32 | {1.4 * GB, "1.40GB"}, 33 | {1.402 * GB, "1.40GB"}, 34 | {7 * GB, "7.00GB"}, 35 | } 36 | for _, e := range expectations { 37 | actual := formatBinary(e.in) 38 | expected := e.out 39 | if expected != actual { 40 | t.Errorf("Expected \"%v\", but got \"%v\"", expected, actual) 41 | } 42 | } 43 | } 44 | 45 | func TestShouldFormatUs(t *testing.T) { 46 | expectations := []struct { 47 | in float64 48 | out string 49 | }{ 50 | {20, "20.00us"}, 51 | {22.222, "22.22us"}, 52 | {20 * K, "20.00ms"}, 53 | {20 * M, "20.00s"}, 54 | {60 * M, "1.00m"}, 55 | {10 * 60 * M, "10.00m"}, 56 | {90 * 60 * M, "1.50h"}, 57 | } 58 | for _, e := range expectations { 59 | actual := formatTimeUs(e.in) 60 | expected := e.out 61 | if expected != actual { 62 | t.Errorf("Expected \"%v\", but got \"%v\"", expected, actual) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codesenberg/bombardier 2 | 3 | go 1.22 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/alecthomas/kingpin v2.2.6+incompatible 9 | github.com/cheggaaa/pb v1.0.29 10 | github.com/codesenberg/concurrent v0.0.0-20180531114123-64560cfcf964 11 | github.com/goware/urlx v0.3.2 12 | github.com/juju/ratelimit v1.0.2 13 | github.com/satori/go.uuid v1.2.0 14 | github.com/valyala/fasthttp v1.59.0 15 | ) 16 | 17 | require ( 18 | github.com/PuerkitoBio/purell v1.2.1 // indirect 19 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 20 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect 21 | github.com/andybalholm/brotli v1.1.1 // indirect 22 | github.com/klauspost/compress v1.18.0 // indirect 23 | github.com/mattn/go-runewidth v0.0.16 // indirect 24 | github.com/rivo/uniseg v0.4.7 // indirect 25 | github.com/valyala/bytebufferpool v1.0.0 // indirect 26 | golang.org/x/net v0.35.0 // indirect 27 | golang.org/x/sys v0.30.0 // indirect 28 | golang.org/x/text v0.22.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= 2 | github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28= 3 | github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= 4 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 5 | github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI= 6 | github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= 7 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= 8 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 9 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= 10 | github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= 11 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 12 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 13 | github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= 14 | github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= 15 | github.com/codesenberg/concurrent v0.0.0-20180531114123-64560cfcf964 h1:9MVnbW3h0Dl4E2oADqwyvODphl9jY1r5HMtcB8U5mGs= 16 | github.com/codesenberg/concurrent v0.0.0-20180531114123-64560cfcf964/go.mod h1:82C6OyVM6eVk7qpBAZXE9uszHUuXWJMHHOeY+b/CSIA= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 21 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 22 | github.com/goware/urlx v0.3.2 h1:gdoo4kBHlkqZNaf6XlQ12LGtQOmpKJrR04Rc3RnpJEo= 23 | github.com/goware/urlx v0.3.2/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw= 24 | github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= 25 | github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= 26 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 27 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 28 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 29 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 30 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 31 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 32 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 33 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 34 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 35 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 39 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 40 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 41 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 42 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 43 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 44 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 45 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 46 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 47 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 48 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 49 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 50 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 51 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 52 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 53 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 54 | github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI= 55 | github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU= 56 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 57 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 58 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 59 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 60 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 61 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 64 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 67 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 68 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 69 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 70 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 71 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 72 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 73 | -------------------------------------------------------------------------------- /headers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type header struct { 9 | key, value string 10 | } 11 | 12 | type headersList []header 13 | 14 | func (h *headersList) String() string { 15 | return fmt.Sprint(*h) 16 | } 17 | 18 | func (h *headersList) IsCumulative() bool { 19 | return true 20 | } 21 | 22 | func (h *headersList) Set(value string) error { 23 | res := strings.SplitN(value, ":", 2) 24 | if len(res) != 2 { 25 | return errInvalidHeaderFormat 26 | } 27 | *h = append(*h, header{ 28 | res[0], strings.Trim(res[1], " "), 29 | }) 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /headers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestHeadersToStringConversion(t *testing.T) { 8 | expectations := []struct { 9 | in headersList 10 | out string 11 | }{ 12 | { 13 | []header{}, 14 | "[]", 15 | }, 16 | { 17 | []header{ 18 | {"Key1", "Value1"}, 19 | {"Key2", "Value2"}}, 20 | "[{Key1 Value1} {Key2 Value2}]", 21 | }, 22 | } 23 | for _, e := range expectations { 24 | actual := e.in.String() 25 | expected := e.out 26 | if expected != actual { 27 | t.Errorf("Expected \"%v\", but got \"%v\"", expected, actual) 28 | } 29 | } 30 | } 31 | 32 | func TestShouldErrorOnInvalidFormat(t *testing.T) { 33 | h := new(headersList) 34 | if err := h.Set("Yaba daba do"); err == nil { 35 | t.Error("Should fail on strings without colon") 36 | } 37 | } 38 | 39 | func TestShouldProperlyAddValidHeaders(t *testing.T) { 40 | h := new(headersList) 41 | for _, hs := range []string{"Key1: Value1", "Key2: Value2"} { 42 | if err := h.Set(hs); err != nil { 43 | t.Error(err) 44 | } 45 | } 46 | e := []header{{"Key1", "Value1"}, {"Key2", "Value2"}} 47 | for i, v := range *h { 48 | if e[i] != v { 49 | t.Fail() 50 | } 51 | } 52 | } 53 | 54 | func TestShouldTrimHeaderValues(t *testing.T) { 55 | h := new(headersList) 56 | if err := h.Set("Key: Value "); err != nil { 57 | t.Error(err) 58 | } 59 | if (*h)[0].key != "Key" || (*h)[0].value != "Value" { 60 | t.Fail() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesenberg/bombardier/2d495aca5b233bec0a5334dc4aa3cbdaef6e1bc5/img/logo.png -------------------------------------------------------------------------------- /internal/test_info.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "math" 5 | "net/url" 6 | "sort" 7 | "time" 8 | ) 9 | 10 | // TestInfo holds information about what specification was used 11 | // to perform the test and results of the test. 12 | type TestInfo struct { 13 | Spec Spec 14 | Result Results 15 | } 16 | 17 | // Header represents HTTP header. 18 | type Header struct { 19 | Key, Value string 20 | } 21 | 22 | // Spec contains information about test performed. 23 | type Spec struct { 24 | NumberOfConnections uint64 25 | 26 | TestType TestType 27 | NumberOfRequests uint64 28 | TestDuration time.Duration 29 | 30 | Method string 31 | URL *url.URL 32 | 33 | Headers []Header 34 | 35 | Body string 36 | BodyFilePath string 37 | 38 | CertPath string 39 | KeyPath string 40 | 41 | Stream bool 42 | Timeout time.Duration 43 | ClientType ClientType 44 | 45 | Rate *uint64 46 | } 47 | 48 | // RequestURL returns URL as string. 49 | func (s Spec) RequestURL() string { 50 | return s.URL.String() 51 | } 52 | 53 | // IsTimedTest tells if the test was limited by time. 54 | func (s Spec) IsTimedTest() bool { 55 | return s.TestType == ByTime 56 | } 57 | 58 | // IsTestWithNumberOfReqs tells if the test was limited by the number 59 | // of requests. 60 | func (s Spec) IsTestWithNumberOfReqs() bool { 61 | return s.TestType == ByNumberOfReqs 62 | } 63 | 64 | // IsFastHTTP tells whether fasthttp were used as HTTP client to 65 | // perform the test. 66 | func (s Spec) IsFastHTTP() bool { 67 | return s.ClientType == FastHTTP 68 | } 69 | 70 | // IsNetHTTPV1 tells whether Go's default net/http library and 71 | // HTTP/1.x were used to perform the test. 72 | func (s Spec) IsNetHTTPV1() bool { 73 | return s.ClientType == NetHTTP1 74 | } 75 | 76 | // IsNetHTTPV2 tells whether Go's default net/http library and 77 | // HTTP/1.x (or HTTP/2.0, if possible) were used to perform the test. 78 | func (s Spec) IsNetHTTPV2() bool { 79 | return s.ClientType == NetHTTP2 80 | } 81 | 82 | // Results holds results of the test. 83 | type Results struct { 84 | BytesRead, BytesWritten int64 85 | TimeTaken time.Duration 86 | 87 | Req1XX, Req2XX, Req3XX, Req4XX, Req5XX uint64 88 | Others uint64 89 | 90 | Errors []ErrorWithCount 91 | 92 | Latencies ReadonlyUint64Histogram 93 | Requests ReadonlyFloat64Histogram 94 | } 95 | 96 | // ReadonlyUint64Histogram is a readonly histogram with uint64 keys 97 | type ReadonlyUint64Histogram interface { 98 | Get(uint64) uint64 99 | VisitAll(func(uint64, uint64) bool) 100 | Count() uint64 101 | } 102 | 103 | // ReadonlyFloat64Histogram is a readonly histogram with float64 keys 104 | type ReadonlyFloat64Histogram interface { 105 | Get(float64) uint64 106 | VisitAll(func(float64, uint64) bool) 107 | Count() uint64 108 | } 109 | 110 | // Throughput returns total throughput (read + write) in bytes per 111 | // second 112 | func (r Results) Throughput() float64 { 113 | return float64(r.BytesRead+r.BytesWritten) / r.TimeTaken.Seconds() 114 | } 115 | 116 | // LatenciesStats contains statistical information about latencies. 117 | type LatenciesStats struct { 118 | // These are in microseconds 119 | Mean float64 120 | Stddev float64 121 | Max float64 122 | 123 | // This is map[0.0 <= p <= 1.0 (percentile)]microseconds 124 | Percentiles map[float64]uint64 125 | } 126 | 127 | // LatenciesStats performs various statistical calculations on 128 | // latencies. 129 | func (r Results) LatenciesStats(percentiles []float64) *LatenciesStats { 130 | h := r.Latencies 131 | sum := uint64(0) 132 | count := uint64(0) 133 | max := uint64(0) 134 | pairs := make([]struct{ k, v uint64 }, 0, h.Count()) 135 | 136 | // Gather all the data 137 | h.VisitAll(func(f uint64, c uint64) bool { 138 | if f > max { 139 | max = f 140 | } 141 | sum += f * c 142 | count += c 143 | pairs = append(pairs, struct{ k, v uint64 }{f, c}) 144 | return true 145 | }) 146 | if count < 1 { 147 | return nil 148 | } 149 | 150 | // Calculate percentiles 151 | sort.Slice(pairs, func(i, j int) bool { 152 | return pairs[i].k < pairs[j].k 153 | }) 154 | percentilesMap := map[float64]uint64{} 155 | for _, pc := range percentiles { 156 | if _, calculated := percentilesMap[pc]; calculated { 157 | continue 158 | } 159 | if pc < 0 || pc > 1 { 160 | // Drop percentiles outside of [0, 1] range 161 | continue 162 | } 163 | rank := uint64(pc*float64(count) + 0.5) 164 | total := uint64(0) 165 | for _, p := range pairs { 166 | total += p.v 167 | if total >= rank { 168 | percentilesMap[pc] = p.k 169 | break 170 | } 171 | } 172 | } 173 | 174 | // Calculate mean and standard deviation 175 | mean := float64(sum) / float64(count) 176 | sumOfSquares := float64(0) 177 | h.VisitAll(func(f uint64, c uint64) bool { 178 | sumOfSquares += math.Pow(float64(f)-mean, 2) 179 | return true 180 | }) 181 | stddev := 0.0 182 | if count > 2 { 183 | stddev = math.Sqrt(sumOfSquares / float64(count)) 184 | } 185 | return &LatenciesStats{ 186 | Mean: mean, 187 | Stddev: stddev, 188 | Max: float64(max), 189 | 190 | Percentiles: percentilesMap, 191 | } 192 | } 193 | 194 | // RequestsStats contains statistical information about requests. 195 | type RequestsStats struct { 196 | // These are in requests per second. 197 | Mean float64 198 | Stddev float64 199 | Max float64 200 | 201 | // This is map[0.0 <= p <= 1.0 (percentile)](req-s per second) 202 | Percentiles map[float64]float64 203 | } 204 | 205 | // RequestsStats performs various statistical calculations on 206 | // latencies. 207 | func (r Results) RequestsStats(percentiles []float64) *RequestsStats { 208 | h := r.Requests 209 | sum := float64(0) 210 | count := uint64(0) 211 | max := float64(0) 212 | pairs := make([]struct { 213 | k float64 214 | v uint64 215 | }, 0, h.Count()) 216 | 217 | // Gather all the data 218 | h.VisitAll(func(f float64, c uint64) bool { 219 | if math.IsInf(f, 0) || math.IsNaN(f) { 220 | return true 221 | } 222 | if f > max { 223 | max = f 224 | } 225 | sum += f * float64(c) 226 | count += c 227 | pairs = append(pairs, struct { 228 | k float64 229 | v uint64 230 | }{f, c}) 231 | return true 232 | }) 233 | if count < 1 { 234 | return nil 235 | } 236 | 237 | // Calculate percentiles 238 | sort.Slice(pairs, func(i, j int) bool { 239 | return pairs[i].k < pairs[j].k 240 | }) 241 | percentilesMap := map[float64]float64{} 242 | for _, pc := range percentiles { 243 | if _, calculated := percentilesMap[pc]; calculated { 244 | continue 245 | } 246 | if pc < 0 || pc > 1 { 247 | // Drop percentiles outside of [0, 1] range 248 | continue 249 | } 250 | rank := uint64(pc*float64(count) + 0.5) 251 | total := uint64(0) 252 | for _, p := range pairs { 253 | total += p.v 254 | if total >= rank { 255 | percentilesMap[pc] = p.k 256 | break 257 | } 258 | } 259 | } 260 | 261 | // Calculate mean and standard deviation 262 | mean := sum / float64(count) 263 | sumOfSquares := float64(0) 264 | h.VisitAll(func(f float64, c uint64) bool { 265 | if math.IsInf(f, 0) || math.IsNaN(f) { 266 | return true 267 | } 268 | sumOfSquares += math.Pow(f-mean, 2) 269 | return true 270 | }) 271 | stddev := 0.0 272 | if count > 2 { 273 | const besselCorrection = 1.0 274 | stddev = math.Sqrt(sumOfSquares / (float64(count) - besselCorrection)) 275 | } 276 | return &RequestsStats{ 277 | Mean: mean, 278 | Stddev: stddev, 279 | Max: max, 280 | 281 | Percentiles: percentilesMap, 282 | } 283 | } 284 | 285 | // ErrorWithCount contains error description alongside with number of 286 | // times this error occurred. 287 | type ErrorWithCount struct { 288 | Error string 289 | Count uint64 290 | } 291 | 292 | // TestType represents the type of test that were performed. 293 | type TestType int 294 | 295 | const ( 296 | _ TestType = iota 297 | // ByTime is a test limited by durations. 298 | ByTime 299 | // ByNumberOfReqs is a test limited by number of requests 300 | // performed. 301 | ByNumberOfReqs 302 | ) 303 | 304 | // ClientType is the type of HTTP client used in test 305 | type ClientType int 306 | 307 | const ( 308 | // FastHTTP is fasthttp's HTTP client 309 | FastHTTP ClientType = iota 310 | // NetHTTP1 is Go's default HTTP client with forced HTTP/1.x 311 | NetHTTP1 312 | // NetHTTP2 is Go's default HTTP client with HTTP/2.0 permitted. 313 | NetHTTP2 314 | ) 315 | -------------------------------------------------------------------------------- /limiter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "sync" 6 | "time" 7 | 8 | "github.com/juju/ratelimit" 9 | ) 10 | 11 | type token uint64 12 | 13 | const ( 14 | brk token = iota 15 | cont 16 | ) 17 | 18 | type limiter interface { 19 | pace(<-chan struct{}) token 20 | } 21 | 22 | type nooplimiter struct{} 23 | 24 | func (n *nooplimiter) pace(<-chan struct{}) token { 25 | return cont 26 | } 27 | 28 | type bucketlimiter struct { 29 | limiter *ratelimit.Bucket 30 | timerPool *sync.Pool 31 | } 32 | 33 | func newBucketLimiter(rate uint64) limiter { 34 | fillInterval, quantum := estimate(rate, rateLimitInterval) 35 | return &bucketlimiter{ 36 | ratelimit.NewBucketWithQuantum( 37 | fillInterval, int64(quantum), int64(quantum), 38 | ), 39 | &sync.Pool{ 40 | New: func() interface{} { 41 | return time.NewTimer(math.MaxInt64) 42 | }, 43 | }, 44 | } 45 | } 46 | 47 | func (b *bucketlimiter) pace(done <-chan struct{}) (res token) { 48 | wd := b.limiter.Take(1) 49 | if wd <= 0 { 50 | return cont 51 | } 52 | 53 | timer := b.timerPool.Get().(*time.Timer) 54 | timer.Reset(wd) 55 | select { 56 | case <-timer.C: 57 | res = cont 58 | case <-done: 59 | res = brk 60 | } 61 | b.timerPool.Put(timer) 62 | return 63 | } 64 | -------------------------------------------------------------------------------- /limiter_barrier_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | "testing" 7 | ) 8 | 9 | func TestNoopLimiterCounterBarrierCombination(t *testing.T) { 10 | expectations := []uint64{ 11 | 1, 15, 50, 100, 150, 500, 1000, 1500, 5000, 12 | } 13 | done := make(chan struct{}) 14 | for _, count := range expectations { 15 | b := newCountingCompletionBarrier(count) 16 | var lim limiter = &nooplimiter{} 17 | counter := uint64(0) 18 | numParties := 10 19 | var wg sync.WaitGroup 20 | wg.Add(numParties) 21 | for i := 0; i < numParties; i++ { 22 | go func() { 23 | defer wg.Done() 24 | for b.tryGrabWork() { 25 | lim.pace(done) 26 | atomic.AddUint64(&counter, 1) 27 | b.jobDone() 28 | } 29 | }() 30 | } 31 | wg.Wait() 32 | if counter != count { 33 | t.Error(count, counter) 34 | } 35 | } 36 | } 37 | 38 | func TestBucketLimiterCounterBarrierCombination(t *testing.T) { 39 | expectations := []struct { 40 | count, rate uint64 41 | }{ 42 | {10, 100}, 43 | {10, 1000}, 44 | {100, 1000}, 45 | {100, 10000}, 46 | {1000, 10000}, 47 | {1000, 100000}, 48 | } 49 | done := make(chan struct{}) 50 | var expWg sync.WaitGroup 51 | expWg.Add(len(expectations)) 52 | for i := range expectations { 53 | exp := expectations[i] 54 | go func() { 55 | defer expWg.Done() 56 | b := newCountingCompletionBarrier(exp.count) 57 | lim := newBucketLimiter(exp.rate) 58 | counter := uint64(0) 59 | numParties := 10 60 | var wg sync.WaitGroup 61 | wg.Add(numParties) 62 | for i := 0; i < numParties; i++ { 63 | go func() { 64 | defer wg.Done() 65 | for b.tryGrabWork() { 66 | lim.pace(done) 67 | atomic.AddUint64(&counter, 1) 68 | b.jobDone() 69 | } 70 | }() 71 | } 72 | wg.Wait() 73 | if counter != exp.count { 74 | t.Error(exp.count, counter) 75 | } 76 | }() 77 | } 78 | expWg.Wait() 79 | } 80 | -------------------------------------------------------------------------------- /limiter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "runtime" 5 | "sync" 6 | "sync/atomic" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | const maxRps = 10000000 12 | 13 | func TestNoopLimiter(t *testing.T) { 14 | var lim limiter = &nooplimiter{} 15 | done := make(chan struct{}) 16 | counter := uint64(0) 17 | var wg sync.WaitGroup 18 | wg.Add(int(defaultNumberOfConns)) 19 | for i := uint64(0); i < defaultNumberOfConns; i++ { 20 | go func() { 21 | defer wg.Done() 22 | for { 23 | res := lim.pace(done) 24 | if res != cont { 25 | t.Error("nooplimiter should always return cont") 26 | } 27 | atomic.AddUint64(&counter, 1) 28 | select { 29 | case <-done: 30 | return 31 | default: 32 | } 33 | } 34 | }() 35 | } 36 | time.Sleep(100 * time.Millisecond) 37 | close(done) 38 | wg.Wait() 39 | if counter == 0 { 40 | t.Error("no events happened") 41 | } 42 | } 43 | 44 | func BenchmarkNoopLimiter(bm *testing.B) { 45 | var lim limiter = &nooplimiter{} 46 | done := make(chan struct{}) 47 | bm.SetParallelism(int(defaultNumberOfConns) / runtime.NumCPU()) 48 | bm.ResetTimer() 49 | bm.RunParallel(func(pb *testing.PB) { 50 | for pb.Next() { 51 | lim.pace(done) 52 | } 53 | }) 54 | } 55 | 56 | func TestBucketLimiterLowRates(t *testing.T) { 57 | expectations := []struct { 58 | rate uint64 59 | duration time.Duration 60 | }{ 61 | {1, 1 * time.Second}, 62 | {10, 1 * time.Second}, 63 | {15, 1 * time.Second}, 64 | {50, 1 * time.Second}, 65 | {100, 1 * time.Second}, 66 | {150, 1 * time.Second}, 67 | {500, 1 * time.Second}, 68 | {1000, 1 * time.Second}, 69 | {1500, 1 * time.Second}, 70 | {5000, 1 * time.Second}, 71 | } 72 | for i := range expectations { 73 | exp := expectations[i] 74 | lim := newBucketLimiter(exp.rate) 75 | done := make(chan struct{}) 76 | counter := uint64(0) 77 | waitChan := make(chan struct{}) 78 | go func() { 79 | defer func() { 80 | waitChan <- struct{}{} 81 | }() 82 | for lim.pace(done) == cont { 83 | counter++ 84 | } 85 | }() 86 | time.Sleep(exp.duration) 87 | close(done) 88 | select { 89 | case <-waitChan: 90 | case <-time.After(exp.duration + 100*time.Millisecond): 91 | t.Error("failed to complete: ", exp) 92 | return 93 | } 94 | expcounter := float64(exp.rate) * exp.duration.Seconds() 95 | var ( 96 | lowerBound = 0.5 * expcounter 97 | upperBound = 1.2*expcounter + 5 98 | ) 99 | if float64(counter) < lowerBound || 100 | float64(counter) > upperBound { 101 | t.Errorf("(lower bound, actual, upper bound): (%11.2f, %11d, %11.2f)", lowerBound, counter, upperBound) 102 | } 103 | } 104 | } 105 | 106 | func TestBucketLimiterHighRates(t *testing.T) { 107 | expectations := []struct { 108 | rate uint64 109 | duration time.Duration 110 | }{ 111 | {100000, 100 * time.Millisecond}, 112 | {150000, 100 * time.Millisecond}, 113 | {200000, 100 * time.Millisecond}, 114 | {500000, 100 * time.Millisecond}, 115 | {1000000, 100 * time.Millisecond}, 116 | } 117 | for i := range expectations { 118 | exp := expectations[i] 119 | lim := newBucketLimiter(exp.rate) 120 | counter := uint64(0) 121 | done := make(chan struct{}) 122 | waitChan := make(chan struct{}) 123 | go func() { 124 | defer func() { 125 | waitChan <- struct{}{} 126 | }() 127 | for lim.pace(done) == cont { 128 | counter++ 129 | } 130 | }() 131 | time.Sleep(exp.duration) 132 | close(done) 133 | select { 134 | case <-waitChan: 135 | case <-time.After(exp.duration + 50*time.Millisecond): 136 | t.Error("failed to complete: ", exp) 137 | return 138 | } 139 | expcounter := float64(exp.rate) * exp.duration.Seconds() 140 | var ( 141 | lowerBound = 0.5 * expcounter 142 | upperBound = 1.2*expcounter + 5 143 | ) 144 | if float64(counter) < lowerBound || 145 | float64(counter) > upperBound { 146 | t.Errorf("(lower bound, actual, upper bound): (%11.2f, %11d, %11.2f)", lowerBound, counter, upperBound) 147 | } 148 | } 149 | } 150 | 151 | func BenchmarkBucketLimiter(bm *testing.B) { 152 | lim := newBucketLimiter(maxRps) 153 | done := make(chan struct{}) 154 | bm.SetParallelism(int(defaultNumberOfConns) / runtime.NumCPU()) 155 | bm.ResetTimer() 156 | bm.RunParallel(func(pb *testing.PB) { 157 | for pb.Next() { 158 | lim.pace(done) 159 | } 160 | }) 161 | } 162 | -------------------------------------------------------------------------------- /proxy_reader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "io" 4 | 5 | type proxyReader struct { 6 | io.Reader 7 | } 8 | -------------------------------------------------------------------------------- /rateestimator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/big" 5 | "time" 6 | ) 7 | 8 | const ( 9 | panicZeroRate = "rate can't be zero" 10 | panicNegativeAdjustTo = "adjustTo can't be negative or zero" 11 | ) 12 | 13 | func estimate(rate uint64, adjustTo time.Duration) (time.Duration, uint64) { 14 | if rate == 0 { 15 | panic(panicZeroRate) 16 | } 17 | if adjustTo <= 0 { 18 | panic(panicNegativeAdjustTo) 19 | } 20 | br := new(big.Int).SetUint64(rate) 21 | bd := new(big.Int).SetInt64(oneSecond.Nanoseconds()) 22 | gcd := new(big.Int).GCD(nil, nil, br, bd).Uint64() 23 | nr, nd := rate/gcd, uint64(oneSecond.Nanoseconds())/gcd 24 | adjustInt := uint64(adjustTo.Nanoseconds()) 25 | if nd >= adjustInt { 26 | return time.Duration(nd), nr 27 | } 28 | coef := adjustInt / nd 29 | return time.Duration(coef * nd), coef * nr 30 | } 31 | -------------------------------------------------------------------------------- /rateestimator_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestRateEstimatorPanicWithZeroRate(t *testing.T) { 9 | defer func() { 10 | pv, ok := recover().(string) 11 | if !ok { 12 | t.Error("expected string value") 13 | return 14 | } 15 | if pv != panicZeroRate { 16 | t.Error(panicZeroRate, pv) 17 | } 18 | }() 19 | _, _ = estimate(0, 10*time.Second) 20 | t.Error("should fail with rate == 0") 21 | } 22 | 23 | func TestRateEstimatorPanicWithNegativeAdjustTo(t *testing.T) { 24 | defer func() { 25 | pv, ok := recover().(string) 26 | if !ok { 27 | t.Error("expected string value") 28 | return 29 | } 30 | if pv != panicNegativeAdjustTo { 31 | t.Error(panicNegativeAdjustTo, pv) 32 | } 33 | }() 34 | _, _ = estimate(10, -10*time.Second) 35 | t.Error("should fail with adjustTo <= 0") 36 | } 37 | 38 | func TestRateEstimatorAccuracy(t *testing.T) { 39 | defer func() { 40 | rv := recover() 41 | if rv != nil { 42 | t.Error(rv) 43 | } 44 | }() 45 | expectations := []struct { 46 | rate uint64 47 | adjustTo time.Duration 48 | expectedQuantum uint64 49 | expectedFillInterval time.Duration 50 | }{ 51 | {1, 100 * time.Millisecond, 1, 1 * time.Second}, 52 | {1, 1000 * time.Millisecond, 1, 1 * time.Second}, 53 | {1, 2000 * time.Millisecond, 2, 2 * time.Second}, 54 | {1, 3000 * time.Millisecond, 3, 3 * time.Second}, 55 | {4, 3000 * time.Millisecond, 12, 3 * time.Second}, 56 | {10000, 100 * time.Millisecond, 1000, 100 * time.Millisecond}, 57 | {100000, 100 * time.Millisecond, 10000, 100 * time.Millisecond}, 58 | {1000000, 100 * time.Millisecond, 100000, 100 * time.Millisecond}, 59 | } 60 | for _, exp := range expectations { 61 | actualFillInterval, actualQuantum := estimate(exp.rate, exp.adjustTo) 62 | if actualFillInterval != exp.expectedFillInterval || 63 | actualQuantum != exp.expectedQuantum { 64 | t.Log("Expected: ", exp.expectedQuantum, exp.expectedFillInterval) 65 | t.Log("Actual: ", actualQuantum, actualFillInterval) 66 | t.Fail() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /template/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package template documents the way user-defined output templates are 3 | ment to be used. 4 | 5 | User-defined templates use Go's text/template package, so you might 6 | want to check its documentation first. 7 | There are a bunch of helper methods available inside a template 8 | besides those described in aforementioned documentation, namely: 9 | - URLString() 10 | Returns the URL string used for the load test. 11 | - WithLatencies() 12 | Tells whether --latencies flag were activated. 13 | - FormatBinary(numberOfBytes float64) string 14 | Converts bytes to kilo-, mega-, giga-, etc.- bytes, and 15 | appends appropriate suffix "KB", "MB", "GB", etc. 16 | - FormatTimeUs(us float64) string 17 | Converts microseconds to milliseconds, seconds, minutes or 18 | hours and appends appropriate suffix. 19 | - FormatTimeUsUint64(us uint64) string 20 | Same as above, but for uint64, since type conversions are 21 | not available in templates. 22 | - FloatsToArray(ps ...float64) []float64 23 | Converts a bunch of floats into array, since, again, 24 | type conversions are not available in templates. 25 | - Multiply(num, coeff float64) float64 26 | Arithmetics are not available inside of templates either. 27 | - StringToBytes(s string) []byte 28 | Convenience function to convert string to []byte. 29 | - UUIDV1() (UUID, error) 30 | Generates UUID Version 1, based on timestamp and 31 | MAC address (RFC 4122) 32 | - UUIDV2(domain byte) (UUID, error) 33 | Generates UUID Version 2, based on timestamp, MAC address 34 | and POSIX UID/GID (DCE 1.1) 35 | - UUIDV3(ns UUID, name string) UUID 36 | Generates UUID Version 3, based on MD5 hashing (RFC 4122) 37 | - UUIDV4() (UUID, error) 38 | Generates UUID Version 4, based on random numbers (RFC 4122) 39 | - UUIDV5(ns UUID, name string) UUID 40 | Generates UUID Version 5, based on SHA-1 hashing (RFC 4122) 41 | 42 | The structure that gets passed to the template is documented in 43 | the package github.com/codesenberg/bombardier/internal. The structure 44 | of interest is TestInfo. It basically consists of Spec and Result 45 | fields, the former contains various information about the test 46 | (number of connections, URL, HTTP method, headers, body, rate, etc.) 47 | performed, while the latter contains results obtained during the 48 | execution of this test (bytes read/written, time taken, RPS, etc.). 49 | 50 | Link to GoDoc for the structure used in template: 51 | https://godoc.org/github.com/codesenberg/bombardier/internal#TestInfo 52 | 53 | Examples of templates can be found in: 54 | https://github.com/codesenberg/bombardier/blob/master/templates.go 55 | */ 56 | package template 57 | -------------------------------------------------------------------------------- /templates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "strings" 4 | 5 | var ( 6 | templates = map[string][]byte{ 7 | "plain-text": []byte(plainTextTemplate), 8 | "json": []byte(jsonTemplate), 9 | } 10 | ) 11 | 12 | type format interface{} 13 | type knownFormat string 14 | 15 | func (kf knownFormat) template() []byte { 16 | return templates[string(kf)] 17 | } 18 | 19 | type filePath string 20 | type userDefinedTemplate filePath 21 | 22 | func formatFromString(formatSpec string) format { 23 | const prefix = "path:" 24 | if strings.HasPrefix(formatSpec, prefix) { 25 | return userDefinedTemplate(formatSpec[len(prefix):]) 26 | } 27 | switch formatSpec { 28 | case "pt", "plain-text": 29 | return knownFormat("plain-text") 30 | case "j", "json": 31 | return knownFormat("json") 32 | } 33 | // nil represents unknown format 34 | return nil 35 | } 36 | 37 | const ( 38 | plainTextTemplate = ` 39 | {{- printf "%10v %10v %10v %10v" "Statistics" "Avg" "Stdev" "Max" }} 40 | {{ with .Result.RequestsStats (FloatsToArray 0.5 0.75 0.9 0.95 0.99) }} 41 | {{- printf " %-10v %10.2f %10.2f %10.2f" "Reqs/sec" .Mean .Stddev .Max -}} 42 | {{ else }} 43 | {{- print " There wasn't enough data to compute statistics for requests." }} 44 | {{ end }} 45 | {{ with .Result.LatenciesStats (FloatsToArray 0.5 0.75 0.9 0.95 0.99) }} 46 | {{- printf " %-10v %10v %10v %10v" "Latency" (FormatTimeUs .Mean) (FormatTimeUs .Stddev) (FormatTimeUs .Max) }} 47 | {{- if WithLatencies }} 48 | {{- "\n Latency Distribution" }} 49 | {{- range $pc, $lat := .Percentiles }} 50 | {{- printf "\n %2.0f%% %10s" (Multiply $pc 100) (FormatTimeUsUint64 $lat) -}} 51 | {{ end -}} 52 | {{ end }} 53 | {{ else }} 54 | {{- print " There wasn't enough data to compute statistics for latencies." }} 55 | {{ end -}} 56 | {{ with .Result -}} 57 | {{ " HTTP codes:" }} 58 | {{ printf " 1xx - %v, 2xx - %v, 3xx - %v, 4xx - %v, 5xx - %v" .Req1XX .Req2XX .Req3XX .Req4XX .Req5XX }} 59 | {{- printf "\n others - %v" .Others }} 60 | {{- with .Errors }} 61 | {{- "\n Errors:"}} 62 | {{- range . }} 63 | {{- printf "\n %10v - %v" .Error .Count }} 64 | {{- end -}} 65 | {{ end -}} 66 | {{ end }} 67 | {{ printf " %-10v %10v/s\n" "Throughput:" (FormatBinary .Result.Throughput)}}` 68 | jsonTemplate = `{"spec":{ 69 | {{- with .Spec -}} 70 | "numberOfConnections":{{ .NumberOfConnections }} 71 | 72 | {{- if .IsTimedTest -}} 73 | ,"testType":"timed","testDurationSeconds":{{ .TestDuration.Seconds }} 74 | {{- else -}} 75 | ,"testType":"number-of-requests","numberOfRequests":{{ .NumberOfRequests }} 76 | {{- end -}} 77 | 78 | ,"method":"{{ .Method }}","url":{{ .RequestURL | printf "%q" }} 79 | 80 | {{- with .Headers -}} 81 | ,"headers":[ 82 | {{- range $index, $header := . -}} 83 | {{- if ne $index 0 -}},{{- end -}} 84 | {"key":{{ .Key | printf "%q" }},"value":{{ .Value | printf "%q" }}} 85 | {{- end -}} 86 | ] 87 | {{- end -}} 88 | 89 | {{- if .BodyFilePath -}} 90 | ,"bodyFilePath":{{ .BodyFilePath | printf "%q" }} 91 | {{- else -}} 92 | ,"body":{{ .Body | printf "%q" }} 93 | {{- end -}} 94 | 95 | {{- if .CertPath -}} 96 | ,"certPath":{{ .CertPath | printf "%q" }} 97 | {{- end -}} 98 | {{- if .KeyPath -}} 99 | ,"keyPath":{{ .KeyPath | printf "%q" }} 100 | {{- end -}} 101 | 102 | ,"stream":{{ .Stream }},"timeoutSeconds":{{ .Timeout.Seconds }} 103 | 104 | {{- if .IsFastHTTP -}} 105 | ,"client":"fasthttp" 106 | {{- end -}} 107 | {{- if .IsNetHTTPV1 -}} 108 | ,"client":"net/http.v1" 109 | {{- end -}} 110 | {{- if .IsNetHTTPV2 -}} 111 | ,"client":"net/http.v2" 112 | {{- end -}} 113 | 114 | {{- with .Rate -}} 115 | ,"rate":{{ . }} 116 | {{- end -}} 117 | {{- end -}} 118 | }, 119 | 120 | {{- with .Result -}} 121 | "result":{"bytesRead":{{ .BytesRead -}} 122 | ,"bytesWritten":{{ .BytesWritten -}} 123 | ,"timeTakenSeconds":{{ .TimeTaken.Seconds -}} 124 | 125 | ,"req1xx":{{ .Req1XX -}} 126 | ,"req2xx":{{ .Req2XX -}} 127 | ,"req3xx":{{ .Req3XX -}} 128 | ,"req4xx":{{ .Req4XX -}} 129 | ,"req5xx":{{ .Req5XX -}} 130 | ,"others":{{ .Others -}} 131 | 132 | {{- with .Errors -}} 133 | ,"errors":[ 134 | {{- range $index, $error := . -}} 135 | {{- if ne $index 0 -}},{{- end -}} 136 | {"description":{{ .Error | printf "%q" }},"count":{{ .Count }}} 137 | {{- end -}} 138 | ] 139 | {{- end -}} 140 | 141 | {{- with .LatenciesStats (FloatsToArray 0.5 0.75 0.9 0.95 0.99) -}} 142 | ,"latency":{"mean":{{ .Mean -}} 143 | ,"stddev":{{ .Stddev -}} 144 | ,"max":{{ .Max -}} 145 | 146 | {{- if WithLatencies -}} 147 | ,"percentiles":{ 148 | {{- range $pc, $lat := .Percentiles }} 149 | {{- if ne $pc 0.5 -}},{{- end -}} 150 | {{- printf "\"%2.0f\":%d" (Multiply $pc 100) $lat -}} 151 | {{- end -}} 152 | } 153 | {{- end -}} 154 | 155 | } 156 | {{- end -}} 157 | 158 | {{- with .RequestsStats (FloatsToArray 0.5 0.75 0.9 0.95 0.99) -}} 159 | ,"rps":{"mean":{{ .Mean -}} 160 | ,"stddev":{{ .Stddev -}} 161 | ,"max":{{ .Max -}} 162 | ,"percentiles":{ 163 | {{- range $pc, $rps := .Percentiles }} 164 | {{- if ne $pc 0.5 -}},{{- end -}} 165 | {{- printf "\"%2.0f\":%f" (Multiply $pc 100) $rps -}} 166 | {{- end -}} 167 | }} 168 | {{- end -}} 169 | }} 170 | {{- end -}}` 171 | ) 172 | -------------------------------------------------------------------------------- /testbody.txt: -------------------------------------------------------------------------------- 1 | abracadabra -------------------------------------------------------------------------------- /testclient.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFITCCAwmgAwIBAgIJAMx2fpQ+fhOZMA0GCSqGSIb3DQEBCwUAMCYxJDAiBgNV 3 | BAMMG0JvbWJhcmRpZXIgQ2xpZW50IFRlc3QgQ2VydDAgFw0xNzAxMjYwNjM2MDda 4 | GA8zMDE2MDUyOTA2MzYwN1owJjEkMCIGA1UEAwwbQm9tYmFyZGllciBDbGllbnQg 5 | VGVzdCBDZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1TE9F36z 6 | fsSmXbxkfNonYggVA7skrb+10iuLyey5snkOHLszEjmL3Gtux02GaqZ8u3GdfeZ/ 7 | Am2KqFnLq/YiZwYJpwpB54PAtKQqCiIcAPCENZdPzuya4bWTP+/bLiLdDY0kJhxM 8 | rReufBL+xBOFrRhLcsW+tECGsu+d39o7JY7oWnm7IQYX7cxK1JgaFL5kmUuaoJN0 9 | iJhB7V3JIQJMC68Yr5dzrYdzwzc1uxm43Y696HYAPkygf41ZoHo5UKWCI9V9M0iz 10 | 8oUoWrLdlXHOVaQKpPV9+aQYBiG1KNePvWZ4PCvukXv+zgLP1SvvqNTQKi2HCV37 11 | RZQd0M0Do9aqrtlDrQLeKE39XZQMBKrype7Vr5JcnXMlaC4A8WFF/+cl11V6m4eY 12 | 8GLnoTv+l/G2Hbjwm6/oLPCvrErqd0M+6JK9jXnXHwNr05FpEVqwdYZ5K10bYBBU 13 | wY+oZM8sGrT0Hd0N0PHtcs2eZ6yYLNrAaTvZT3w/sFgEqrDKn6c5WJdKO9PKSvbb 14 | E7whD+WkZPeN2ndh+lGYAEnVzyzVgKmNPOPGFa244QEIUpeZv4d+ivPN9eOwgAVH 15 | l4Ms9+u38VjuIE5LNZCiqOlIzaMBD+dPbOpx7rtEacMs8UgyMVIGPiJcsqzw++Ji 16 | pWHOKRAi82TLLcqt30wgIjCfu49hFPbnfIMCAwEAAaNQME4wHQYDVR0OBBYEFAyp 17 | 8Do7nsAhXWAPamH+Vn8ntZ3pMB8GA1UdIwQYMBaAFAyp8Do7nsAhXWAPamH+Vn8n 18 | tZ3pMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAKZcYn3AlK77Yg9K 19 | THZym7Cmr07HA9138PnlMLlVTqJnwAU9nGZ41h/Vth1nZLy9SppefuFwXrmpI5W4 20 | lGqY/t+saEgSzwI8cQ1F6AP0XyeQaGcqNpwHnBNF414Um42Emu+lbBJuYFV53Pgq 21 | nhxGUD7/PbUHSCkTj/LOuvVOeXKMl2muuMpk2lwJnQNAv4mB29F+6mwxntxuSk2O 22 | NFk13QXB5ii1NawT16yW/bYSMZsLQKvz/e/9B+tCjGdbQ8ga3OcEzjGgKRhh4Z/E 23 | gdgl7vtu+ZkE+LHzP9V2lUVlbSP4UhQiZJSEawItSwKlIq/hTjra1kby8UbGMtLp 24 | /vjAznZleF5nG4eQbbM3wDtnpWim0ODXH+p1JNPuK/6GzbWh4eykHQ6JElci26Hg 25 | ug+/7iGAxBcCiPPRX1vT92rnKAIicqzZ1UInlun2X01+pxFo+xGKCYDYAJwyS8Y/ 26 | Zy4eFbXJJMGznTef7fNhtBLk6jni90YWBbLgaY62T+y1gYsyfYzQE28Idc7LiKOu 27 | DNEfa4nXAB1zIg9JMKzqbDy4WN0B8tKbBBQIwtRn5DyW7YsmgsgZt6Gs+ochvZdh 28 | 5Zlr6T6nvFMunVGNUZP901Xa+HYqCTV7PcrvCDVMOfiNkmrlWf6amFTzL8kBELCC 29 | 0O6WGsL6rVCeSVy2BPSna0GzH36g 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /testclient.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKQIBAAKCAgEA1TE9F36zfsSmXbxkfNonYggVA7skrb+10iuLyey5snkOHLsz 3 | EjmL3Gtux02GaqZ8u3GdfeZ/Am2KqFnLq/YiZwYJpwpB54PAtKQqCiIcAPCENZdP 4 | zuya4bWTP+/bLiLdDY0kJhxMrReufBL+xBOFrRhLcsW+tECGsu+d39o7JY7oWnm7 5 | IQYX7cxK1JgaFL5kmUuaoJN0iJhB7V3JIQJMC68Yr5dzrYdzwzc1uxm43Y696HYA 6 | Pkygf41ZoHo5UKWCI9V9M0iz8oUoWrLdlXHOVaQKpPV9+aQYBiG1KNePvWZ4PCvu 7 | kXv+zgLP1SvvqNTQKi2HCV37RZQd0M0Do9aqrtlDrQLeKE39XZQMBKrype7Vr5Jc 8 | nXMlaC4A8WFF/+cl11V6m4eY8GLnoTv+l/G2Hbjwm6/oLPCvrErqd0M+6JK9jXnX 9 | HwNr05FpEVqwdYZ5K10bYBBUwY+oZM8sGrT0Hd0N0PHtcs2eZ6yYLNrAaTvZT3w/ 10 | sFgEqrDKn6c5WJdKO9PKSvbbE7whD+WkZPeN2ndh+lGYAEnVzyzVgKmNPOPGFa24 11 | 4QEIUpeZv4d+ivPN9eOwgAVHl4Ms9+u38VjuIE5LNZCiqOlIzaMBD+dPbOpx7rtE 12 | acMs8UgyMVIGPiJcsqzw++JipWHOKRAi82TLLcqt30wgIjCfu49hFPbnfIMCAwEA 13 | AQKCAgEAm+JU+Uj7lkXUH9YQ4/nfsh6WvxOnziPPns2YeR1O6uD5IKkAvuK1EYa8 14 | iZ52GqWBrs10iwpu9CeEq3R9KE/g99PCWxF0/wOndG5VDvPB5i33ffgVswfud/t8 15 | n9OSQDndyHrbY8JtjmMygiahgl2D8P1CrblJqCNGWrA6j+PSO7Qy0XURDySVeptW 16 | W/yblW9hv3U4qxEmtHogOp/I4Qn88M4nDr1/J/NTAfrsntJACkDFO6SMqQD+mkWQ 17 | s3arUfyzG+COm2EdsscKqsb+nreIV7aK0fNvGYqSxmj/Pc3gnGzAnb7Bwj8YISqN 18 | LSHjK1/wleaURpUhlc6nvnUppDLiuYC8yB3Xk3XqgEIw96Bn7DLWdXZdCHnm02C1 19 | WiV6SDI6TDY0oNw6qhUZ/Aq/hc4gN+MX/g1TJUXfk2R060Ul4ZY4Ywl89K45cUoo 20 | v7uZM0hkZEukTv1LjlbkMHHMyAoHIVBgjGeIUGUOc3/2zsvRHM5MBhuZQRuTxiGg 21 | vcR9/qdX6I0nZlUpjwLJduWn/rF8v2/lFABB36n9frK5wm6HeFp1hEitTljaeRMA 22 | s/RgM9XiHdZpzJT1YK6J6dXzJuUYCiSAeKps4n7xT4F66FTQuXMLqD2eLkiZIsEb 23 | nx6m0muEav+fUg8a/xi4WP/2eMNg7Ayq/PYtY76wIoxsowA7AQECggEBAPT09yFV 24 | wrxV2K3bFgC2+0sMcwoR984j8X82aipMHPKxXPiNE0budIP2OWhMOoIelrR3Pjdm 25 | xizU0bGI3lo5po9t22MhoQIrEREpv5rrUR+sg4DFUPUATRWlVGdr/EdsElQKTKKi 26 | ZpCqrBAMd8D6Vp3EH+LGpy1i37MoY8T+ljZ9wMStsKgN0/k6vHzwDVyUwadvrzNO 27 | v3HuEP+XRRxC/fhuxlti2/AS4Tm9IJIwUzNaVBpio8mLiDWdwHlGZXzxP6vlE3ef 28 | xzHJ71D89qFlxr4EDjiKzYxiiZbGqowv2NDS+Z87rYD5mFCaVUtzrj9PJHLNTh11 29 | P56QizutmAEvazkCggEBAN7NriGkK1+Tkf6hc5KPBgqZaUDSDAHr8DBpSMJy7sTq 30 | yOmd1L+09rNqAXy0wV6isU9v3wYMB38xIoxYMkkW1HQr6tWpcyzbC9HomBKMZGoX 31 | jNmj9MWKlaDN8cXfxx7l7CAf/1oJs7MAFMVu/oKnK35+uZoo9mvo0G6udc7vRHGa 32 | OLPoySq7RuAWT7z9ULd1+Ny+Y4kvWsO/hdjVU4a9r9KQf/eNXt5PWmMb69v7hNGD 33 | 2j2CqxdlnRYdUW6y3d8919lNneTXZBbzovw6aK58l124sYjuMyDIoof5ErouWDps 34 | SM1gE+xo48M1cSuZrv+RJu5G6mmtDe1/aXFzeEaBGZsCggEBAKB6R9kf7TcjapPj 35 | nxOSzSjKnCcxxE3ZgGIeDQlu2dwpVEZFbiafG9hEHDH3FrGeRo8uO6ViAFzohAQy 36 | LbGgaT039G2KX4gjHMhIuI1OstP0WiannjUUIGwY5yXmOd20sIE8Sh6WFGmcVqMg 37 | 9+eGWe57yYPxLx7t0q31vP8W5uQGGJ8BR2WhwYha8ZdMUQShNAl0gqwzX/rMw3ge 38 | 6xjrzqTONcczCfHK/KCuBcOgQzG2cLjkfHcSoYa2tZz+AIkNJ/B+X/WTyJUWvWEq 39 | iI0ON1jPIV3rmWPqPkd4Gc1Dn2CXhw/Jsg539lB/+3c17ybsu202kYF9CdPg0Eal 40 | oJrOLQkCggEADS1U8yBmgEyWAd1CnJRg4xeXpgHGPAbcOcDAUN/DR1orb8Wp43ys 41 | aogGdn2qQhKVMgGHyy/C8b7SMEK3FqOHBSfjx6cx7KE33b5H4DD1b2DdL7IGs/gy 42 | SURk3DMT77vhbzT1QTn5qsiCcfrSip+gbubHy1pI2LD4QtOGnCqCfcWFPP6zhxd0 43 | ZaRsKt1AfNk5UrTf5ikq0RDutZhITFvDnkx1hQqTZcqDqgDoviXuAQYvThwASm30 44 | EG7DdiyV+rIJpgx1Hieu/7yBEzHRJyCvQxe9SD/uPi4fjrMobGJ5TVtCIwNfqke5 45 | 0L3EZ7O7KdpH1yfSjVVy0W0Lq24M2v6fqQKCAQA25Xsu2HVCcE3KsEuywGJOs3hA 46 | kXPPJu7vyLRPDPlbDhVMtKSUOAGEHyh8h6o65VZF4322gZ++AoV+EFWlGfQYVYGu 47 | +/uBeTf6y4IuCPvCtsELiERtMAMbrhwSDB83/xIMcKkvs7X7DQ8GRe6n3mDZa6cg 48 | EjZhQRnxnKDR7AO8pM19GuMVPDeMVNdgfUJTSDO7nifuiPEO9rtAwuH/y+RNeLIJ 49 | 9a/1zcHXU1uCJPxITlN3ckhWIVEw7ycQ0xXULt4UfcfPHNtfMR0ccUYlP6zI1eBc 50 | CS5K58CWPWjBSiS+SFUIIx6qPtjBDuYBcqnrWqekd7m4yYKOJIaUbXhwm4IN 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /testserver.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFITCCAwmgAwIBAgIJALu5MYN2H+2PMA0GCSqGSIb3DQEBCwUAMCYxJDAiBgNV 3 | BAMMG0JvbWJhcmRpZXIgU2VydmVyIFRlc3QgQ2VydDAgFw0xNzAxMjYwNjM2NTRa 4 | GA8zMDE2MDUyOTA2MzY1NFowJjEkMCIGA1UEAwwbQm9tYmFyZGllciBTZXJ2ZXIg 5 | VGVzdCBDZXJ0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1laXP9o5 6 | eo9YFOdqK/Il1f4AePr7UZ/mq5nbBkA3Pt/5uW1LIq0ECJ3+JzGrzIkYjrj6an0c 7 | 3bV5GDW/XA6yH4iMV36ADc+D+SWixQQkkWNrIL3nTHnnxOFbml4uCCDEV5TGWv8Z 8 | GNraUPPSIZAM/OO4uKTMHKiacpnGZ8ZqUuJ0E0F3OHoLwxpJsAhiKv18iy7mHvva 9 | PBCQ/aq8LYUOLRwiL019EVx+LFuMj9Ugc9G5lYHnn0jI/AcMdWeXil01aFEFtGpG 10 | S7b4FV5d3x1C8l1PDDWQfuBzu4wrztnCUnvQyUTZAmBJFXt2V5MxO6p69qvGav// 11 | IdPTxToeaz0oil+O+7cJ4gqjUBsr8xPuGLYjytZ2KJEqTIJJogXFylpwDWIvbXBw 12 | pFhDsuE9J2oz9gPC8R54lb93cuNAeGrWZfoJ8qSzOsO4CKgasdTTrv3uFTgnovh4 13 | qJ4SaRjOtiSmWLZhy7fSG86kB+ZuGkEOUJiFok2aNJL1UI10QuSc095JLFnqZ3LP 14 | DG3gkdwqmYMYFnSKv43Z+azO1+t5BUSNyddSb2ZEF4d5J/UzN/D5XAxgJJoyfMK7 15 | vHfOlglnfBkSdyxw1/BbOPLBFVDWinV33sabtgEismgdDaYjActBuRdl1md4BPRJ 16 | Z714Noquv7qcOYXNuqdhLNWjWh4d0HwA0yECAwEAAaNQME4wHQYDVR0OBBYEFB0Y 17 | it+Qidk1AgL+N00nyYDWgeEFMB8GA1UdIwQYMBaAFB0Yit+Qidk1AgL+N00nyYDW 18 | geEFMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBABjxo7L4N+3lsHya 19 | 14dk8ycssuHkJvE2PhbandywV0E5bfqkoVGOmSVw50BIkpQ3WMTwACJZsjeTF1OJ 20 | CtDHSx3EJ3wPOMChAA06QdWSwG6Gxi6NYLrZ+60VjX3f64zwvmasD2xHT20icqAR 21 | H/405qzv5MyELvDzD9u+agdIvf2yXGywGiT8p1yPQZX5pn/1omWZEMSNICOzhFqv 22 | U6FfYOGu/g+FJYmV5JRitMBNcr1sTJI0eHEYWW/d7yxBCtmoN4UoH1TZepijb65n 23 | CT9xl9WX4CRGiG6/T4wcYmL2Q8OwjEn/JNUCSxU9tcyJr66JIIx4BUhUJ012BPXu 24 | BbohBaNA8ygMpt1sweBteVCC5O/O1J7YKYG1o0J2Fd4DLol+bWfM8IijoT23XYF3 25 | I5fcf/Y61iAxx9PqCe7BsRsPi7WXrPxZJlfocXIWbdwVwpQUngjQjiVgdcPH7LnB 26 | NI1E2PDcDVJDVsS7XB/zK2nyY31DlQ8QZYpOzIeHjy4UcepClzk8JqeIQEmh01S7 27 | p7fIIt51N2s2TpvZGC/wGL/0iFn/4mwyXvH9RZn8AUGBWrdB/kY/DD+Nmz8tHtlH 28 | PpM4uDdRwh+Ks62Rw1qde5xIZ82PjLxJ+P79rQ+wYejmfenLr1PPnl1q0W/MT8gT 29 | Puxw1k5iEsR8O9CjyEYP6bhQoZfb 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /testserver.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKgIBAAKCAgEA1laXP9o5eo9YFOdqK/Il1f4AePr7UZ/mq5nbBkA3Pt/5uW1L 3 | Iq0ECJ3+JzGrzIkYjrj6an0c3bV5GDW/XA6yH4iMV36ADc+D+SWixQQkkWNrIL3n 4 | THnnxOFbml4uCCDEV5TGWv8ZGNraUPPSIZAM/OO4uKTMHKiacpnGZ8ZqUuJ0E0F3 5 | OHoLwxpJsAhiKv18iy7mHvvaPBCQ/aq8LYUOLRwiL019EVx+LFuMj9Ugc9G5lYHn 6 | n0jI/AcMdWeXil01aFEFtGpGS7b4FV5d3x1C8l1PDDWQfuBzu4wrztnCUnvQyUTZ 7 | AmBJFXt2V5MxO6p69qvGav//IdPTxToeaz0oil+O+7cJ4gqjUBsr8xPuGLYjytZ2 8 | KJEqTIJJogXFylpwDWIvbXBwpFhDsuE9J2oz9gPC8R54lb93cuNAeGrWZfoJ8qSz 9 | OsO4CKgasdTTrv3uFTgnovh4qJ4SaRjOtiSmWLZhy7fSG86kB+ZuGkEOUJiFok2a 10 | NJL1UI10QuSc095JLFnqZ3LPDG3gkdwqmYMYFnSKv43Z+azO1+t5BUSNyddSb2ZE 11 | F4d5J/UzN/D5XAxgJJoyfMK7vHfOlglnfBkSdyxw1/BbOPLBFVDWinV33sabtgEi 12 | smgdDaYjActBuRdl1md4BPRJZ714Noquv7qcOYXNuqdhLNWjWh4d0HwA0yECAwEA 13 | AQKCAgEAmRXPgTODyh2Hc6a1Fh4lF+oKvF3GEk56miWRYa2Lx8SAwAdnmqSoNN9j 14 | HutDIRrqB0Xm1Rf2/gMXMktxGXcFkbAdTIB1RWfpgpF25/BFjfHMGd6IzP5koyGy 15 | I1cQ2Y1Nrp/77BI3AqGNPDRo6L/SBu0+ieJqRi3F4gQiyQvV9Mz4yqf/Vr8Ul4y3 16 | BJt4Qew6f85HXenTvQK4C/Vd4cUekul9IPvfT/8XvubERhaazx4Dxty5afK6WgdO 17 | xqvueEyKUK9Nu8YL3xgXqGt18F0d66zpQHchdP0qq9E5mMu/FtqIDLi3phLPICDG 18 | LVZb25mvqW6WkOW2e5qnrj4Ma9uKj7q8Pj3Xh69asZaHx71K2Stc7cgEFJxjm0jm 19 | mDZd1huCVwokpWr1hVj353kS/VK85qfN0YoBp1Lz+mi7vqQ+I+o6+IXQblwI0hAA 20 | zYJ91ixGM4Krgc0Jj0g+pIY9AYbESxyvT/wnc2Ub1Fi4e7qvmeoWCSa/gUo6fcro 21 | VlWIcVFj1itNVhvFm29v8aypbpWJepMHPPNAm97bhvjzIiXQMh8ET7cdmcLiKUK7 22 | n1GVEld8UlNThmQ54/d6vSK7+aRtqq/ImVyGZdl7SW8RvVnI/AUQYQXirNdft7Lb 23 | G6HGbLsfk4f/xznKNJt1vGgYpg/8YB1D2allhVClvtA+XbjBYWECggEBAPrOLcsE 24 | y7IbsppDdzNNXSMcgay+24ZD9BEEwd6wAKV4uAc5Hq3JUaene/K8g87g+Vrkx7SS 25 | mDfKUOvepYwflVBwM40Mo8V/zif0riqhYbrGPFgEw6cfiGvW34WUb9KOmrP96hEl 26 | pEQEDetkjjeaiRTWTeTU/Hy6CLXz+4aEfCXib+vKuat8c2E3AXokaTGHsl6BKuEM 27 | GlYod4tc62bcObId8BcwjqS+hsjAD6tWYkeeF6L4iq7rZ1XmHFUD281ZSyW2+BJL 28 | ieSM84ZSg3hXZyTEUPvHKn4QQmNzrkm06VBmvHWDU7avFqfrvOLOcDqPBzpnxAUA 29 | 15oKlujae4PVSh8CggEBANrHDkWB4oxSDmFPk27EkzMs83eWJ4sjpT+ZUC1wo4v3 30 | icp4Cc62oow4+tpoA8novx3tSwRfnfKig9w4svlVa2piHv6//NN6LM0jckx1dZdb 31 | TVeAFgGchpzTpCZ+F+vw4Qzgrw2r/Pa+ShyR0FyyRwu2wAQXNl74815b01iAWa3f 32 | UoIkvxlLu6Fy0pmeFQWyFI/WIIpXCyNrDxnrCWjcF28+et7+yngUvQ+4lRn9nF/T 33 | oSjl4uV6RNlI7Q1KI0pqHWIFYuFYFRavgAXIshaZrPpfskE8RrX1b1IofLHi1q6P 34 | A1O55Gxd/WWl5YTu+lpd4HeRPb+QbL0AgRujmZ98ur8CggEAe9Ts2z5k7G2sg2oo 35 | IpZiFAHxLL+XV/WZPgXhSvgPeaPfCQH02c16mZKiKjlVwwFlXLF0wP1YVsN3rN3j 36 | UwoNCQg9C7lf6xWtTiELFVVVEYjrJnJDv/JbwxL2jde6VnW+gHwv44N4VXTDAqRF 37 | a8LLSBR/pSpb96FKx7vNRp+HRJVGuV8AyWDK/wbPneT4Y1IiiXKxHyiAoGWekJqy 38 | R7kYa49IicqZw1Gm7tuVYP1nzQCLnxWkM7Va8hiJiJg9IGikJ9ztIutVDBlj68A1 39 | 1WciMA8WBRpTKqcQgFYPiajfQalYB5Vt8dcFEqfcPQe8dc1EvluZdvbxfMcZt6KY 40 | NYFL9QKCAQEAyl7C9sy0kPP+VUlUqWuwdfAorf/5SB2K6A+bOM0um3Q4w07SU6Jh 41 | LbAvawQ4LPbcgoRTlhIUerKVoonYFAdNuzRUU3WoGr6y3nbhbZRhV8ae/kd/E7KE 42 | WmDzQJ/25MsGgfD8PHtRHbTbvR2sTXKjgVRkvePy6VsDU89A6mafjdQ78CKpmm6R 43 | e0BJSswNyhz2JC8AHrdxmCuZ5nGhXJvqGX8EDW5GP1l/oSEu2sHbelC6jKhJf9fg 44 | A9YPYPGpP1Z1I4yz8JqXt0pT9AW3pmw0s8z9iJaHGh2UAb1tyuZ3izTC8RnND+jJ 45 | UtNoQdUFQ73+uttg8OhZjWMACl8E5aBs5QKCAQEAjBAMw41WxOPbF3S9zLOU2mqq 46 | T+sUKhhv36Ri9nKOtyfbOWuMgtYZHDijAkkP5yXDda31kgPkx3qJH4K6F6l+1PUF 47 | w+rbvUck15Vfed0BrzUxXL9m4JAq59TnAWajmoI/5eaeyY6M8Vfz4XQwYNNm353h 48 | zTBbxxO9AKz9I8/mRnctGo9UZNhhBwh30t8oQl2hWLqC1CuY/1R71tkxy6Qz1Tg+ 49 | wcnDU8IbyWtuLKBikBOLzTb+EvSMfEZyS9bLUhUZvWznvwJgaLCON+k5gQSjwE98 50 | nvR8VhuFDyq09ChAmFCAOA3plCOHCa/yYEX1BYx0tbP/yOYSePC6sKF1UzLBrw== 51 | -----END RSA PRIVATE KEY----- 52 | --------------------------------------------------------------------------------