feat: add body read timeout option, adjust default timeouts

This commit is contained in:
Daniel G. Taylor 2020-04-08 23:29:48 -07:00
parent 1c1f2c780d
commit c3cbcb9802
No known key found for this signature in database
GPG key ID: 7BD6DC99C9A87E22
5 changed files with 178 additions and 28 deletions

104
README.md
View file

@ -605,7 +605,7 @@ r.Run()
### Timeouts, Deadlines, & Cancellation
By default, only a `ReadHeaderTimeout` of _30 seconds_ and an `IdleTimeout` of _60 seconds_ are set. This allows large request and response bodies to be sent without fear of timing out in the default config, as well as the use of WebSockets.
By default, only a `ReadHeaderTimeout` of _10 seconds_ and an `IdleTimeout` of _15 seconds_ are set at the server level. This allows large request and response bodies to be sent without fear of timing out in the default config, as well as the use of WebSockets.
Set timeouts and deadlines on the request context and pass that along to libraries to prevent long-running handlers. For example:
@ -632,6 +632,85 @@ r.Resource("/timeout",
})
```
### Request Body Timeouts
By default any handler which takes in a request body parameter will have a read timeout of 15 seconds set on it. If set to nonzero for a handler which does **not** take a body, then the timeout will be set on the underlying connection before calling your handler. The timeout value is configurable at the resource and operation level.
When triggered, the server sends a 408 Request Timeout as JSON with a message containing the time waited.
```go
type Input struct {
ID string
}
r := huma.NewRouter(&huma.OpenAPI{
Title: "Example API",
Version: "1.0.0",
})
// Resource-level limit to 5 seconds
r.Resource("/foo").BodyReadTimeout(5 * time.Second).Post(
"Create item", func(input *Input) string {
return "Hello, " + input.ID
})
// Operation-level limit
r.Resource("/foo").Operation(http.MethodPost, &huma.Operation{
// ...
BodyReadTimeout: 5 * time.Second,
Handler: func(input *Input) string {
return "Hello, " + input.ID
},
// ...
})
```
You can also access the underlying TCP connection and set deadlines manually:
```go
r.Resource("/foo", huma.GinContextDependency()).Get(func (c *gin.Context) string {
// Get the underlying `net.Conn` and set a new deadline.
conn := huma.GetConn(c.Request)
conn.SetReadDeadline(time.Now().Add(600 * time.Second))
// Read all the data from the request.
data, err := ioutil.ReadAll(c.Request.Body)
if err != nil {
panic(err)
}
// Do something with the data...
return fmt.Sprintf("Read %d bytes", len(data))
})
```
> :whale: Set to `-1` in order to disable the timeout.
### Request Body Size Limits
By default each operation has a 1 MiB reqeuest body size limit. This value is configurable at the resource and operation level.
When triggered, the server sends a 413 Request Entity Too Large as JSON with a message containing the maximum body size for this operation.
```go
r := huma.NewRouter(&huma.OpenAPI{
Title: "Example API",
Version: "1.0.0",
})
// Resource-level limit set to 10 MiB
r.Resource("/foo").MaxBodyBytes(10 * 1024 * 1024).Get(...)
// Operation-level limit
r.Resource("/foo").Operation(http.MethodGet, &huma.Operation{
// ...
MaxBodyBytes: 10 * 1024 * 1024,
// ...
})
```
> :whale: Set to `-1` in order to disable the check, allowing for unlimited request body size for e.g. large streaming file uploads.
## Logging
Huma provides a Zap-based contextual structured logger built-in. You can access it via the `huma.LogDependency()` which returns a `*zap.SugaredLogger`. It requires the use of the `huma.LogMiddleware(...)`, which is included by default.
@ -786,29 +865,6 @@ r := huma.NewRouter(&huma.OpenAPI{
r.Use(gin.Logger())
```
## Request Body Size Limits
By default each operation has a 1 MiB reqeuest body size limit. You can modify this via the resource or operation:
```go
r := huma.NewRouter(&huma.OpenAPI{
Title: "Example API",
Version: "1.0.0",
})
// Resource-level limit set to 10 MiB
r.Resource("/foo").MaxBodyBytes(10 * 1024 * 1024)
// Operation-level limit
r.Operation(http.MethodGet, &huma.Operation{
// ...
MaxBodyBytes: 10 * 1024 * 1024,
// ...
})
```
> :whale: Set to `-1` in order to disable the check, allowing for unlimited request body size for e.g. large streaming file uploads.
## HTTP/2 Setup
TODO

View file

