Skip to content
113 changes: 113 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,118 @@
# Changelog

## v4.15.0 - TBD

**DEPRECATION NOTICE** Timeout Middleware Deprecated - Use ContextTimeout Instead

The `middleware.Timeout` middleware has been **deprecated** due to fundamental architectural issues that cause
data races. Use `middleware.ContextTimeout` or `middleware.ContextTimeoutWithConfig` instead.

**Why is this being deprecated?**

The Timeout middleware manipulates response writers across goroutine boundaries, which causes data races that
cannot be reliably fixed without a complete architectural redesign. The middleware:

- Swaps the response writer using `http.TimeoutHandler`
- Must be the first middleware in the chain (fragile constraint)
- Can cause races with other middleware (Logger, metrics, custom middleware)
- Has been the source of multiple race condition fixes over the years

**What should you use instead?**

The `ContextTimeout` middleware (available since v4.12.0) provides timeout functionality using Go's standard
context mechanism. It is:

- Race-free by design
- Can be placed anywhere in the middleware chain
- Simpler and more maintainable
- Compatible with all other middleware

**Migration Guide:**

```go
// Before (deprecated):
e.Use(middleware.Timeout())

// After (recommended):
e.Use(middleware.ContextTimeout(30 * time.Second))
```

With configuration:
```go
// Before (deprecated):
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Timeout: 30 * time.Second,
Skipper: func(c echo.Context) bool {
return c.Path() == "/health"
},
}))

// After (recommended):
e.Use(middleware.ContextTimeoutWithConfig(middleware.ContextTimeoutConfig{
Timeout: 30 * time.Second,
Skipper: func(c echo.Context) bool {
return c.Path() == "/health"
},
}))
```

**Important Behavioral Differences:**

1. **Handler cooperation required**: With ContextTimeout, your handlers must check `context.Done()` for cooperative
cancellation. The old Timeout middleware would send a 503 response regardless of handler cooperation, but had
data race issues.

2. **Error handling**: ContextTimeout returns errors through the standard error handling flow. Handlers that receive
`context.DeadlineExceeded` should handle it appropriately:

```go
e.GET("/long-task", func(c echo.Context) error {
ctx := c.Request().Context()

// Example: database query with context
result, err := db.QueryContext(ctx, "SELECT * FROM large_table")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// Handle timeout
return echo.NewHTTPError(http.StatusServiceUnavailable, "Request timeout")
}
return err
}

return c.JSON(http.StatusOK, result)
})
```

3. **Background tasks**: For long-running background tasks, use goroutines with context:

```go
e.GET("/async-task", func(c echo.Context) error {
ctx := c.Request().Context()

resultCh := make(chan Result, 1)
errCh := make(chan error, 1)

go func() {
result, err := performLongTask(ctx)
if err != nil {
errCh <- err
return
}
resultCh <- result
}()

select {
case result := <-resultCh:
return c.JSON(http.StatusOK, result)
case err := <-errCh:
return err
case <-ctx.Done():
return echo.NewHTTPError(http.StatusServiceUnavailable, "Request timeout")
}
})
```


## v4.14.0 - 2025-12-11

