Skip to content

Commit 7f502b1

Browse files
pwli0755lipengweialdas
authored
try to fix #1905 and add some notes (#1947)
* fix 1905 and add some notes (cherry picked from commit 9d96199) * fix typo (cherry picked from commit e8ea1bc) * Add tests for timeout middleware with full http.Server stack running. Add warning about middleware * Fix example Co-authored-by: lipengwei <[email protected]> Co-authored-by: toimtoimtoim <[email protected]>
1 parent d793521 commit 7f502b1

File tree

2 files changed

+185
-3
lines changed

2 files changed

+185
-3
lines changed

middleware/timeout.go

+57-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,53 @@ import (
88
"github.com/labstack/echo/v4"
99
)
1010

11+
// ---------------------------------------------------------------------------------------------------------------
12+
// WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING
13+
// WARNING: Timeout middleware causes more problems than it solves.
14+
// WARNING: This middleware should be first middleware as it messes with request Writer and could cause data race if
15+
// it is in other position
16+
//
17+
// Depending on out requirements you could be better of setting timeout to context and
18+
// check its deadline from handler.
19+
//
20+
// For example: create middleware to set timeout to context
21+
// func RequestTimeout(timeout time.Duration) echo.MiddlewareFunc {
22+
// return func(next echo.HandlerFunc) echo.HandlerFunc {
23+
// return func(c echo.Context) error {
24+
// timeoutCtx, cancel := context.WithTimeout(c.Request().Context(), timeout)
25+
// c.SetRequest(c.Request().WithContext(timeoutCtx))
26+
// defer cancel()
27+
// return next(c)
28+
// }
29+
// }
30+
//}
31+
//
32+
// Create handler that checks for context deadline and runs actual task in separate coroutine
33+
// Note: separate coroutine may not be even if you do not want to process continue executing and
34+
// just want to stop long-running handler to stop and you are using "context aware" methods (ala db queries with ctx)
35+
// e.GET("/", func(c echo.Context) error {
36+
//
37+
// doneCh := make(chan error)
38+
// go func(ctx context.Context) {
39+
// doneCh <- myPossiblyLongRunningBackgroundTaskWithCtx(ctx)
40+
// }(c.Request().Context())
41+
//
42+
// select { // wait for task to finish or context to timeout/cancelled
43+
// case err := <-doneCh:
44+
// if err != nil {
45+
// return err
46+
// }
47+
// return c.String(http.StatusOK, "OK")
48+
// case <-c.Request().Context().Done():
49+
// if c.Request().Context().Err() == context.DeadlineExceeded {
50+
// return c.String(http.StatusServiceUnavailable, "timeout")
51+
// }
52+
// return c.Request().Context().Err()
53+
// }
54+
//
55+
// })
56+
//
57+
1158
type (
1259
// TimeoutConfig defines the config for Timeout middleware.
1360
TimeoutConfig struct {
@@ -116,13 +163,20 @@ func (t echoHandlerFuncWrapper) ServeHTTP(rw http.ResponseWriter, r *http.Reques
116163
// we restore original writer only for cases we did not timeout. On timeout we have already sent response to client
117164
// and should not anymore send additional headers/data
118165
// so on timeout writer stays what http.TimeoutHandler uses and prevents writing headers/body
119-
t.ctx.Response().Writer = originalWriter
120166
if err != nil {
121-
// call global error handler to write error to the client. This is needed or `http.TimeoutHandler` will send status code by itself
122-
// and after that our tries to write status code will not work anymore
167+
// Error must be written into Writer created in `http.TimeoutHandler` so to get Response into `commited` state.
168+
// So call global error handler to write error to the client. This is needed or `http.TimeoutHandler` will send
169+
// status code by itself and after that our tries to write status code will not work anymore and/or create errors in
170+
// log about `superfluous response.WriteHeader call from`
123171
t.ctx.Error(err)
124172
// we pass error from handler to middlewares up in handler chain to act on it if needed. But this means that
125173
// global error handler is probably be called twice as `t.ctx.Error` already does that.
174+
175+
// NB: later call of the global error handler or middlewares will not take any effect, as echo.Response will be
176+
// already marked as `committed` because we called global error handler above.
177+
t.ctx.Response().Writer = originalWriter // make sure we restore before we signal original coroutine about the error
126178
t.errChan <- err
179+
return
127180
}
181+
t.ctx.Response().Writer = originalWriter
128182
}

middleware/timeout_test.go

+128
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package middleware
22

33
import (
4+
"bytes"
45
"errors"
6+
"fmt"
7+
"io/ioutil"
8+
"log"
9+
"net"
510
"net/http"
611
"net/http/httptest"
712
"net/url"
@@ -313,3 +318,126 @@ func TestTimeoutCanHandleContextDeadlineOnNextHandler(t *testing.T) {
313318
assert.Equal(t, "Timeout! change me", rec.Body.String())
314319
assert.False(t, <-handlerFinishedExecution)
315320
}
321+
322+
func TestTimeoutWithFullEchoStack(t *testing.T) {
323+
// test timeout with full http server stack running, do see what http.Server.ErrorLog contains
324+
var testCases = []struct {
325+
name string
326+
whenPath string
327+
expectStatusCode int
328+
expectResponse string
329+
expectLogContains []string
330+
expectLogNotContains []string
331+
}{
332+
{
333+
name: "404 - write response in global error handler",
334+
whenPath: "/404",
335+
expectResponse: "{\"message\":\"Not Found\"}\n",
336+
expectStatusCode: http.StatusNotFound,
337+
expectLogNotContains: []string{"echo:http: superfluous response.WriteHeader call from"},
338+
expectLogContains: []string{`"status":404,"error":"code=404, message=Not Found"`},
339+
},
340+
{
341+
name: "418 - write response in handler",
342+
whenPath: "/",
343+
expectResponse: "{\"message\":\"OK\"}\n",
344+
expectStatusCode: http.StatusTeapot,
345+
expectLogNotContains: []string{"echo:http: superfluous response.WriteHeader call from"},
346+
expectLogContains: []string{`"status":418,"error":"",`},
347+
},
348+
{
349+
name: "503 - handler timeouts, write response in timeout middleware",
350+
whenPath: "/?delay=50ms",
351+
expectResponse: "<html><head><title>Timeout</title></head><body><h1>Timeout</h1></body></html>",
352+
expectStatusCode: http.StatusServiceUnavailable,
353+
expectLogNotContains: []string{
354+
"echo:http: superfluous response.WriteHeader call from",
355+
"{", // means that logger was not called.
356+
},
357+
},
358+
}
359+
360+
e := echo.New()
361+
362+
buf := new(bytes.Buffer)
363+
e.Logger.SetOutput(buf)
364+
365+
// NOTE: timeout middleware is first as it changes Response.Writer and causes data race for logger middleware if it is not first
366+
// FIXME: I have no idea how to fix this without adding mutexes.
367+
e.Use(TimeoutWithConfig(TimeoutConfig{
368+
Timeout: 15 * time.Millisecond,
369+
}))
370+
e.Use(Logger())
371+
e.Use(Recover())
372+
373+
e.GET("/", func(c echo.Context) error {
374+
var delay time.Duration
375+
if err := echo.QueryParamsBinder(c).Duration("delay", &delay).BindError(); err != nil {
376+
return err
377+
}
378+
if delay > 0 {
379+
time.Sleep(delay)
380+
}
381+
return c.JSON(http.StatusTeapot, map[string]string{"message": "OK"})
382+
})
383+
384+
server, addr, err := startServer(e)
385+
if err != nil {
386+
assert.NoError(t, err)
387+
return
388+
}
389+
defer server.Close()
390+
391+
for _, tc := range testCases {
392+
t.Run(tc.name, func(t *testing.T) {
393+
buf.Reset() // this is design this can not be run in parallel
394+
395+
res, err := http.Get(fmt.Sprintf("http://%v%v", addr, tc.whenPath))
396+
if err != nil {
397+
assert.NoError(t, err)
398+
return
399+
}
400+
401+
assert.Equal(t, tc.expectStatusCode, res.StatusCode)
402+
if body, err := ioutil.ReadAll(res.Body); err == nil {
403+
assert.Equal(t, tc.expectResponse, string(body))
404+
} else {
405+
assert.Fail(t, err.Error())
406+
}
407+
408+
logged := buf.String()
409+
for _, subStr := range tc.expectLogContains {
410+
assert.True(t, strings.Contains(logged, subStr))
411+
}
412+
for _, subStr := range tc.expectLogNotContains {
413+
assert.False(t, strings.Contains(logged, subStr))
414+
}
415+
})
416+
}
417+
}
418+
419+
func startServer(e *echo.Echo) (*http.Server, string, error) {
420+
l, err := net.Listen("tcp", ":0")
421+
if err != nil {
422+
return nil, "", err
423+
}
424+
425+
s := http.Server{
426+
Handler: e,
427+
ErrorLog: log.New(e.Logger.Output(), "echo:", 0),
428+
}
429+
430+
errCh := make(chan error)
431+
go func() {
432+
if err := s.Serve(l); err != http.ErrServerClosed {
433+
errCh <- err
434+
}
435+
}()
436+
437+
select {
438+
case <-time.After(10 * time.Millisecond):
439+
return &s, l.Addr().String(), nil
440+
case err := <-errCh:
441+
return nil, "", err
442+
}
443+
}

0 commit comments

Comments
 (0)