diff --git a/docs/configuration.md b/docs/configuration.md index 934e2e4..53cc207 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,13 +173,13 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit. -The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1TiB`, or `2147483647` on a 32-bit platform. +The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because the limit is also the most memory one response may take. **Examples:** `1048576`, `10MiB`, `2MB`, `512KiB` A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `body size exceeds the given limit` and is counted like any other error. -The limit applies per response in flight, so the worst case is roughly the limit times the concurrency. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts). +The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. Requests made by scripts have their own limit, see [HTTP Requests in Scripts](#http-requests-in-scripts). ## Concurrency @@ -596,7 +596,7 @@ http(url, opts) | `timeout` | duration string (`"500ms"`) | `30s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed | | `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) | | `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 | -| `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept (the max response body config does not apply), maximum `1TiB` | +| `maxBodySize` | number or size (`"10MiB"`) | `10MiB` | Largest response body to accept, `0` accepts any size, maximum `1GiB` | Unknown options and values of the wrong type raise an error. @@ -619,7 +619,7 @@ In Lua, call the helpers as `res:header("name")` or `res.header("name")`. - **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to. - **Status codes:** non-2xx responses are returned normally; check `status` in the script. - **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript). -- **Response bodies:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. +- **Response bodies:** script requests carry their own limit, like their own timeout, so the max response body config does not apply to them. A body above `maxBodySize` raises an error instead of being truncated, and a body whose declared `Content-Length` is already above it is refused before it is read. The limit is also the most memory one response may take, so `0` gives a server as much memory as it asks for. - **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts. - **Dry run:** script requests are still sent in dry-run mode. - **Not measured:** script requests are not included in the results. diff --git a/docs/templating.md b/docs/templating.md index c3ddc5a..ec98792 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -521,7 +521,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) | `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` | | `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` | | `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` | -| `fakeit_Quote` | Random quote | `"Energy is contagious—so is fear"` | +| `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` | | `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` | | `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` | | `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` | diff --git a/internal/sarin/scripthttp.go b/internal/sarin/scripthttp.go index 2652417..6cca8c7 100644 --- a/internal/sarin/scripthttp.go +++ b/internal/sarin/scripthttp.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "sync" + "sync/atomic" "time" "github.com/valyala/fasthttp" @@ -21,6 +22,9 @@ const scriptHTTPDefaultTimeout = 30 * time.Second // independent of the max response body config. const scriptHTTPDefaultMaxBodySize = 10 << 20 // 10 MiB +// scriptHTTPMaxClients bounds the client cache, since each one holds a connection pool. +const scriptHTTPMaxClients = 16 + // scriptHTTPClientKey picks the client a request needs, since fasthttp takes the body // limit per client rather than per request. type scriptHTTPClientKey struct { @@ -33,6 +37,7 @@ type scriptHTTPClient struct { ctx context.Context //nolint:containedctx newClient func(key scriptHTTPClientKey) *fasthttp.Client clients sync.Map + clientCount atomic.Int64 defaultTimeout time.Duration } @@ -79,18 +84,13 @@ func newScriptHTTPClient(ctx context.Context, dial fasthttp.DialFuncWithTimeout, } } -// scriptHTTPBodyCap rounds a limit up to a power of two, so a script that varies its -// limit cannot mint a client, and a connection pool with it, per request. -func scriptHTTPBodyCap(maxBodySize int64) int { - if maxBodySize <= 0 { +// scriptHTTPBodyLimit resolves the limit a request is read with, where a request +// without one takes the default and zero means no limit. +func scriptHTTPBodyLimit(maxBodySize *int64) int { + if maxBodySize == nil { return scriptHTTPDefaultMaxBodySize } - - size := int64(1) - for size < maxBodySize { - size <<= 1 - } - return safeInt64ToInt(size) + return safeInt64ToInt(*maxBodySize) } // dialWithDeadline dials within the request timeout and keeps it as the connection deadline, @@ -163,7 +163,8 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro timeout = c.defaultTimeout } - client := c.clientFor(r.Insecure, r.MaxBodySize) + limit := scriptHTTPBodyLimit(r.MaxBodySize) + client := c.clientFor(r.Insecure, limit) req.SetTimeout(timeout) if r.MaxRedirects > 0 { @@ -178,11 +179,7 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err) } - // The client caps the body at the rounded up limit, so the exact one is checked here. body := resp.Body() - if r.MaxBodySize > 0 && int64(len(body)) > r.MaxBodySize { - return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPBodyTooLarge) - } return &script.HTTPResponse{ Status: resp.StatusCode(), @@ -191,13 +188,22 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro }, nil } -// clientFor returns the client whose body limit covers maxBodySize, building it once. -func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int64) *fasthttp.Client { - key := scriptHTTPClientKey{insecure: insecure, maxBodySize: scriptHTTPBodyCap(maxBodySize)} +// clientFor returns the client enforcing maxBodySize, building it once. +func (c *scriptHTTPClient) clientFor(insecure bool, maxBodySize int) *fasthttp.Client { + key := scriptHTTPClientKey{insecure: insecure, maxBodySize: maxBodySize} if client, ok := c.clients.Load(key); ok { return client.(*fasthttp.Client) //nolint:forcetypeassert } - client, _ := c.clients.LoadOrStore(key, c.newClient(key)) - return client.(*fasthttp.Client) //nolint:forcetypeassert + client := c.newClient(key) + // A script that varies its limit would otherwise grow the cache without end. + if c.clientCount.Load() >= scriptHTTPMaxClients { + return client + } + + stored, loaded := c.clients.LoadOrStore(key, client) + if !loaded { + c.clientCount.Add(1) + } + return stored.(*fasthttp.Client) //nolint:forcetypeassert } diff --git a/internal/script/http.go b/internal/script/http.go index bc48bdb..aabf3f2 100644 --- a/internal/script/http.go +++ b/internal/script/http.go @@ -21,7 +21,7 @@ type HTTPRequest struct { Timeout time.Duration // zero means the doer's default timeout Insecure bool MaxRedirects int - MaxBodySize int64 // zero means no limit + MaxBodySize *int64 // nil takes the doer default, zero means no limit } // HTTPResponse is the response returned to a script by the http.* functions. @@ -126,9 +126,9 @@ func parseHTTPMaxRedirects(count float64) (int, error) { // parseHTTPMaxBodySize checks that the body limit is a non-negative whole number. // It can return the following errors: // - types.ScriptHTTPOptionError -func parseHTTPMaxBodySize(size float64) (int64, error) { +func parseHTTPMaxBodySize(size float64) (*int64, error) { if math.IsNaN(size) || size < 0 || size > float64(httpMaxBodySizeLimit) || size != math.Trunc(size) { - return 0, types.NewScriptHTTPOptionError( + return nil, types.NewScriptHTTPOptionError( httpOptionMaxBodySize, types.NewScriptTypeError( "a whole number of bytes between 0 and "+strconv.FormatInt(httpMaxBodySizeLimit, 10), @@ -136,24 +136,26 @@ func parseHTTPMaxBodySize(size float64) (int64, error) { ), ) } - return int64(size), nil + limit := int64(size) + return &limit, nil } // parseHTTPMaxBodySizeString reads a body limit written with a unit, such as "10MiB". // It can return the following errors: // - types.ScriptHTTPOptionError -func parseHTTPMaxBodySizeString(value string) (int64, error) { +func parseHTTPMaxBodySizeString(value string) (*int64, error) { size, err := types.ParseByteSize(value) if err != nil { - return 0, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err) + return nil, types.NewScriptHTTPOptionError(httpOptionMaxBodySize, err) } if size > uint64(httpMaxBodySizeLimit) { - return 0, types.NewScriptHTTPOptionError( + return nil, types.NewScriptHTTPOptionError( httpOptionMaxBodySize, types.NewScriptTypeError("a size up to "+types.FormatByteSize(uint64(httpMaxBodySizeLimit)), value), ) } - return int64(size), nil + limit := int64(size) + return &limit, nil } // parseHTTPTimeout parses a Go duration string such as "500ms" or "2s". diff --git a/internal/types/byte_size.go b/internal/types/byte_size.go index 78ebc6a..9dec020 100644 --- a/internal/types/byte_size.go +++ b/internal/types/byte_size.go @@ -6,8 +6,9 @@ import ( "strings" ) -// ByteSizeLimit is the largest size any option may ask for, 1 TiB, less where int is 32 bit. -const ByteSizeLimit = min(1<<40, math.MaxInt) +// ByteSizeLimit is the largest size any option may ask for. A limit is also the most +// memory one response may take, so it stays well under what a machine can hold. +const ByteSizeLimit = 1 << 30 // 1 GiB // RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script. const RemoteFetchLimit = 64 << 20 // 64 MiB