mirror of
https://github.com/Fishwaldo/huma.git
synced 2025-03-15 19:31:27 +00:00
feat: add body read timeout option, adjust default timeouts
This commit is contained in:
parent
1c1f2c780d
commit
c3cbcb9802
5 changed files with 178 additions and 28 deletions
104
README.md
104
README.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
14
resource.go
14
resource.go
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
42
router.go
42
router.go
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue