Repository: labstack/echo
Pull request #2267 (2022-09-09 13:36:54)

gzip response only if it exceeds a minimal length
If the response is too short, e.g. a few bytes, compressing the response makes it even larger. The new parameter MinLength to the GzipConfig struct allows to set a threshold (in bytes) as of which response size the compression should be applied. If the response is shorter, no compression will be applied.

aldas: I think this will break `http.Flusher` interface logic. If you partially write response to the client and that part is smaller than limit and now call flush - what will happen? What status client will see for flushes response as it seems that `func (w *gzipResponseWriter) WriteHeader(code int) {` has been changed. 


If `GzipConfig.MinLength` is to bee number it needs to have comments at which size it makes sense to use this feature. Writing 1 byte from handler gives me 26 byte of gzipped data. https://en.wikipedia.org/wiki/Gzip#File_format says that gzip header is 10 bytes and footer 8 bytes + data + some something else. so minimum is probably something around 19-20 bytes?
Does it even make sense to set it larger number than 20?
aldas: Also thread about https://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits
ioppermann: @aldas Thanks for your thoughts.

For the `http.Flusher` it must probably enforce a gzip'ed response (ignoring the threshold) in order not to break the interface logic.

Regarding the threshold for compressing the response, I would say it depends on the application. For an API with JSON responses, a lower threshold or no threshold at all might be appropriate. But if an API has many empty responses (or a simple `OK`), then compressing the response will make it bigger and consumes more CPU (on the server and client) and increases the latency. It really depends on the use-case at hand.
aldas: I think that field needs definitely longer description than at the moment. I you way that most of the people would not understand what they are doing and start setting random numbers there. There should be declaration of related aspects that you should consider when changing this number so there would be somewhat informed decision.
aldas: and probably `gzipResponseWriter.buffer` needs to be pooled as we are already pooling `gzip.Writer`
ioppermann: Any more comments on this?
codecov[bot]: ## [Codecov](https://codecov.io/gh/labstack/echo/pull/2267?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) Report
Patch coverage: **`94.33`**% and project coverage change: **`+0.01`** :tada:
> Comparison is base [(`f22ba67`)](https://codecov.io/gh/labstack/echo/commit/f22ba6725c66896efdef029aaeb3bfc471f171c3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) 92.87% compared to head [(`e931f16`)](https://codecov.io/gh/labstack/echo/pull/2267?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) 92.88%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #2267      +/-   ##
==========================================
+ Coverage   92.87%   92.88%   +0.01%     
==========================================
  Files          39       39              
  Lines        4519     4569      +50     
==========================================
+ Hits         4197     4244      +47     
- Misses        234      236       +2     
- Partials       88       89       +1     
```


| [Impacted Files](https://codecov.io/gh/labstack/echo/pull/2267?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) | Coverage Δ | |
|---|---|---|
| [middleware/compress.go](https://codecov.io/gh/labstack/echo/pull/2267?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-bWlkZGxld2FyZS9jb21wcmVzcy5nbw==) | `86.55% <94.33%> (+5.39%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/labstack/echo/pull/2267?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).

aldas: @ioppermann other than that test function should not consist of multiple testcases - divide it to multiple test functions or use table based tests, I think this PR is OK.   After https://github.com/labstack/echo/pull/2355 I am little bit vary of changing this middleware but I can not find any problems to block to ATM :)

just to be sure I looked what other frameworks are doing -  similar thing for Gin middleware https://github.com/nanmu42/gzip/blob/v1/writerwrapper.go  FastHTTP does some minimal length checking https://github.com/valyala/fasthttp/blob/87cb886157ae414e178fd5bc14a57d5e1c8fcadb/http.go#L1766

@lammel could you please review this. 



Patch:
diff --git a/README.md b/README.md
index fe78b6e..ea8f30f 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,7 @@ of middlewares in this list.
 | [github.com/swaggo/echo-swagger](https://github.com/swaggo/echo-swagger)                             | Automatically generate RESTful API documentation with [Swagger](https://swagger.io/) 2.0.                                                                                                                |
 | [github.com/ziflex/lecho](https://github.com/ziflex/lecho)                                           | [Zerolog](https://github.com/rs/zerolog) logging library wrapper for Echo logger interface.                                                                                                              |
 | [github.com/brpaz/echozap](https://github.com/brpaz/echozap)                                         | Uber´s [Zap](https://github.com/uber-go/zap) logging library wrapper for Echo logger interface.                                                                                                          |
+| [github.com/samber/slog-echo](https://github.com/samber/slog-echo)                                         | Go [slog](https://pkg.go.dev/golang.org/x/exp/slog) logging library wrapper for Echo logger interface.                                                                                                          |
 | [github.com/darkweak/souin/plugins/echo](https://github.com/darkweak/souin/tree/master/plugins/echo) | HTTP cache system based on [Souin](https://github.com/darkweak/souin) to automatically get your endpoints cached. It supports some distributed and non-distributed storage systems depending your needs. |
 | [github.com/mikestefanello/pagoda](https://github.com/mikestefanello/pagoda)                         | Rapid, easy full-stack web development starter kit built with Echo.                                                                                                                                      |
 | [github.com/go-woo/protoc-gen-echo](https://github.com/go-woo/protoc-gen-echo)                       | ProtoBuf generate Echo server side code                                                                                                                                                                  |
diff --git a/bind.go b/bind.go
index c841ca0..374a2ae 100644
--- a/bind.go
+++ b/bind.go
@@ -114,7 +114,7 @@ func (b *DefaultBinder) Bind(i interface{}, c Context) (err error) {
 	// Only bind query parameters for GET/DELETE/HEAD to avoid unexpected behavior with destination struct binding from body.
 	// For example a request URL `&id=1&lang=en` with body `{"id":100,"lang":"de"}` would lead to precedence issues.
 	// The HTTP method check restores pre-v4.1.11 behavior to avoid these problems (see issue #1670)
-  method := c.Request().Method
+	method := c.Request().Method
 	if method == http.MethodGet || method == http.MethodDelete || method == http.MethodHead {
 		if err = b.BindQueryParams(c, i); err != nil {
 			return err
diff --git a/binder.go b/binder.go
index 5a6cf9d..29cceca 100644
--- a/binder.go
+++ b/binder.go
@@ -1236,7 +1236,7 @@ func (b *ValueBinder) durations(sourceParam string, values []string, dest *[]tim
 // Example: 1609180603 bind to 2020-12-28T18:36:43.000000000+00:00
 //
 // Note:
-//  * time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
+//   - time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
 func (b *ValueBinder) UnixTime(sourceParam string, dest *time.Time) *ValueBinder {
 	return b.unixTime(sourceParam, dest, false, time.Second)
 }
@@ -1247,7 +1247,7 @@ func (b *ValueBinder) UnixTime(sourceParam string, dest *time.Time) *ValueBinder
 // Example: 1609180603 bind to 2020-12-28T18:36:43.000000000+00:00
 //
 // Note:
-//  * time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
+//   - time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
 func (b *ValueBinder) MustUnixTime(sourceParam string, dest *time.Time) *ValueBinder {
 	return b.unixTime(sourceParam, dest, true, time.Second)
 }
@@ -1257,7 +1257,7 @@ func (b *ValueBinder) MustUnixTime(sourceParam string, dest *time.Time) *ValueBi
 // Example: 1647184410140 bind to 2022-03-13T15:13:30.140000000+00:00
 //
 // Note:
-//  * time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
+//   - time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
 func (b *ValueBinder) UnixTimeMilli(sourceParam string, dest *time.Time) *ValueBinder {
 	return b.unixTime(sourceParam, dest, false, time.Millisecond)
 }
@@ -1268,7 +1268,7 @@ func (b *ValueBinder) UnixTimeMilli(sourceParam string, dest *time.Time) *ValueB
 // Example: 1647184410140 bind to 2022-03-13T15:13:30.140000000+00:00
 //
 // Note:
-//  * time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
+//   - time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
 func (b *ValueBinder) MustUnixTimeMilli(sourceParam string, dest *time.Time) *ValueBinder {
 	return b.unixTime(sourceParam, dest, true, time.Millisecond)
 }
@@ -1280,8 +1280,8 @@ func (b *ValueBinder) MustUnixTimeMilli(sourceParam string, dest *time.Time) *Va
 // Example:           999999999 binds to 1970-01-01T00:00:00.999999999+00:00
 //
 // Note:
-//  * time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
-//  * Javascript's Number type only has about 53 bits of precision (Number.MAX_SAFE_INTEGER = 9007199254740991). Compare it to 1609180603123456789 in example.
+//   - time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
+//   - Javascript's Number type only has about 53 bits of precision (Number.MAX_SAFE_INTEGER = 9007199254740991). Compare it to 1609180603123456789 in example.
 func (b *ValueBinder) UnixTimeNano(sourceParam string, dest *time.Time) *ValueBinder {
 	return b.unixTime(sourceParam, dest, false, time.Nanosecond)
 }
@@ -1294,8 +1294,8 @@ func (b *ValueBinder) UnixTimeNano(sourceParam string, dest *time.Time) *ValueBi
 // Example:           999999999 binds to 1970-01-01T00:00:00.999999999+00:00
 //
 // Note:
-//  * time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
-//  * Javascript's Number type only has about 53 bits of precision (Number.MAX_SAFE_INTEGER = 9007199254740991). Compare it to 1609180603123456789 in example.
+//   - time.Time{} (param is empty) and time.Unix(0,0) (param = "0") are not equal
+//   - Javascript's Number type only has about 53 bits of precision (Number.MAX_SAFE_INTEGER = 9007199254740991). Compare it to 1609180603123456789 in example.
 func (b *ValueBinder) MustUnixTimeNano(sourceParam string, dest *time.Time) *ValueBinder {
 	return b.unixTime(sourceParam, dest, true, time.Nanosecond)
 }
diff --git a/echo.go b/echo.go
index 085a3a7..e216354 100644
--- a/echo.go
+++ b/echo.go
@@ -39,6 +39,7 @@ package echo
 import (
 	stdContext "context"
 	"crypto/tls"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -438,12 +439,18 @@ func (e *Echo) DefaultHTTPErrorHandler(err error, c Context) {
 	// Issue #1426
 	code := he.Code
 	message := he.Message
-	if m, ok := he.Message.(string); ok {
+
+	switch m := he.Message.(type) {
+	case string:
 		if e.Debug {
 			message = Map{"message": m, "error": err.Error()}
 		} else {
 			message = Map{"message": m}
 		}
+	case json.Marshaler:
+		// do nothing - this type knows how to format itself to JSON
+	case error:
+		message = Map{"message": m.Error()}
 	}
 
 	// Send response
@@ -614,7 +621,7 @@ func (e *Echo) URL(h HandlerFunc, params ...interface{}) string {
 	return e.URI(h, params...)
 }
 
-// Reverse generates an URL from route name and provided parameters.
+// Reverse generates a URL from route name and provided parameters.
 func (e *Echo) Reverse(name string, params ...interface{}) string {
 	return e.router.Reverse(name, params...)
 }
diff --git a/middleware/basic_auth.go b/middleware/basic_auth.go
index 52ef104..f9e8caa 100644
--- a/middleware/basic_auth.go
+++ b/middleware/basic_auth.go
@@ -2,9 +2,9 @@ package middleware
 
 import (
 	"encoding/base64"
+	"net/http"
 	"strconv"
 	"strings"
-	"net/http"
 
 	"github.com/labstack/echo/v4"
 )
diff --git a/middleware/compress.go b/middleware/compress.go
index 9e5f610..cbe29fc 100644
--- a/middleware/compress.go
+++ b/middleware/compress.go
@@ -2,6 +2,7 @@ package middleware
 
 import (
 	"bufio"
+	"bytes"
 	"compress/gzip"
 	"io"
 	"net"
@@ -21,12 +22,30 @@ type (
 		// Gzip compression level.
 		// Optional. Default value -1.
 		Level int `yaml:"level"`
+
+		// Length threshold before gzip compression is applied.
+		// Optional. Default value 0.
+		//
+		// Most of the time you will not need to change the default. Compressing
+		// a short response might increase the transmitted data because of the
+		// gzip format overhead. Compressing the response will also consume CPU
+		// and time on the server and the client (for decompressing). Depending on
+		// your use case such a threshold might be useful.
+		//
+		// See also:
+		// https://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits
+		MinLength int
 	}
 
 	gzipResponseWriter struct {
 		io.Writer
 		http.ResponseWriter
-		wroteBody bool
+		wroteHeader       bool
+		wroteBody         bool
+		minLength         int
+		minLengthExceeded bool
+		buffer            *bytes.Buffer
+		code              int
 	}
 )
 
@@ -37,8 +56,9 @@ const (
 var (
 	// DefaultGzipConfig is the default Gzip middleware config.
 	DefaultGzipConfig = GzipConfig{
-		Skipper: DefaultSkipper,
-		Level:   -1,
+		Skipper:   DefaultSkipper,
+		Level:     -1,
+		MinLength: 0,
 	}
 )
 
@@ -58,8 +78,12 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 	if config.Level == 0 {
 		config.Level = DefaultGzipConfig.Level
 	}
+	if config.MinLength < 0 {
+		config.MinLength = DefaultGzipConfig.MinLength
+	}
 
 	pool := gzipCompressPool(config)
+	bpool := bufferPool()
 
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
 		return func(c echo.Context) error {
@@ -70,7 +94,6 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 			res := c.Response()
 			res.Header().Add(echo.HeaderVary, echo.HeaderAcceptEncoding)
 			if strings.Contains(c.Request().Header.Get(echo.HeaderAcceptEncoding), gzipScheme) {
-				res.Header().Set(echo.HeaderContentEncoding, gzipScheme) // Issue #806
 				i := pool.Get()
 				w, ok := i.(*gzip.Writer)
 				if !ok {
@@ -78,7 +101,11 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 				}
 				rw := res.Writer
 				w.Reset(rw)
-				grw := &gzipResponseWriter{Writer: w, ResponseWriter: rw}
+
+				buf := bpool.Get().(*bytes.Buffer)
+				buf.Reset()
+
+				grw := &gzipResponseWriter{Writer: w, ResponseWriter: rw, minLength: config.MinLength, buffer: buf}
 				defer func() {
 					if !grw.wroteBody {
 						if res.Header().Get(echo.HeaderContentEncoding) == gzipScheme {
@@ -89,8 +116,17 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 						// See issue #424, #407.
 						res.Writer = rw
 						w.Reset(io.Discard)
+					} else if !grw.minLengthExceeded {
+						// Write uncompressed response
+						res.Writer = rw
+						if grw.wroteHeader {
+							grw.ResponseWriter.WriteHeader(grw.code)
+						}
+						grw.buffer.WriteTo(rw)
+						w.Reset(io.Discard)
 					}
 					w.Close()
+					bpool.Put(buf)
 					pool.Put(w)
 				}()
 				res.Writer = grw
@@ -102,7 +138,11 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 
 func (w *gzipResponseWriter) WriteHeader(code int) {
 	w.Header().Del(echo.HeaderContentLength) // Issue #444
-	w.ResponseWriter.WriteHeader(code)
+
+	w.wroteHeader = true
+
+	// Delay writing of the header until we know if we'll actually compress the response
+	w.code = code
 }
 
 func (w *gzipResponseWriter) Write(b []byte) (int, error) {
@@ -110,10 +150,40 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
 		w.Header().Set(echo.HeaderContentType, http.DetectContentType(b))
 	}
 	w.wroteBody = true
+
+	if !w.minLengthExceeded {
+		n, err := w.buffer.Write(b)
+
+		if w.buffer.Len() >= w.minLength {
+			w.minLengthExceeded = true
+
+			// The minimum length is exceeded, add Content-Encoding header and write the header
+			w.Header().Set(echo.HeaderContentEncoding, gzipScheme) // Issue #806
+			if w.wroteHeader {
+				w.ResponseWriter.WriteHeader(w.code)
+			}
+
+			return w.Writer.Write(w.buffer.Bytes())
+		}
+
+		return n, err
+	}
+
 	return w.Writer.Write(b)
 }
 
 func (w *gzipResponseWriter) Flush() {
+	if !w.minLengthExceeded {
+		// Enforce compression because we will not know how much more data will come
+		w.minLengthExceeded = true
+		w.Header().Set(echo.HeaderContentEncoding, gzipScheme) // Issue #806
+		if w.wroteHeader {
+			w.ResponseWriter.WriteHeader(w.code)
+		}
+
+		w.Writer.Write(w.buffer.Bytes())
+	}
+
 	w.Writer.(*gzip.Writer).Flush()
 	if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
 		flusher.Flush()
@@ -142,3 +212,12 @@ func gzipCompressPool(config GzipConfig) sync.Pool {
 		},
 	}
 }
+
+func bufferPool() sync.Pool {
+	return sync.Pool{
+		New: func() interface{} {
+			b := &bytes.Buffer{}
+			return b
+		},
+	}
+}
diff --git a/middleware/decompress.go b/middleware/decompress.go
index 88ec709..a73c973 100644
--- a/middleware/decompress.go
+++ b/middleware/decompress.go
@@ -20,7 +20,7 @@ type (
 	}
 )
 
-//GZIPEncoding content-encoding header if set to "gzip", decompress body contents.
+// GZIPEncoding content-encoding header if set to "gzip", decompress body contents.
 const GZIPEncoding string = "gzip"
 
 // Decompressor is used to get the sync.Pool used by the middleware to get Gzip readers
@@ -44,12 +44,12 @@ func (d *DefaultGzipDecompressPool) gzipDecompressPool() sync.Pool {
 	return sync.Pool{New: func() interface{} { return new(gzip.Reader) }}
 }
 
-//Decompress decompresses request body based if content encoding type is set to "gzip" with default config
+// Decompress decompresses request body based if content encoding type is set to "gzip" with default config
 func Decompress() echo.MiddlewareFunc {
 	return DecompressWithConfig(DefaultDecompressConfig)
 }
 
-//DecompressWithConfig decompresses request body based if content encoding type is set to "gzip" with config
+// DecompressWithConfig decompresses request body based if content encoding type is set to "gzip" with config
 func DecompressWithConfig(config DecompressConfig) echo.MiddlewareFunc {
 	// Defaults
 	if config.Skipper == nil {
diff --git a/middleware/proxy.go b/middleware/proxy.go
index 74f49de..e4f98d9 100644
--- a/middleware/proxy.go
+++ b/middleware/proxy.go
@@ -29,6 +29,33 @@ type (
 		// Required.
 		Balancer ProxyBalancer
 
+		// RetryCount defines the number of times a failed proxied request should be retried
+		// using the next available ProxyTarget. Defaults to 0, meaning requests are never retried.
+		RetryCount int
+
+		// RetryFilter defines a function used to determine if a failed request to a
+		// ProxyTarget should be retried. The RetryFilter will only be called when the number
+		// of previous retries is less than RetryCount. If the function returns true, the
+		// request will be retried. The provided error indicates the reason for the request
+		// failure. When the ProxyTarget is unavailable, the error will be an instance of
+		// echo.HTTPError with a Code of http.StatusBadGateway. In all other cases, the error
+		// will indicate an internal error in the Proxy middleware. When a RetryFilter is not
+		// specified, all requests that fail with http.StatusBadGateway will be retried. A custom
+		// RetryFilter can be provided to only retry specific requests. Note that RetryFilter is
+		// only called when the request to the target fails, or an internal error in the Proxy
+		// middleware has occurred. Successful requests that return a non-200 response code cannot
+		// be retried.
+		RetryFilter func(c echo.Context, e error) bool
+
+		// ErrorHandler defines a function which can be used to return custom errors from
+		// the Proxy middleware. ErrorHandler is only invoked when there has been
+		// either an internal error in the Proxy middleware or the ProxyTarget is
+		// unavailable. Due to the way requests are proxied, ErrorHandler is not invoked
+		// when a ProxyTarget returns a non-200 response. In these cases, the response
+		// is already written so errors cannot be modified. ErrorHandler is only
+		// invoked after all retry attempts have been exhausted.
+		ErrorHandler func(c echo.Context, err error) error
+
 		// Rewrite defines URL path rewrite rules. The values captured in asterisk can be
 		// retrieved by index e.g. $1, $2 and so on.
 		// Examples:
@@ -71,7 +98,8 @@ type (
 		Next(echo.Context) *ProxyTarget
 	}
 
-	// TargetProvider defines an interface that gives the opportunity for balancer to return custom errors when selecting target.
+	// TargetProvider defines an interface that gives the opportunity for balancer
+	// to return custom errors when selecting target.
 	TargetProvider interface {
 		NextTarget(echo.Context) (*ProxyTarget, error)
 	}
@@ -107,14 +135,14 @@ func proxyRaw(t *ProxyTarget, c echo.Context) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		in, _, err := c.Response().Hijack()
 		if err != nil {
-			c.Set("_error", fmt.Sprintf("proxy raw, hijack error=%v, url=%s", t.URL, err))
+			c.Set("_error", fmt.Errorf("proxy raw, hijack error=%w, url=%s", err, t.URL))
 			return
 		}
 		defer in.Close()
 
 		out, err := net.Dial("tcp", t.URL.Host)
 		if err != nil {
-			c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, dial error=%v, url=%s", t.URL, err)))
+			c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, dial error=%v, url=%s", err, t.URL)))
 			return
 		}
 		defer out.Close()
@@ -122,7 +150,7 @@ func proxyRaw(t *ProxyTarget, c echo.Context) http.Handler {
 		// Write header
 		err = r.Write(out)
 		if err != nil {
-			c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, request header copy error=%v, url=%s", t.URL, err)))
+			c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, request header copy error=%v, url=%s", err, t.URL)))
 			return
 		}
 
@@ -136,7 +164,7 @@ func proxyRaw(t *ProxyTarget, c echo.Context) http.Handler {
 		go cp(in, out)
 		err = <-errCh
 		if err != nil && err != io.EOF {
-			c.Set("_error", fmt.Errorf("proxy raw, copy body error=%v, url=%s", t.URL, err))
+			c.Set("_error", fmt.Errorf("proxy raw, copy body error=%w, url=%s", err, t.URL))
 		}
 	})
 }
@@ -200,7 +228,12 @@ func (b *randomBalancer) Next(c echo.Context) *ProxyTarget {
 	return b.targets[b.random.Intn(len(b.targets))]
 }
 
-// Next returns an upstream target using round-robin technique.
+// Next returns an upstream target using round-robin technique. In the case
+// where a previously failed request is being retried, the round-robin
+// balancer will attempt to use the next target relative to the original
+// request. If the list of targets held by the balancer is modified while a
+// failed request is being retried, it is possible that the balancer will
+// return the original failed target.
 //
 // Note: `nil` is returned in case upstream target list is empty.
 func (b *roundRobinBalancer) Next(c echo.Context) *ProxyTarget {
@@ -211,13 +244,29 @@ func (b *roundRobinBalancer) Next(c echo.Context) *ProxyTarget {
 	} else if len(b.targets) == 1 {
 		return b.targets[0]
 	}
-	// reset the index if out of bounds
-	if b.i >= len(b.targets) {
-		b.i = 0
+
+	var i int
+	const lastIdxKey = "_round_robin_last_index"
+	// This request is a retry, start from the index of the previous
+	// target to ensure we don't attempt to retry the request with
+	// the same failed target
+	if c.Get(lastIdxKey) != nil {
+		i = c.Get(lastIdxKey).(int)
+		i++
+		if i >= len(b.targets) {
+			i = 0
+		}
+	} else {
+		// This is a first time request, use the global index
+		if b.i >= len(b.targets) {
+			b.i = 0
+		}
+		i = b.i
+		b.i++
 	}
-	t := b.targets[b.i]
-	b.i++
-	return t
+
+	c.Set(lastIdxKey, i)
+	return b.targets[i]
 }
 
 // Proxy returns a Proxy middleware.
@@ -232,14 +281,26 @@ func Proxy(balancer ProxyBalancer) echo.MiddlewareFunc {
 // ProxyWithConfig returns a Proxy middleware with config.
 // See: `Proxy()`
 func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
+	if config.Balancer == nil {
+		panic("echo: proxy middleware requires balancer")
+	}
 	// Defaults
 	if config.Skipper == nil {
 		config.Skipper = DefaultProxyConfig.Skipper
 	}
-	if config.Balancer == nil {
-		panic("echo: proxy middleware requires balancer")
+	if config.RetryFilter == nil {
+		config.RetryFilter = func(c echo.Context, e error) bool {
+			if httpErr, ok := e.(*echo.HTTPError); ok {
+				return httpErr.Code == http.StatusBadGateway
+			}
+			return false
+		}
+	}
+	if config.ErrorHandler == nil {
+		config.ErrorHandler = func(c echo.Context, err error) error {
+			return err
+		}
 	}
-
 	if config.Rewrite != nil {
 		if config.RegexRewrite == nil {
 			config.RegexRewrite = make(map[*regexp.Regexp]string)
@@ -250,28 +311,17 @@ func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
 	}
 
 	provider, isTargetProvider := config.Balancer.(TargetProvider)
+
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
-		return func(c echo.Context) (err error) {
+		return func(c echo.Context) error {
 			if config.Skipper(c) {
 				return next(c)
 			}
 
 			req := c.Request()
 			res := c.Response()
-
-			var tgt *ProxyTarget
-			if isTargetProvider {
-				tgt, err = provider.NextTarget(c)
-				if err != nil {
-					return err
-				}
-			} else {
-				tgt = config.Balancer.Next(c)
-			}
-			c.Set(config.ContextKey, tgt)
-
 			if err := rewriteURL(config.RegexRewrite, req); err != nil {
-				return err
+				return config.ErrorHandler(c, err)
 			}
 
 			// Fix header
@@ -287,19 +337,49 @@ func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
 				req.Header.Set(echo.HeaderXForwardedFor, c.RealIP())
 			}
 
-			// Proxy
-			switch {
-			case c.IsWebSocket():
-				proxyRaw(tgt, c).ServeHTTP(res, req)
-			case req.Header.Get(echo.HeaderAccept) == "text/event-stream":
-			default:
-				proxyHTTP(tgt, c, config).ServeHTTP(res, req)
-			}
-			if e, ok := c.Get("_error").(error); ok {
-				err = e
-			}
+			retries := config.RetryCount
+			for {
+				var tgt *ProxyTarget
+				var err error
+				if isTargetProvider {
+					tgt, err = provider.NextTarget(c)
+					if err != nil {
+						return config.ErrorHandler(c, err)
+					}
+				} else {
+					tgt = config.Balancer.Next(c)
+				}
 
-			return
+				c.Set(config.ContextKey, tgt)
+
+				//If retrying a failed request, clear any previous errors from
+				//context here so that balancers have the option to check for
+				//errors that occurred using previous target
+				if retries < config.RetryCount {
+					c.Set("_error", nil)
+				}
+
+				// Proxy
+				switch {
+				case c.IsWebSocket():
+					proxyRaw(tgt, c).ServeHTTP(res, req)
+				case req.Header.Get(echo.HeaderAccept) == "text/event-stream":
+				default:
+					proxyHTTP(tgt, c, config).ServeHTTP(res, req)
+				}
+
+				err, hasError := c.Get("_error").(error)
+				if !hasError {
+					return nil
+				}
+
+				retry := retries > 0 && config.RetryFilter(c, err)
+				if !retry {
+					return config.ErrorHandler(c, err)
+				}
+
+				retries--
+			}
 		}
 	}
 }
diff --git a/router.go b/router.go
index 50a6385..ee6f3fa 100644
--- a/router.go
+++ b/router.go
@@ -151,7 +151,7 @@ func (r *Router) Routes() []*Route {
 	return routes
 }
 
-// Reverse generates an URL from route name and provided parameters.
+// Reverse generates a URL from route name and provided parameters.
 func (r *Router) Reverse(name string, params ...interface{}) string {
 	uri := new(bytes.Buffer)
 	ln := len(params)


Test patch:
diff --git a/echo_test.go b/echo_test.go
index eab25db..a352e40 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -1286,67 +1286,141 @@ func TestHTTPError_Unwrap(t *testing.T) {
 	})
 }
 
+type customError struct {
+	s string
+}
+
+func (ce *customError) MarshalJSON() ([]byte, error) {
+	return []byte(fmt.Sprintf(`{"x":"%v"}`, ce.s)), nil
+}
+
+func (ce *customError) Error() string {
+	return ce.s
+}
+
 func TestDefaultHTTPErrorHandler(t *testing.T) {
-	e := New()
-	e.Debug = true
-	e.Any("/plain", func(c Context) error {
-		return errors.New("an error occurred")
-	})
-	e.Any("/badrequest", func(c Context) error {
-		return NewHTTPError(http.StatusBadRequest, "Invalid request")
-	})
-	e.Any("/servererror", func(c Context) error {
-		return NewHTTPError(http.StatusInternalServerError, map[string]interface{}{
-			"code":    33,
-			"message": "Something bad happened",
-			"error":   "stackinfo",
-		})
-	})
-	e.Any("/early-return", func(c Context) error {
-		err := c.String(http.StatusOK, "OK")
-		if err != nil {
-			assert.Fail(t, err.Error())
-		}
-		return errors.New("ERROR")
-	})
-	e.GET("/internal-error", func(c Context) error {
-		err := errors.New("internal error message body")
-		return NewHTTPError(http.StatusBadRequest).SetInternal(err)
-	})
+	var testCases = []struct {
+		name       string
+		givenDebug bool
+		whenPath   string
+		expectCode int
+		expectBody string
+	}{
+		{
+			name:       "with Debug=true plain response contains error message",
+			givenDebug: true,
+			whenPath:   "/plain",
+			expectCode: http.StatusInternalServerError,
+			expectBody: "{\n  \"error\": \"an error occurred\",\n  \"message\": \"Internal Server Error\"\n}\n",
+		},
+		{
+			name:       "with Debug=true special handling for HTTPError",
+			givenDebug: true,
+			whenPath:   "/badrequest",
+			expectCode: http.StatusBadRequest,
+			expectBody: "{\n  \"error\": \"code=400, message=Invalid request\",\n  \"message\": \"Invalid request\"\n}\n",
+		},
+		{
+			name:       "with Debug=true complex errors are serialized to pretty JSON",
+			givenDebug: true,
+			whenPath:   "/servererror",
+			expectCode: http.StatusInternalServerError,
+			expectBody: "{\n  \"code\": 33,\n  \"error\": \"stackinfo\",\n  \"message\": \"Something bad happened\"\n}\n",
+		},
+		{
+			name:       "with Debug=true if the body is already set HTTPErrorHandler should not add anything to response body",
+			givenDebug: true,
+			whenPath:   "/early-return",
+			expectCode: http.StatusOK,
+			expectBody: "OK",
+		},
+		{
+			name:       "with Debug=true internal error should be reflected in the message",
+			givenDebug: true,
+			whenPath:   "/internal-error",
+			expectCode: http.StatusBadRequest,
+			expectBody: "{\n  \"error\": \"code=400, message=Bad Request, internal=internal error message body\",\n  \"message\": \"Bad Request\"\n}\n",
+		},
+		{
+			name:       "with Debug=false the error response is shortened",
+			whenPath:   "/plain",
+			expectCode: http.StatusInternalServerError,
+			expectBody: "{\"message\":\"Internal Server Error\"}\n",
+		},
+		{
+			name:       "with Debug=false the error response is shortened",
+			whenPath:   "/badrequest",
+			expectCode: http.StatusBadRequest,
+			expectBody: "{\"message\":\"Invalid request\"}\n",
+		},
+		{
+			name:       "with Debug=false No difference for error response with non plain string errors",
+			whenPath:   "/servererror",
+			expectCode: http.StatusInternalServerError,
+			expectBody: "{\"code\":33,\"error\":\"stackinfo\",\"message\":\"Something bad happened\"}\n",
+		},
+		{
+			name:       "with Debug=false when httpError contains an error",
+			whenPath:   "/error-in-httperror",
+			expectCode: http.StatusBadRequest,
+			expectBody: "{\"message\":\"error in httperror\"}\n",
+		},
+		{
+			name:       "with Debug=false when httpError contains an error",
+			whenPath:   "/customerror-in-httperror",
+			expectCode: http.StatusBadRequest,
+			expectBody: "{\"x\":\"custom error msg\"}\n",
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			e.Debug = tc.givenDebug // With Debug=true plain response contains error message
 
-	// With Debug=true plain response contains error message
-	c, b := request(http.MethodGet, "/plain", e)
-	assert.Equal(t, http.StatusInternalServerError, c)
-	assert.Equal(t, "{\n  \"error\": \"an error occurred\",\n  \"message\": \"Internal Server Error\"\n}\n", b)
-	// and special handling for HTTPError
-	c, b = request(http.MethodGet, "/badrequest", e)
-	assert.Equal(t, http.StatusBadRequest, c)
-	assert.Equal(t, "{\n  \"error\": \"code=400, message=Invalid request\",\n  \"message\": \"Invalid request\"\n}\n", b)
-	// complex errors are serialized to pretty JSON
-	c, b = request(http.MethodGet, "/servererror", e)
-	assert.Equal(t, http.StatusInternalServerError, c)
-	assert.Equal(t, "{\n  \"code\": 33,\n  \"error\": \"stackinfo\",\n  \"message\": \"Something bad happened\"\n}\n", b)
-	// if the body is already set HTTPErrorHandler should not add anything to response body
-	c, b = request(http.MethodGet, "/early-return", e)
-	assert.Equal(t, http.StatusOK, c)
-	assert.Equal(t, "OK", b)
-	// internal error should be reflected in the message
-	c, b = request(http.MethodGet, "/internal-error", e)
-	assert.Equal(t, http.StatusBadRequest, c)
-	assert.Equal(t, "{\n  \"error\": \"code=400, message=Bad Request, internal=internal error message body\",\n  \"message\": \"Bad Request\"\n}\n", b)
-
-	e.Debug = false
-	// With Debug=false the error response is shortened
-	c, b = request(http.MethodGet, "/plain", e)
-	assert.Equal(t, http.StatusInternalServerError, c)
-	assert.Equal(t, "{\"message\":\"Internal Server Error\"}\n", b)
-	c, b = request(http.MethodGet, "/badrequest", e)
-	assert.Equal(t, http.StatusBadRequest, c)
-	assert.Equal(t, "{\"message\":\"Invalid request\"}\n", b)
-	// No difference for error response with non plain string errors
-	c, b = request(http.MethodGet, "/servererror", e)
-	assert.Equal(t, http.StatusInternalServerError, c)
-	assert.Equal(t, "{\"code\":33,\"error\":\"stackinfo\",\"message\":\"Something bad happened\"}\n", b)
+			e.Any("/plain", func(c Context) error {
+				return errors.New("an error occurred")
+			})
+
+			e.Any("/badrequest", func(c Context) error { // and special handling for HTTPError
+				return NewHTTPError(http.StatusBadRequest, "Invalid request")
+			})
+
+			e.Any("/servererror", func(c Context) error { // complex errors are serialized to pretty JSON
+				return NewHTTPError(http.StatusInternalServerError, map[string]interface{}{
+					"code":    33,
+					"message": "Something bad happened",
+					"error":   "stackinfo",
+				})
+			})
+
+			// if the body is already set HTTPErrorHandler should not add anything to response body
+			e.Any("/early-return", func(c Context) error {
+				err := c.String(http.StatusOK, "OK")
+				if err != nil {
+					assert.Fail(t, err.Error())
+				}
+				return errors.New("ERROR")
+			})
+
+			// internal error should be reflected in the message
+			e.GET("/internal-error", func(c Context) error {
+				err := errors.New("internal error message body")
+				return NewHTTPError(http.StatusBadRequest).SetInternal(err)
+			})
+
+			e.GET("/error-in-httperror", func(c Context) error {
+				return NewHTTPError(http.StatusBadRequest, errors.New("error in httperror"))
+			})
+
+			e.GET("/customerror-in-httperror", func(c Context) error {
+				return NewHTTPError(http.StatusBadRequest, &customError{s: "custom error msg"})
+			})
+
+			c, b := request(http.MethodGet, tc.whenPath, e)
+			assert.Equal(t, tc.expectCode, c)
+			assert.Equal(t, tc.expectBody, b)
+		})
+	}
 }
 
 func TestEchoClose(t *testing.T) {
diff --git a/middleware/compress_test.go b/middleware/compress_test.go
index 714548e..e43e2d6 100644
--- a/middleware/compress_test.go
+++ b/middleware/compress_test.go
@@ -88,6 +88,123 @@ func TestGzip(t *testing.T) {
 	assert.Equal(t, "test", buf.String())
 }
 
+func TestGzipWithMinLength(t *testing.T) {
+	assert := assert.New(t)
+
+	e := echo.New()
+	// Minimal response length
+	e.Use(GzipWithConfig(GzipConfig{MinLength: 10}))
+	e.GET("/", func(c echo.Context) error {
+		c.Response().Write([]byte("foobarfoobar"))
+		return nil
+	})
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme)
+	rec := httptest.NewRecorder()
+	e.ServeHTTP(rec, req)
+	assert.Equal(gzipScheme, rec.Header().Get(echo.HeaderContentEncoding))
+	r, err := gzip.NewReader(rec.Body)
+	if assert.NoError(err) {
+		buf := new(bytes.Buffer)
+		defer r.Close()
+		buf.ReadFrom(r)
+		assert.Equal("foobarfoobar", buf.String())
+	}
+}
+
+func TestGzipWithMinLengthTooShort(t *testing.T) {
+	assert := assert.New(t)
+
+	e := echo.New()
+	// Minimal response length
+	e.Use(GzipWithConfig(GzipConfig{MinLength: 10}))
+	e.GET("/", func(c echo.Context) error {
+		c.Response().Write([]byte("test"))
+		return nil
+	})
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme)
+	rec := httptest.NewRecorder()
+	e.ServeHTTP(rec, req)
+	assert.Equal("", rec.Header().Get(echo.HeaderContentEncoding))
+	assert.Contains(rec.Body.String(), "test")
+}
+
+func TestGzipWithMinLengthChunked(t *testing.T) {
+	assert := assert.New(t)
+
+	e := echo.New()
+
+	// Gzip chunked
+	chunkBuf := make([]byte, 5)
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme)
+	rec := httptest.NewRecorder()
+
+	var r *gzip.Reader = nil
+
+	c := e.NewContext(req, rec)
+	GzipWithConfig(GzipConfig{MinLength: 10})(func(c echo.Context) error {
+		c.Response().Header().Set("Content-Type", "text/event-stream")
+		c.Response().Header().Set("Transfer-Encoding", "chunked")
+
+		// Write and flush the first part of the data
+		c.Response().Write([]byte("test\n"))
+		c.Response().Flush()
+
+		// Read the first part of the data
+		assert.True(rec.Flushed)
+		assert.Equal(gzipScheme, rec.Header().Get(echo.HeaderContentEncoding))
+
+		var err error
+		r, err = gzip.NewReader(rec.Body)
+		assert.NoError(err)
+
+		_, err = io.ReadFull(r, chunkBuf)
+		assert.NoError(err)
+		assert.Equal("test\n", string(chunkBuf))
+
+		// Write and flush the second part of the data
+		c.Response().Write([]byte("test\n"))
+		c.Response().Flush()
+
+		_, err = io.ReadFull(r, chunkBuf)
+		assert.NoError(err)
+		assert.Equal("test\n", string(chunkBuf))
+
+		// Write the final part of the data and return
+		c.Response().Write([]byte("test"))
+		return nil
+	})(c)
+
+	assert.NotNil(r)
+
+	buf := new(bytes.Buffer)
+
+	buf.ReadFrom(r)
+	assert.Equal("test", buf.String())
+
+	r.Close()
+}
+
+func TestGzipWithMinLengthNoContent(t *testing.T) {
+	e := echo.New()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme)
+	rec := httptest.NewRecorder()
+	c := e.NewContext(req, rec)
+	h := GzipWithConfig(GzipConfig{MinLength: 10})(func(c echo.Context) error {
+		return c.NoContent(http.StatusNoContent)
+	})
+	if assert.NoError(t, h(c)) {
+		assert.Empty(t, rec.Header().Get(echo.HeaderContentEncoding))
+		assert.Empty(t, rec.Header().Get(echo.HeaderContentType))
+		assert.Equal(t, 0, len(rec.Body.Bytes()))
+	}
+}
+
 func TestGzipNoContent(t *testing.T) {
 	e := echo.New()
 	req := httptest.NewRequest(http.MethodGet, "/", nil)
diff --git a/middleware/proxy_test.go b/middleware/proxy_test.go
index 122ddde..1b5ba6c 100644
--- a/middleware/proxy_test.go
+++ b/middleware/proxy_test.go
@@ -3,6 +3,7 @@ package middleware
 import (
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"net"
@@ -393,6 +394,321 @@ func TestProxyError(t *testing.T) {
 	assert.Equal(t, http.StatusBadGateway, rec.Code)
 }
 
+func TestProxyRetries(t *testing.T) {
+
+	newServer := func(res int) (*url.URL, *httptest.Server) {
+		server := httptest.NewServer(
+			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(res)
+			}),
+		)
+		targetURL, _ := url.Parse(server.URL)
+		return targetURL, server
+	}
+
+	targetURL, server := newServer(http.StatusOK)
+	defer server.Close()
+	goodTarget := &ProxyTarget{
+		Name: "Good",
+		URL:  targetURL,
+	}
+
+	targetURL, server = newServer(http.StatusBadRequest)
+	defer server.Close()
+	goodTargetWith40X := &ProxyTarget{
+		Name: "Good with 40X",
+		URL:  targetURL,
+	}
+
+	targetURL, _ = url.Parse("http://127.0.0.1:27121")
+	badTarget := &ProxyTarget{
+		Name: "Bad",
+		URL:  targetURL,
+	}
+
+	alwaysRetryFilter := func(c echo.Context, e error) bool { return true }
+	neverRetryFilter := func(c echo.Context, e error) bool { return false }
+
+	testCases := []struct {
+		name             string
+		retryCount       int
+		retryFilters     []func(c echo.Context, e error) bool
+		targets          []*ProxyTarget
+		expectedResponse int
+	}{
+		{
+			name: "retry count 0 does not attempt retry on fail",
+			targets: []*ProxyTarget{
+				badTarget,
+				goodTarget,
+			},
+			expectedResponse: http.StatusBadGateway,
+		},
+		{
+			name:       "retry count 1 does not attempt retry on success",
+			retryCount: 1,
+			targets: []*ProxyTarget{
+				goodTarget,
+			},
+			expectedResponse: http.StatusOK,
+		},
+		{
+			name:       "retry count 1 does retry on handler return true",
+			retryCount: 1,
+			retryFilters: []func(c echo.Context, e error) bool{
+				alwaysRetryFilter,
+			},
+			targets: []*ProxyTarget{
+				badTarget,
+				goodTarget,
+			},
+			expectedResponse: http.StatusOK,
+		},
+		{
+			name:       "retry count 1 does not retry on handler return false",
+			retryCount: 1,
+			retryFilters: []func(c echo.Context, e error) bool{
+				neverRetryFilter,
+			},
+			targets: []*ProxyTarget{
+				badTarget,
+				goodTarget,
+			},
+			expectedResponse: http.StatusBadGateway,
+		},
+		{
+			name:       "retry count 2 returns error when no more retries left",
+			retryCount: 2,
+			retryFilters: []func(c echo.Context, e error) bool{
+				alwaysRetryFilter,
+				alwaysRetryFilter,
+			},
+			targets: []*ProxyTarget{
+				badTarget,
+				badTarget,
+				badTarget,
+				goodTarget, //Should never be reached as only 2 retries
+			},
+			expectedResponse: http.StatusBadGateway,
+		},
+		{
+			name:       "retry count 2 returns error when retries left but handler returns false",
+			retryCount: 3,
+			retryFilters: []func(c echo.Context, e error) bool{
+				alwaysRetryFilter,
+				alwaysRetryFilter,
+				neverRetryFilter,
+			},
+			targets: []*ProxyTarget{
+				badTarget,
+				badTarget,
+				badTarget,
+				goodTarget, //Should never be reached as retry handler returns false on 2nd check
+			},
+			expectedResponse: http.StatusBadGateway,
+		},
+		{
+			name:       "retry count 3 succeeds",
+			retryCount: 3,
+			retryFilters: []func(c echo.Context, e error) bool{
+				alwaysRetryFilter,
+				alwaysRetryFilter,
+				alwaysRetryFilter,
+			},
+			targets: []*ProxyTarget{
+				badTarget,
+				badTarget,
+				badTarget,
+				goodTarget,
+			},
+			expectedResponse: http.StatusOK,
+		},
+		{
+			name:       "40x responses are not retried",
+			retryCount: 1,
+			targets: []*ProxyTarget{
+				goodTargetWith40X,
+				goodTarget,
+			},
+			expectedResponse: http.StatusBadRequest,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+
+			retryFilterCall := 0
+			retryFilter := func(c echo.Context, e error) bool {
+				if len(tc.retryFilters) == 0 {
+					assert.FailNow(t, fmt.Sprintf("unexpected calls, %d, to retry handler", retryFilterCall))
+				}
+
+				retryFilterCall++
+
+				nextRetryFilter := tc.retryFilters[0]
+				tc.retryFilters = tc.retryFilters[1:]
+
+				return nextRetryFilter(c, e)
+			}
+
+			e := echo.New()
+			e.Use(ProxyWithConfig(
+				ProxyConfig{
+					Balancer:    NewRoundRobinBalancer(tc.targets),
+					RetryCount:  tc.retryCount,
+					RetryFilter: retryFilter,
+				},
+			))
+
+			req := httptest.NewRequest(http.MethodGet, "/", nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectedResponse, rec.Code)
+			if len(tc.retryFilters) > 0 {
+				assert.FailNow(t, fmt.Sprintf("expected %d more retry handler calls", len(tc.retryFilters)))
+			}
+		})
+	}
+}
+
+func TestProxyRetryWithBackendTimeout(t *testing.T) {
+
+	transport := http.DefaultTransport.(*http.Transport).Clone()
+	transport.ResponseHeaderTimeout = time.Millisecond * 500
+
+	timeoutBackend := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			time.Sleep(1 * time.Second)
+			w.WriteHeader(404)
+		}),
+	)
+	defer timeoutBackend.Close()
+
+	timeoutTargetURL, _ := url.Parse(timeoutBackend.URL)
+	goodBackend := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(200)
+		}),
+	)
+	defer goodBackend.Close()
+
+	goodTargetURL, _ := url.Parse(goodBackend.URL)
+	e := echo.New()
+	e.Use(ProxyWithConfig(
+		ProxyConfig{
+			Transport: transport,
+			Balancer: NewRoundRobinBalancer([]*ProxyTarget{
+				{
+					Name: "Timeout",
+					URL:  timeoutTargetURL,
+				},
+				{
+					Name: "Good",
+					URL:  goodTargetURL,
+				},
+			}),
+			RetryCount: 1,
+		},
+	))
+
+	var wg sync.WaitGroup
+	for i := 0; i < 20; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			req := httptest.NewRequest(http.MethodGet, "/", nil)
+			rec := httptest.NewRecorder()
+			e.ServeHTTP(rec, req)
+			assert.Equal(t, 200, rec.Code)
+		}()
+	}
+
+	wg.Wait()
+
+}
+
+func TestProxyErrorHandler(t *testing.T) {
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	goodURL, _ := url.Parse(server.URL)
+	defer server.Close()
+	goodTarget := &ProxyTarget{
+		Name: "Good",
+		URL:  goodURL,
+	}
+
+	badURL, _ := url.Parse("http://127.0.0.1:27121")
+	badTarget := &ProxyTarget{
+		Name: "Bad",
+		URL:  badURL,
+	}
+
+	transformedError := errors.New("a new error")
+
+	testCases := []struct {
+		name             string
+		target           *ProxyTarget
+		errorHandler     func(c echo.Context, e error) error
+		expectFinalError func(t *testing.T, err error)
+	}{
+		{
+			name:   "Error handler not invoked when request success",
+			target: goodTarget,
+			errorHandler: func(c echo.Context, e error) error {
+				assert.FailNow(t, "error handler should not be invoked")
+				return e
+			},
+		},
+		{
+			name:   "Error handler invoked when request fails",
+			target: badTarget,
+			errorHandler: func(c echo.Context, e error) error {
+				httpErr, ok := e.(*echo.HTTPError)
+				assert.True(t, ok, "expected http error to be passed to handler")
+				assert.Equal(t, http.StatusBadGateway, httpErr.Code, "expected http bad gateway error to be passed to handler")
+				return transformedError
+			},
+			expectFinalError: func(t *testing.T, err error) {
+				assert.Equal(t, transformedError, err, "transformed error not returned from proxy")
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := echo.New()
+			e.Use(ProxyWithConfig(
+				ProxyConfig{
+					Balancer:     NewRoundRobinBalancer([]*ProxyTarget{tc.target}),
+					ErrorHandler: tc.errorHandler,
+				},
+			))
+
+			errorHandlerCalled := false
+			e.HTTPErrorHandler = func(err error, c echo.Context) {
+				errorHandlerCalled = true
+				tc.expectFinalError(t, err)
+				e.DefaultHTTPErrorHandler(err, c)
+			}
+
+			req := httptest.NewRequest(http.MethodGet, "/", nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			if !errorHandlerCalled && tc.expectFinalError != nil {
+				t.Fatalf("error handler was not called")
+			}
+
+		})
+	}
+}
+
 func TestClientCancelConnectionResultsHTTPCode499(t *testing.T) {
 	var timeoutStop sync.WaitGroup
 	timeoutStop.Add(1)