`middleware.Logger` has been deprecated. For request logging, use `middleware.RequestLogger` or
Expand Down
8 changes: 6 additions & 2 deletions middleware/body_dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,12 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {

// Request
reqBody := []byte{}
if c.Request().Body != nil { // Read
reqBody, _ = io.ReadAll(c.Request().Body)
if c.Request().Body != nil {
var readErr error
reqBody, readErr = io.ReadAll(c.Request().Body)
if readErr != nil {
return readErr
}
}
c.Request().Body = io.NopCloser(bytes.NewBuffer(reqBody)) // Reset

Expand Down
62 changes: 62 additions & 0 deletions middleware/body_dump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,65 @@ func TestBodyDumpResponseWriter_CanNotHijack(t *testing.T) {
_, _, err := bdrw.Hijack()
assert.EqualError(t, err, "feature not supported")
}

func TestBodyDump_ReadError(t *testing.T) {
e := echo.New()

// Create a reader that fails during read
failingReader := &failingReadCloser{
data: []byte("partial data"),
failAt: 7, // Fail after 7 bytes
failWith: errors.New("connection reset"),
}

req := httptest.NewRequest(http.MethodPost, "/", failingReader)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

h := func(c echo.Context) error {
// This handler should not be reached if body read fails
body, _ := io.ReadAll(c.Request().Body)
return c.String(http.StatusOK, string(body))
}

requestBodyReceived := ""
mw := BodyDump(func(c echo.Context, reqBody, resBody []byte) {
requestBodyReceived = string(reqBody)
})

err := mw(h)(c)

// Verify error is propagated
assert.Error(t, err)
assert.Contains(t, err.Error(), "connection reset")

// Verify handler was not executed (callback wouldn't have received data)
assert.Empty(t, requestBodyReceived)
}

// failingReadCloser is a helper type for testing read errors
type failingReadCloser struct {
data []byte
pos int
failAt int
failWith error
}

func (f *failingReadCloser) Read(p []byte) (n int, err error) {
if f.pos >= f.failAt {
return 0, f.failWith
}

n = copy(p, f.data[f.pos:])
f.pos += n

if f.pos >= f.failAt {
return n, f.failWith
}

return n, nil
}

func (f *failingReadCloser) Close() error {
return nil
}
6 changes: 5 additions & 1 deletion middleware/body_limit.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package middleware
import (
"fmt"
"io"
"net/http"
"sync"

"github.com/labstack/echo/v4"
Expand Down Expand Up @@ -77,7 +78,10 @@ func BodyLimitWithConfig(config BodyLimitConfig) echo.MiddlewareFunc {
}

// Based on content read
r := pool.Get().(*limitedReader)
r, ok := pool.Get().(*limitedReader)
if !ok {
return echo.NewHTTPError(http.StatusInternalServerError, "invalid pool object")
}
r.Reset(req.Body)
defer pool.Put(r)
req.Body = r
Expand Down
6 changes: 4 additions & 2 deletions middleware/compress.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
i := pool.Get()
w, ok := i.(*gzip.Writer)
if !ok {
return echo.NewHTTPError(http.StatusInternalServerError, i.(error).Error())
return echo.NewHTTPError(http.StatusInternalServerError, "invalid pool object")
}
rw := res.Writer
w.Reset(rw)
Expand Down Expand Up @@ -189,7 +189,9 @@ func (w *gzipResponseWriter) Flush() {
w.Writer.Write(w.buffer.Bytes())
}

w.Writer.(*gzip.Writer).Flush()
if gw, ok := w.Writer.(*gzip.Writer); ok {
gw.Flush()
}
_ = http.NewResponseController(w.ResponseWriter).Flush()
}

Expand Down
2 changes: 1 addition & 1 deletion middleware/compress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ func TestGzipErrorReturnedInvalidConfig(t *testing.T) {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Contains(t, rec.Body.String(), "gzip")
assert.Contains(t, rec.Body.String(), `{"message":"invalid pool object"}`)
}

// Issue #806
Expand Down
33 changes: 33 additions & 0 deletions middleware/context_timeout.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ import (
"github.com/labstack/echo/v4"
)

// ContextTimeout Middleware
//
// ContextTimeout provides request timeout functionality using Go's context mechanism.
// It is the recommended replacement for the deprecated Timeout middleware.
//
//
// Basic Usage:
//
// e.Use(middleware.ContextTimeout(30 * time.Second))
//
// With Configuration:
//
// e.Use(middleware.ContextTimeoutWithConfig(middleware.ContextTimeoutConfig{
// Timeout: 30 * time.Second,
// Skipper: middleware.DefaultSkipper,
// }))
//
// Handler Example:
//
// e.GET("/task", func(c echo.Context) error {
// ctx := c.Request().Context()
//
// result, err := performTaskWithContext(ctx)
// if err != nil {
// if errors.Is(err, context.DeadlineExceeded) {
// return echo.NewHTTPError(http.StatusServiceUnavailable, "timeout")
// }
// return err
// }
//
// return c.JSON(http.StatusOK, result)
// })

// ContextTimeoutConfig defines the config for ContextTimeout middleware.
type ContextTimeoutConfig struct {
// Skipper defines a function to skip middleware.
Expand Down
6 changes: 1 addition & 5 deletions middleware/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package middleware

import (
"bytes"
"encoding/json"
"io"
"strconv"
"strings"
Expand Down Expand Up @@ -375,10 +374,7 @@ func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc {
return buf.WriteString(s)
case "error":
if err != nil {
// Error may contain invalid JSON e.g. `"`
b, _ := json.Marshal(err.Error())
b = b[1 : len(b)-1]
return buf.Write(b)
return writeJSONSafeString(buf, err.Error())
}
case "latency":
l := stop.Sub(start)
Expand Down
3 changes: 3 additions & 0 deletions middleware/logger_strings_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors

package middleware

import (
Expand Down
15 changes: 15 additions & 0 deletions middleware/logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ func TestLoggerDefaultMW(t *testing.T) {
whenError: errors.New("error"),
expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":500,"error":"error","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":36}` + "\n",
},
{
name: "error with invalid UTF-8 sequences",
whenError: errors.New("invalid data: \xFF\xFE"),
expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":500,"error":"invalid data: \ufffd\ufffd","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":36}` + "\n",
},
{
name: "error with JSON special characters (quotes and backslashes)",
whenError: errors.New(`error with "quotes" and \backslash`),
expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":500,"error":"error with \"quotes\" and \\backslash","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":36}` + "\n",
},
{
name: "error with control characters (newlines and tabs)",
whenError: errors.New("error\nwith\nnewlines\tand\ttabs"),
expect: `{"time":"2020-04-28T01:26:40Z","id":"","remote_ip":"192.0.2.1","host":"example.com","method":"GET","uri":"/","user_agent":"","status":500,"error":"error\nwith\nnewlines\tand\ttabs","latency":1,"latency_human":"1µs","bytes_in":0,"bytes_out":36}` + "\n",
},
{
name: "ok, remote_ip from X-Real-Ip header",
whenHeader: map[string]string{echo.HeaderXRealIP: "127.0.0.1"},
Expand Down
16 changes: 11 additions & 5 deletions middleware/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,21 @@ func proxyRaw(t *ProxyTarget, c echo.Context, config ProxyConfig) http.Handler {

errCh := make(chan error, 2)
cp := func(dst io.Writer, src io.Reader) {
_, err = io.Copy(dst, src)
errCh <- err
_, copyErr := io.Copy(dst, src)
errCh <- copyErr
}

go cp(out, in)
go cp(in, out)
err = <-errCh
if err != nil && err != io.EOF {
c.Set("_error", fmt.Errorf("proxy raw, copy body error=%w, url=%s", err, t.URL))

// Wait for BOTH goroutines to complete
err1 := <-errCh
err2 := <-errCh

if err1 != nil && err1 != io.EOF {
c.Set("_error", fmt.Errorf("proxy raw, copy body error=%w, url=%s", err1, t.URL))
} else if err2 != nil && err2 != io.EOF {
c.Set("_error", fmt.Errorf("proxy raw, copy body error=%w, url=%s", err2, t.URL))
}
})
}
Expand Down
6 changes: 4 additions & 2 deletions middleware/rate_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package middleware

import (
"math"
"net/http"
"sync"
"time"
Expand Down Expand Up @@ -215,7 +216,7 @@ func NewRateLimiterMemoryStoreWithConfig(config RateLimiterMemoryStoreConfig) (s
store.expiresIn = DefaultRateLimiterMemoryStoreConfig.ExpiresIn
}
if config.Burst == 0 {
store.burst = int(config.Rate)
store.burst = int(math.Max(1, math.Ceil(float64(config.Rate))))
}
store.visitors = make(map[string]*Visitor)
store.timeNow = time.Now
Expand Down Expand Up @@ -249,8 +250,9 @@ func (store *RateLimiterMemoryStore) Allow(identifier string) (bool, error) {
if now.Sub(store.lastCleanup) > store.expiresIn {
store.cleanupStaleVisitors()
}
allowed := limiter.AllowN(now, 1)
store.mutex.Unlock()
return limiter.AllowN(store.timeNow(), 1), nil
return allowed, nil
}

/*
Expand Down
Loading
Loading