Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

try to fix #1905 and add some notes #1947

Merged
merged 4 commits into from Aug 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
60 changes: 57 additions & 3 deletions middleware/timeout.go
Expand Up @@ -8,6 +8,53 @@ import (
"github.com/labstack/echo/v4"
)

// ---------------------------------------------------------------------------------------------------------------
// WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING
// WARNING: Timeout middleware causes more problems than it solves.
// WARNING: This middleware should be first middleware as it messes with request Writer and could cause data race if
// it is in other position
//
// Depending on out requirements you could be better of setting timeout to context and
// check its deadline from handler.
//
// For example: create middleware to set timeout to context
// func RequestTimeout(timeout time.Duration) echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error {
// timeoutCtx, cancel := context.WithTimeout(c.Request().Context(), timeout)
// c.SetRequest(c.Request().WithContext(timeoutCtx))
// defer cancel()
// return next(c)
// }
// }
//}
//
// Create handler that checks for context deadline and runs actual task in separate coroutine
// Note: separate coroutine may not be even if you do not want to process continue executing and
// just want to stop long-running handler to stop and you are using "context aware" methods (ala db queries with ctx)
// e.GET("/", func(c echo.Context) error {
//
// doneCh := make(chan error)
// go func(ctx context.Context) {
// doneCh <- myPossiblyLongRunningBackgroundTaskWithCtx(ctx)
// }(c.Request().Context())
//
// select { // wait for task to finish or context to timeout/cancelled
// case err := <-doneCh:
// if err != nil {
// return err
// }
// return c.String(http.StatusOK, "OK")
// case <-c.Request().Context().Done():
// if c.Request().Context().Err() == context.DeadlineExceeded {
// return c.String(http.StatusServiceUnavailable, "timeout")
// }
// return c.Request().Context().Err()
// }
//
// })
//

type (
// TimeoutConfig defines the config for Timeout middleware.
TimeoutConfig struct {
Expand Down Expand Up @@ -116,13 +163,20 @@ func (t echoHandlerFuncWrapper) ServeHTTP(rw http.ResponseWriter, r *http.Reques
// we restore original writer only for cases we did not timeout. On timeout we have already sent response to client
// and should not anymore send additional headers/data
// so on timeout writer stays what http.TimeoutHandler uses and prevents writing headers/body
t.ctx.Response().Writer = originalWriter
if err != nil {
// call global error handler to write error to the client. This is needed or `http.TimeoutHandler` will send status code by itself
// and after that our tries to write status code will not work anymore
// Error must be written into Writer created in `http.TimeoutHandler` so to get Response into `commited` state.
// So call global error handler to write error to the client. This is needed or `http.TimeoutHandler` will send
// status code by itself and after that our tries to write status code will not work anymore and/or create errors in
// log about `superfluous response.WriteHeader call from`
t.ctx.Error(err)
// we pass error from handler to middlewares up in handler chain to act on it if needed. But this means that
// global error handler is probably be called twice as `t.ctx.Error` already does that.

// NB: later call of the global error handler or middlewares will not take any effect, as echo.Response will be
// already marked as `committed` because we called global error handler above.
t.ctx.Response().Writer = originalWriter // make sure we restore before we signal original coroutine about the error
t.errChan <- err
return
}
t.ctx.Response().Writer = originalWriter
}
128 changes: 128 additions & 0 deletions middleware/timeout_test.go
@@ -1,7 +1,12 @@
package middleware

import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -313,3 +318,126 @@ func TestTimeoutCanHandleContextDeadlineOnNextHandler(t *testing.T) {
assert.Equal(t, "Timeout! change me", rec.Body.String())
assert.False(t, <-handlerFinishedExecution)
}

func TestTimeoutWithFullEchoStack(t *testing.T) {
// test timeout with full http server stack running, do see what http.Server.ErrorLog contains
var testCases = []struct {
name string
whenPath string
expectStatusCode int
expectResponse string
expectLogContains []string
expectLogNotContains []string
}{
{
name: "404 - write response in global error handler",
whenPath: "/404",
expectResponse: "{\"message\":\"Not Found\"}\n",
expectStatusCode: http.StatusNotFound,
expectLogNotContains: []string{"echo:http: superfluous response.WriteHeader call from"},
expectLogContains: []string{`"status":404,"error":"code=404, message=Not Found"`},
},
{
name: "418 - write response in handler",
whenPath: "/",
expectResponse: "{\"message\":\"OK\"}\n",
expectStatusCode: http.StatusTeapot,
expectLogNotContains: []string{"echo:http: superfluous response.WriteHeader call from"},
expectLogContains: []string{`"status":418,"error":"",`},
},
{
name: "503 - handler timeouts, write response in timeout middleware",
whenPath: "/?delay=50ms",
expectResponse: "<html><head><title>Timeout</title></head><body><h1>Timeout</h1></body></html>",
expectStatusCode: http.StatusServiceUnavailable,
expectLogNotContains: []string{
"echo:http: superfluous response.WriteHeader call from",
"{", // means that logger was not called.
},
},
}

e := echo.New()

buf := new(bytes.Buffer)
e.Logger.SetOutput(buf)

// NOTE: timeout middleware is first as it changes Response.Writer and causes data race for logger middleware if it is not first
// FIXME: I have no idea how to fix this without adding mutexes.
e.Use(TimeoutWithConfig(TimeoutConfig{
Timeout: 15 * time.Millisecond,
}))
e.Use(Logger())
e.Use(Recover())

e.GET("/", func(c echo.Context) error {
var delay time.Duration
if err := echo.QueryParamsBinder(c).Duration("delay", &delay).BindError(); err != nil {
return err
}
if delay > 0 {
time.Sleep(delay)
}
return c.JSON(http.StatusTeapot, map[string]string{"message": "OK"})
})

server, addr, err := startServer(e)
if err != nil {
assert.NoError(t, err)
return
}
defer server.Close()

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
buf.Reset() // this is design this can not be run in parallel

res, err := http.Get(fmt.Sprintf("http://%v%v", addr, tc.whenPath))
if err != nil {
assert.NoError(t, err)
return
}

assert.Equal(t, tc.expectStatusCode, res.StatusCode)
if body, err := ioutil.ReadAll(res.Body); err == nil {
assert.Equal(t, tc.expectResponse, string(body))
} else {
assert.Fail(t, err.Error())
}

logged := buf.String()
for _, subStr := range tc.expectLogContains {
assert.True(t, strings.Contains(logged, subStr))
}
for _, subStr := range tc.expectLogNotContains {
assert.False(t, strings.Contains(logged, subStr))
}
})
}
}

func startServer(e *echo.Echo) (*http.Server, string, error) {
l, err := net.Listen("tcp", ":0")
if err != nil {
return nil, "", err
}

s := http.Server{
Handler: e,
ErrorLog: log.New(e.Logger.Output(), "echo:", 0),
}

errCh := make(chan error)
go func() {
if err := s.Serve(l); err != http.ErrServerClosed {
errCh <- err
}
}()

select {
case <-time.After(10 * time.Millisecond):
return &s, l.Addr().String(), nil
case err := <-errCh:
return nil, "", err
}
}