diff --git a/README.md b/README.md index 44b0b9a..2cd8031 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,9 @@ # Timeout -> [!NOTE] -> This repository has been transferred from `github.com/tigerwill90/foxtimeout` to `github.com/fox-toolkit/timeout`. -> Existing users should update their imports and `go.mod` accordingly. - -Timeout is a middleware for [Fox](https://github.com/fox-toolkit/fox) which ensure that a handler do not exceed the +Timeout is a middleware for [Fox](https://github.com/fox-toolkit/fox) which ensures that a handler does not exceed the configured timeout limit. -## Disclaimer -Timeout's API is closely tied to the Fox router, and it will only reach v1 when the router is stabilized. -During the pre-v1 phase, breaking changes may occur and will be documented in the release notes. - ## Getting started ### Installation @@ -26,7 +18,7 @@ go get -u github.com/fox-toolkit/timeout ### Feature - Allows for custom timeout response to better suit specific use cases. - Tightly integrates with the Fox ecosystem for enhanced performance and scalability. -- Supports dynamic timeout configuration on a per-route & per-request basis using custom `Resolver`. +- Supports per-route overrides of the handler timeout and of the connection read/write deadlines. ### Usage ````go diff --git a/annotation.go b/annotation.go index 3c5057c..6c0d7b9 100644 --- a/annotation.go +++ b/annotation.go @@ -27,12 +27,14 @@ func OverrideHandler(dt time.Duration) fox.RouteOption { // OverrideRead returns a RouteOption that sets the read deadline for the underlying connection. // This controls how long the server will wait before timing out while reading the request body. +// Passing a value <= 0 (or None) clears the read deadline for this route. func OverrideRead(dt time.Duration) fox.RouteOption { return fox.WithAnnotation(rKey{}, dt) } // OverrideWrite returns a RouteOption that sets the write deadline for the underlying connection. // This controls how long the server will wait before timing out writes to the client. +// Passing a value <= 0 (or None) clears the write deadline for this route. func OverrideWrite(dt time.Duration) fox.RouteOption { return fox.WithAnnotation(wKey{}, dt) } diff --git a/go.mod b/go.mod index 53bc815..fd4e983 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,13 @@ module github.com/fox-toolkit/timeout go 1.26.0 require ( - github.com/fox-toolkit/fox v0.33.0 + github.com/fox-toolkit/fox v1.0.0 github.com/stretchr/testify v1.12.1 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 44e895c..d99b0b6 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,12 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fox-toolkit/fox v0.32.0 h1:Idx/fSK9c8wjhJMz5Vr7H4VRS6t5YbKldAJIxRnQWrk= -github.com/fox-toolkit/fox v0.32.0/go.mod h1:e5eQE028uqYvvoBAmfIvkH4qN+OZj98h7pwHti99c3w= -github.com/fox-toolkit/fox v0.33.0 h1:1GTggw1DzrHhdnjiQF0nYrQVPtDtmWorU4/ucRv3FIo= -github.com/fox-toolkit/fox v0.33.0/go.mod h1:IWpK0++VvaNyc8Gts1Zjefow7WymGiY258wC+yY7Xg4= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/fox-toolkit/fox v1.0.0 h1:Z8Yus2t3xRWk7rT4aSuYkOeG72k53KgwdFb1RcuWrLc= +github.com/fox-toolkit/fox v1.0.0/go.mod h1:IWpK0++VvaNyc8Gts1Zjefow7WymGiY258wC+yY7Xg4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/timeout.go b/timeout.go index 4d40a3f..f7d87e0 100644 --- a/timeout.go +++ b/timeout.go @@ -30,8 +30,8 @@ var ( } ) -// Timeout is a middleware that ensure HTTP handlers don't exceed the configured timeout duration. -type Timeout struct { +// middleware ensures HTTP handlers don't exceed the configured timeout duration. +type middleware struct { cfg *config dt time.Duration } @@ -39,8 +39,8 @@ type Timeout struct { // Middleware returns a [fox.MiddlewareFunc] that runs handlers with the given time limit. // // The middleware calls the next handler to handle each request, but if a call runs for longer than its time limit, -// the handler responds with a 503 Service Unavailable error and the given message in its body (if a custom response -// handler is not configured). After such a timeout, writes by the handler to its ResponseWriter will return [http.ErrHandlerTimeout]. +// the handler responds with a 503 Service Unavailable error (see [DefaultResponse]). After such a timeout, writes by the +// handler to its ResponseWriter will return [http.ErrHandlerTimeout]. // // The timeout middleware supports the [http.Pusher] interface but does not support the [http.Hijacker] or [http.Flusher] interfaces. // @@ -51,20 +51,20 @@ func Middleware(dt time.Duration, opts ...Option) fox.MiddlewareFunc { return create(dt, opts...).run } -func create(dt time.Duration, opts ...Option) *Timeout { +func create(dt time.Duration, opts ...Option) *middleware { cfg := defaultConfig() for _, opt := range opts { opt.apply(cfg) } - return &Timeout{ + return &middleware{ dt: dt, cfg: cfg, } } // run is the internal handler that applies the timeout logic. -func (t *Timeout) run(next fox.HandlerFunc) fox.HandlerFunc { +func (t *middleware) run(next fox.HandlerFunc) fox.HandlerFunc { return func(c *fox.Context) { t.setDeadline(c) dt := t.resolveTimeout(c) @@ -130,24 +130,32 @@ func (t *Timeout) run(next fox.HandlerFunc) fox.HandlerFunc { } } -func (t *Timeout) resolveTimeout(c *fox.Context) time.Duration { +func (t *middleware) resolveTimeout(c *fox.Context) time.Duration { if dt, ok := unwrapRouteTimeout(c.Route(), hKey{}); ok { return dt } return t.dt } -func (t *Timeout) setDeadline(c *fox.Context) { +func (t *middleware) setDeadline(c *fox.Context) { // Errors are intentionally ignored: the underlying connection may not support deadlines // (e.g., http.ErrNotSupported), and there's no actionable recovery in this context. if dt, ok := unwrapRouteTimeout(c.Route(), rKey{}); ok { - _ = c.Writer().SetReadDeadline(time.Now().Add(dt)) + _ = c.Writer().SetReadDeadline(deadline(dt)) } if dt, ok := unwrapRouteTimeout(c.Route(), wKey{}); ok { - _ = c.Writer().SetWriteDeadline(time.Now().Add(dt)) + _ = c.Writer().SetWriteDeadline(deadline(dt)) } } +// deadline returns the absolute deadline for dt, or the zero time (no deadline) if dt <= 0. +func deadline(dt time.Duration) time.Time { + if dt <= 0 { + return time.Time{} + } + return time.Now().Add(dt) +} + func checkWriteHeaderCode(code int) { if code < 100 || code > 999 { panic(fmt.Sprintf("invalid status code %d", code)) diff --git a/timeout_test.go b/timeout_test.go index 721864d..3026887 100644 --- a/timeout_test.go +++ b/timeout_test.go @@ -269,6 +269,68 @@ func TestMiddleware_WithWriteTimeout(t *testing.T) { assert.Less(t, n, int64(10*1024*1024)) } +func TestMiddleware_WithReadTimeoutNone(t *testing.T) { + f, err := fox.NewRouter(fox.WithMiddleware(Middleware(None))) + require.NoError(t, err) + + f.MustAdd(fox.MethodPost, "/foo", func(c *fox.Context) { + body, err := io.ReadAll(c.Request().Body) + if err != nil { + http.Error(c.Writer(), err.Error(), http.StatusRequestTimeout) + return + } + _, _ = c.Writer().Write(body) + }, OverrideRead(None)) + + srv := httptest.NewUnstartedServer(f) + srv.Config.ReadTimeout = 50 * time.Millisecond + srv.Start() + defer srv.Close() + + pr, pw := io.Pipe() + go func() { + // Slow writer: sends data after the server read timeout, which the route clears + time.Sleep(200 * time.Millisecond) + _, _ = pw.Write([]byte("hello")) + pw.Close() + }() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/foo", pr) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "hello", string(body)) +} + +func TestMiddleware_WithWriteTimeoutNone(t *testing.T) { + f, err := fox.NewRouter(fox.WithMiddleware(Middleware(None))) + require.NoError(t, err) + + f.MustAdd(fox.MethodGet, "/foo", func(c *fox.Context) { + // Exceed the server write timeout, which the route clears + time.Sleep(200 * time.Millisecond) + _, _ = c.Writer().Write([]byte("hello")) + }, OverrideWrite(None)) + + srv := httptest.NewUnstartedServer(f) + srv.Config.WriteTimeout = 50 * time.Millisecond + srv.Start() + defer srv.Close() + + resp, err := http.Get(srv.URL + "/foo") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "hello", string(body)) +} + func ExampleOverrideHandler() { f, err := fox.NewRouter( fox.WithMiddleware(Middleware(2 * time.Second)),