@ -5,6 +5,7 @@ import (
"net/http"
"reflect"
"strings"
"time"
"github.com/Jeffail/gabs"
"github.com/gin-gonic/gin"
@ -210,6 +211,11 @@ type Operation struct {
// an error is returned. Defaults to 1MiB if set to zero. Set to -1 for
// unlimited.
MaxBodyBytes int64
// BodyReadTimeout sets the duration until reading the body is given up and
// aborted with an error. Defaults to 15 seconds if the body is automatically
// read and parsed into a struct, otherwise unset. Set to -1 for unlimited.
BodyReadTimeout time.Duration
}
// AllParams returns a list of all the parameters for this operation, including

View file

@ -5,6 +5,7 @@ import (
"net/http"
"reflect"
"strings"
"time"
)
// Resource describes a REST resource at a given URI path. Resources are
@ -18,6 +19,7 @@ type Resource struct {
responseHeaders []*ResponseHeader
responses []*Response
maxBodyBytes int64
bodyReadTimeout time.Duration
}
// NewResource creates a new resource with the given router and path. All
@ -48,6 +50,7 @@ func (r *Resource) Copy() *Resource {
responseHeaders: append([]*ResponseHeader{}, r.responseHeaders...),
responses: append([]*Response{}, r.responses...),
maxBodyBytes: r.maxBodyBytes,
bodyReadTimeout: r.bodyReadTimeout,
}
}
@ -86,6 +89,13 @@ func (r *Resource) MaxBodyBytes(value int64) *Resource {
return r
}
// BodyReadTimeout sets the duration after which the read is aborted and an
// error is returned.
func (r *Resource) BodyReadTimeout(value time.Duration) *Resource {
r.bodyReadTimeout = value
return r
}
// Path returns the generated path including any path parameters.
func (r *Resource) Path() string {
generated := r.path
@ -180,6 +190,10 @@ func (r *Resource) Operation(method string, op *Operation) {
op.MaxBodyBytes = r.maxBodyBytes
}
if op.BodyReadTimeout == 0 {
op.BodyReadTimeout = r.bodyReadTimeout
}
r.router.Register(method, path, op)
}

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"os"
"reflect"
@ -25,6 +26,19 @@ import (
// is not a valid value.
var ErrInvalidParamLocation = errors.New("invalid parameter location")
// ConnContextKey is used to get/set the underlying `net.Conn` from a request
// context value.
var ConnContextKey = struct{}{}
// GetConn gets the underlying `net.Conn` from a request.
func GetConn(r *http.Request) net.Conn {
conn := r.Context().Value(ConnContextKey)
if conn != nil {
return conn.(net.Conn)
}
return nil
}
// Checks if data validates against the given schema. Returns false on failure.
func validAgainstSchema(c *gin.Context, label string, schema *Schema, data []byte) bool {
defer func() {
@ -190,9 +204,13 @@ func getRequestBody(c *gin.Context, t reflect.Type, op *Operation) (interface{},
body, err := ioutil.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorModel{
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, ErrorModel{
Message: fmt.Sprintf("Request body too large, limit = %d bytes", op.MaxBodyBytes),
})
} else if e, ok := err.(net.Error); ok && e.Timeout() {
c.AbortWithStatusJSON(http.StatusRequestTimeout, ErrorModel{
Message: fmt.Sprintf("Request body took too long to read: timed out after %v", op.BodyReadTimeout),
})
} else {
panic(err)
}
@ -405,7 +423,17 @@ func (r *Router) Register(method, path string, op *Operation) {
in = append(in, reflect.ValueOf(pv))
}
readTimeout := op.BodyReadTimeout
if len(in) != method.Type().NumIn() {
if readTimeout == 0 {
// Default to 15s when reading/parsing/validating automatically.
readTimeout = 15 * time.Second
}
if conn := GetConn(c.Request); readTimeout > 0 && conn != nil {
conn.SetReadDeadline(time.Now().Add(readTimeout))
}
// Parse body
i := len(in)
val, success := getRequestBody(c, method.Type().In(i), op)
@ -417,6 +445,11 @@ func (r *Router) Register(method, path string, op *Operation) {
if in[i].Kind() == reflect.Ptr {
in[i] = in[i].Elem()
}
} else if readTimeout > 0 {
// We aren't processing the input, but still set the timeout.
if conn := GetConn(c.Request); conn != nil {
conn.SetReadDeadline(time.Now().Add(readTimeout))
}
}
out := method.Call(in)
@ -495,9 +528,12 @@ func (r *Router) listen(addr, certFile, keyFile string) error {
if r.server == nil {
r.server = &http.Server{
Addr: addr,
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
Handler: r,
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
return context.WithValue(ctx, ConnContextKey, c)
},
}
} else {
r.server.Addr = addr

View file

@ -535,6 +535,44 @@ func TestTooBigBody(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPut, "/test", strings.NewReader(`{"id": "foo"}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
assert.Contains(t, w.Body.String(), "Request body too large")
}
type timeoutError struct{}
func (e *timeoutError) Error() string {
return "timed out"
}
func (e *timeoutError) Timeout() bool {
return true
}
func (e *timeoutError) Temporary() bool {
return false
}
type slowReader struct{}
func (r *slowReader) Read(p []byte) (int, error) {
return 0, &timeoutError{}
}
func TestBodySlow(t *testing.T) {
r := NewTestRouter(t)
type Input struct {
ID string
}
r.Resource("/test").BodyReadTimeout(1).Put("desc", func(input *Input) string {
return "hello, " + input.ID
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPut, "/test", &slowReader{})
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusRequestTimeout, w.Code)
assert.Contains(t, w.Body.String(), "timed out")
}