Repository: labstack/echo
Pull request #2414 (2023-03-07 18:59:46)

Support retries of failed proxy requests
Implements #2372 

Support for retrying proxy requests that fail due to an unavailable backend instance. 

mikemherron: > I think we need to edit proxyRaw so all errors are actually set as error. c.Set("_error", fmt.Sprintf("proxy raw, hijack error=%v, url=%s", t.URL, err)) is not error

Good catch, I'll do this as part of this PR.

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/context.go b/context.go
index b3a7ce8..27da28a 100644
--- a/context.go
+++ b/context.go
@@ -100,8 +100,8 @@ type (
 		// Set saves data in the context.
 		Set(key string, val interface{})
 
-		// Bind binds the request body into provided type `i`. The default binder
-		// does it based on Content-Type header.
+		// Bind binds path params, query params and the request body into provided type `i`. The default binder
+		// binds body based on Content-Type header.
 		Bind(i interface{}) error
 
 		// Validate validates provided `i`. It is usually called after `Context#Bind()`.
diff --git a/echo.go b/echo.go
index 085a3a7..9028b7a 100644
--- a/echo.go
+++ b/echo.go
@@ -614,7 +614,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/cors.go b/middleware/cors.go
index 149de34..6ddb540 100644
--- a/middleware/cors.go
+++ b/middleware/cors.go
@@ -150,8 +150,8 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 	allowOriginPatterns := []string{}
 	for _, origin := range config.AllowOrigins {
 		pattern := regexp.QuoteMeta(origin)
-		pattern = strings.Replace(pattern, "\\*", ".*", -1)
-		pattern = strings.Replace(pattern, "\\?", ".", -1)
+		pattern = strings.ReplaceAll(pattern, "\\*", ".*")
+		pattern = strings.ReplaceAll(pattern, "\\?", ".")
 		pattern = "^" + pattern + "$"
 		allowOriginPatterns = append(allowOriginPatterns, pattern)
 	}
diff --git a/middleware/middleware.go b/middleware/middleware.go
index f250ca4..664f71f 100644
--- a/middleware/middleware.go
+++ b/middleware/middleware.go
@@ -38,9 +38,9 @@ func rewriteRulesRegex(rewrite map[string]string) map[*regexp.Regexp]string {
 	rulesRegex := map[*regexp.Regexp]string{}
 	for k, v := range rewrite {
 		k = regexp.QuoteMeta(k)
-		k = strings.Replace(k, `\*`, "(.*?)", -1)
+		k = strings.ReplaceAll(k, `\*`, "(.*?)")
 		if strings.HasPrefix(k, `\^`) {
-			k = strings.Replace(k, `\^`, "^", -1)
+			k = strings.ReplaceAll(k, `\^`, "^")
 		}
 		k = k + "$"
 		rulesRegex[regexp.MustCompile(k)] = v
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/middleware/recover.go b/middleware/recover.go
index 36d41aa..0466cfe 100644
--- a/middleware/recover.go
+++ b/middleware/recover.go
@@ -37,6 +37,7 @@ type (
 
 		// LogErrorFunc defines a function for custom logging in the middleware.
 		// If it's set you don't need to provide LogLevel for config.
+		// If this function returns nil, the centralized HTTPErrorHandler will not be called.
 		LogErrorFunc LogErrorFunc
 
 		// DisableErrorHandler disables the call to centralized HTTPErrorHandler.
@@ -49,12 +50,12 @@ type (
 var (
 	// DefaultRecoverConfig is the default Recover middleware config.
 	DefaultRecoverConfig = RecoverConfig{
-		Skipper:           DefaultSkipper,
-		StackSize:         4 << 10, // 4 KB
-		DisableStackAll:   false,
-		DisablePrintStack: false,
-		LogLevel:          0,
-		LogErrorFunc:      nil,
+		Skipper:             DefaultSkipper,
+		StackSize:           4 << 10, // 4 KB
+		DisableStackAll:     false,
+		DisablePrintStack:   false,
+		LogLevel:            0,
+		LogErrorFunc:        nil,
 		DisableErrorHandler: false,
 	}
 )
@@ -120,7 +121,7 @@ func RecoverWithConfig(config RecoverConfig) echo.MiddlewareFunc {
 						}
 					}
 
-					if(!config.DisableErrorHandler) {
+					if err != nil && !config.DisableErrorHandler {
 						c.Error(err)
 					} else {
 						returnErr = err
diff --git a/middleware/request_logger.go b/middleware/request_logger.go
index b9e3692..8e312e8 100644
--- a/middleware/request_logger.go
+++ b/middleware/request_logger.go
@@ -257,7 +257,7 @@ func (config RequestLoggerConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
 				config.BeforeNextFunc(c)
 			}
 			err := next(c)
-			if config.HandleError {
+			if err != nil && config.HandleError {
 				c.Error(err)
 			}
 
diff --git a/response.go b/response.go
index 84f7c9e..d9c9aa6 100644
--- a/response.go
+++ b/response.go
@@ -94,6 +94,13 @@ func (r *Response) Hijack() (net.Conn, *bufio.ReadWriter, error) {
 	return r.Writer.(http.Hijacker).Hijack()
 }
 
+// Unwrap returns the original http.ResponseWriter.
+// ResponseController can be used to access the original http.ResponseWriter.
+// See [https://go.dev/blog/go1.20]
+func (r *Response) Unwrap() http.ResponseWriter {
+	return r.Writer
+}
+
 func (r *Response) reset(w http.ResponseWriter) {
 	r.beforeFuncs = nil
 	r.afterFuncs = nil
diff --git a/router.go b/router.go
index 597660d..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)
@@ -159,7 +159,12 @@ func (r *Router) Reverse(name string, params ...interface{}) string {
 	for _, route := range r.routes {
 		if route.Name == name {
 			for i, l := 0, len(route.Path); i < l; i++ {
-				if (route.Path[i] == ':' || route.Path[i] == '*') && n < ln {
+				hasBackslash := route.Path[i] == '\\'
+				if hasBackslash && i+1 < l && route.Path[i+1] == ':' {
+					i++ // backslash before colon escapes that colon. in that case skip backslash
+				}
+				if n < ln && (route.Path[i] == '*' || (!hasBackslash && route.Path[i] == ':')) {
+					// in case of `*` wildcard or `:` (unescaped colon) param we replace everything till next slash or end of path
 					for ; i < l && route.Path[i] != '/'; i++ {
 					}
 					uri.WriteString(fmt.Sprintf("%v", params[n]))


Test patch:
diff --git a/echo_test.go b/echo_test.go
index 2f66c8c..eab25db 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -1517,26 +1517,97 @@ func TestEcho_OnAddRouteHandler(t *testing.T) {
 }
 
 func TestEchoReverse(t *testing.T) {
-	e := New()
-	dummyHandler := func(Context) error { return nil }
+	var testCases = []struct {
+		name          string
+		whenRouteName string
+		whenParams    []interface{}
+		expect        string
+	}{
+		{
+			name:          "ok,static with no params",
+			whenRouteName: "/static",
+			expect:        "/static",
+		},
+		{
+			name:          "ok,static with non existent param",
+			whenRouteName: "/static",
+			whenParams:    []interface{}{"missing param"},
+			expect:        "/static",
+		},
+		{
+			name:          "ok, wildcard with no params",
+			whenRouteName: "/static/*",
+			expect:        "/static/*",
+		},
+		{
+			name:          "ok, wildcard with params",
+			whenRouteName: "/static/*",
+			whenParams:    []interface{}{"foo.txt"},
+			expect:        "/static/foo.txt",
+		},
+		{
+			name:          "ok, single param without param",
+			whenRouteName: "/params/:foo",
+			expect:        "/params/:foo",
+		},
+		{
+			name:          "ok, single param with param",
+			whenRouteName: "/params/:foo",
+			whenParams:    []interface{}{"one"},
+			expect:        "/params/one",
+		},
+		{
+			name:          "ok, multi param without params",
+			whenRouteName: "/params/:foo/bar/:qux",
+			expect:        "/params/:foo/bar/:qux",
+		},
+		{
+			name:          "ok, multi param with one param",
+			whenRouteName: "/params/:foo/bar/:qux",
+			whenParams:    []interface{}{"one"},
+			expect:        "/params/one/bar/:qux",
+		},
+		{
+			name:          "ok, multi param with all params",
+			whenRouteName: "/params/:foo/bar/:qux",
+			whenParams:    []interface{}{"one", "two"},
+			expect:        "/params/one/bar/two",
+		},
+		{
+			name:          "ok, multi param + wildcard with all params",
+			whenRouteName: "/params/:foo/bar/:qux/*",
+			whenParams:    []interface{}{"one", "two", "three"},
+			expect:        "/params/one/bar/two/three",
+		},
+		{
+			name:          "ok, backslash is not escaped",
+			whenRouteName: "/backslash",
+			whenParams:    []interface{}{"test"},
+			expect:        `/a\b/test`,
+		},
+		{
+			name:          "ok, escaped colon verbs",
+			whenRouteName: "/params:customVerb",
+			whenParams:    []interface{}{"PATCH"},
+			expect:        `/params:PATCH`,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			dummyHandler := func(Context) error { return nil }
+
+			e.GET("/static", dummyHandler).Name = "/static"
+			e.GET("/static/*", dummyHandler).Name = "/static/*"
+			e.GET("/params/:foo", dummyHandler).Name = "/params/:foo"
+			e.GET("/params/:foo/bar/:qux", dummyHandler).Name = "/params/:foo/bar/:qux"
+			e.GET("/params/:foo/bar/:qux/*", dummyHandler).Name = "/params/:foo/bar/:qux/*"
+			e.GET("/a\\b/:x", dummyHandler).Name = "/backslash"
+			e.GET("/params\\::customVerb", dummyHandler).Name = "/params:customVerb"
 
-	e.GET("/static", dummyHandler).Name = "/static"
-	e.GET("/static/*", dummyHandler).Name = "/static/*"
-	e.GET("/params/:foo", dummyHandler).Name = "/params/:foo"
-	e.GET("/params/:foo/bar/:qux", dummyHandler).Name = "/params/:foo/bar/:qux"
-	e.GET("/params/:foo/bar/:qux/*", dummyHandler).Name = "/params/:foo/bar/:qux/*"
-
-	assert.Equal(t, "/static", e.Reverse("/static"))
-	assert.Equal(t, "/static", e.Reverse("/static", "missing param"))
-	assert.Equal(t, "/static/*", e.Reverse("/static/*"))
-	assert.Equal(t, "/static/foo.txt", e.Reverse("/static/*", "foo.txt"))
-
-	assert.Equal(t, "/params/:foo", e.Reverse("/params/:foo"))
-	assert.Equal(t, "/params/one", e.Reverse("/params/:foo", "one"))
-	assert.Equal(t, "/params/:foo/bar/:qux", e.Reverse("/params/:foo/bar/:qux"))
-	assert.Equal(t, "/params/one/bar/:qux", e.Reverse("/params/:foo/bar/:qux", "one"))
-	assert.Equal(t, "/params/one/bar/two", e.Reverse("/params/:foo/bar/:qux", "one", "two"))
-	assert.Equal(t, "/params/one/bar/two/three", e.Reverse("/params/:foo/bar/:qux/*", "one", "two", "three"))
+			assert.Equal(t, tc.expect, e.Reverse(tc.whenRouteName, tc.whenParams...))
+		})
+	}
 }
 
 func TestEchoReverseHandleHostProperly(t *testing.T) {
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)
diff --git a/response_test.go b/response_test.go
index d95e079..e4fd636 100644
--- a/response_test.go
+++ b/response_test.go
@@ -72,3 +72,11 @@ func TestResponse_ChangeStatusCodeBeforeWrite(t *testing.T) {
 
 	assert.Equal(t, http.StatusOK, rec.Code)
 }
+
+func TestResponse_Unwrap(t *testing.T) {
+	e := New()
+	rec := httptest.NewRecorder()
+	res := &Response{echo: e, Writer: rec}
+
+	assert.Equal(t, rec, res.Unwrap())
+}