Repository: labstack/echo
Pull request #2217 (2022-07-10 20:18:27)

Add support for registering handlers for different 404 routes
This PR adds support for registering handlers for 404 routes.  Echo instance and group has now method 
`RouteNotFound(path string, h HandlerFunc, m ...MiddlewareFunc) *Route` 
which registers handler for given path. Path supports any/path parameters and can be static (which is little bit silly but we still support it)

Custom 404 handler has priority in router over global 404 handler and 405 handling logic.

Example:
```go
e := echo.New()

e.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })

g := e.Group("/images")
g.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })
// or
g.Add(echo.RouteNotFound, "/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })
```
so you would have 2 404handlers in this example:
* `/*`
* `/images/*`

----
Routing performance is not significantly affected by this feature. 

Benchstat between current master branch and this branch. 
```
x@x:~/code/echo$ benchstat benchmark_master2.txt benchmark_new2.txt 
name                                    old time/op    new time/op    delta
pkg:github.com/labstack/echo/v4 goos:linux goarch:amd64
BindbindDataWithTags-6                    11.1µs ± 1%    11.1µs ± 0%    ~     (p=0.130 n=8+8)
DefaultBinder_BindInt64_single-6           244ns ± 1%     243ns ± 1%    ~     (p=0.664 n=8+8)
ValueBinder_BindInt64_single-6            24.0ns ± 1%    23.8ns ± 1%    ~     (p=0.067 n=7+8)
RawFunc_Int64_single-6                    12.9ns ± 1%    13.1ns ± 1%  +1.29%  (p=0.001 n=7+7)
DefaultBinder_BindInt64_10_fields-6       2.30µs ± 1%    2.29µs ± 1%    ~     (p=0.168 n=8+8)
ValueBinder_BindInt64_10_fields-6          272ns ± 2%     275ns ± 1%  +1.32%  (p=0.021 n=8+8)
AllocJSONP-6                               417ns ± 2%     397ns ± 1%  -4.80%  (p=0.000 n=8+8)
AllocJSON-6                                280ns ± 1%     266ns ± 1%  -5.07%  (p=0.000 n=8+8)
AllocXML-6                                1.69µs ± 2%    1.72µs ± 2%  +1.75%  (p=0.007 n=8+7)
RealIPForHeaderXForwardFor-6              32.9ns ± 1%    32.6ns ± 1%  -0.93%  (p=0.043 n=8+7)
Context_Store-6                           54.8ns ± 1%    55.0ns ± 2%    ~     (p=0.895 n=8+8)
EchoStaticRoutes-6                        18.0µs ± 2%    17.1µs ± 2%  -4.70%  (p=0.000 n=8+8)
EchoStaticRoutesMisses-6                  17.9µs ± 2%    17.3µs ± 2%  -2.91%  (p=0.000 n=8+8)
EchoGitHubAPI-6                           31.7µs ± 1%    31.8µs ± 1%    ~     (p=0.442 n=8+8)
EchoGitHubAPIMisses-6                     31.5µs ± 1%    31.8µs ± 2%  +0.98%  (p=0.014 n=7+8)
EchoParseAPI-6                            2.04µs ± 1%    2.07µs ± 2%  +1.23%  (p=0.019 n=8+8)
RouterStaticRoutes-6                      14.2µs ± 1%    13.7µs ± 1%  -3.20%  (p=0.000 n=8+8)
RouterStaticRoutesMisses-6                 476ns ± 1%     479ns ± 1%    ~     (p=0.100 n=7+8)
RouterGitHubAPI-6                         24.6µs ± 1%    24.3µs ± 1%  -1.13%  (p=0.000 n=8+7)
RouterGitHubAPIMisses-6                    587ns ± 1%     584ns ± 2%    ~     (p=0.105 n=8+8)
RouterParseAPI-6                          1.29µs ± 1%    1.25µs ± 1%  -3.17%  (p=0.000 n=8+8)
RouterParseAPIMisses-6                     313ns ± 1%     315ns ± 1%    ~     (p=0.099 n=8+8)
RouterGooglePlusAPI-6                      823ns ± 1%     817ns ± 0%  -0.70%  (p=0.005 n=8+6)
RouterGooglePlusAPIMisses-6                470ns ± 1%     470ns ± 1%    ~     (p=0.702 n=8+8)
RouterParamsAndAnyAPI-6                   2.02µs ± 1%    2.01µs ± 1%  -0.85%  (p=0.007 n=8+8)
pkg:github.com/labstack/echo/v4/middleware goos:linux goarch:amd64
Gzip-6                                    22.9µs ± 1%    22.9µs ± 0%    ~     (p=0.613 n=8+7)
Decompress-6                              1.08µs ± 2%    1.14µs ± 6%  +5.18%  (p=0.014 n=8+8)
LoggerWithConfig_withoutMapFields-6       3.02µs ±15%    3.09µs ± 6%    ~     (p=0.755 n=8+6)
LoggerWithConfig_withMapFields-6          3.92µs ±26%    3.72µs ± 7%    ~     (p=0.505 n=8+8)
RateLimiterMemoryStore_1000-6             2.37µs ± 3%    2.36µs ± 0%    ~     (p=0.143 n=8+7)
RateLimiterMemoryStore_10000-6            2.45µs ± 1%    2.49µs ± 1%  +1.41%  (p=0.001 n=8+8)
RateLimiterMemoryStore_100000-6           4.23µs ± 4%    4.36µs ± 1%  +3.04%  (p=0.000 n=8+8)
RateLimiterMemoryStore_conc100_10000-6    29.2µs ± 3%    29.4µs ± 2%    ~     (p=0.645 n=8+8)
RequestLogger_withoutMapFields-6          1.43µs ±16%    1.55µs ±15%    ~     (p=0.130 n=8+8)
RequestLogger_withMapFields-6             2.81µs ±24%    2.97µs ±13%    ~     (p=0.161 n=8+8)

name                                    old alloc/op   new alloc/op   delta
pkg:github.com/labstack/echo/v4 goos:linux goarch:amd64
BindbindDataWithTags-6                    1.05kB ± 0%    1.05kB ± 0%    ~     (all equal)
DefaultBinder_BindInt64_single-6           16.0B ± 0%     16.0B ± 0%    ~     (all equal)
ValueBinder_BindInt64_single-6             0.00B          0.00B         ~     (all equal)
RawFunc_Int64_single-6                     0.00B          0.00B         ~     (all equal)
DefaultBinder_BindInt64_10_fields-6         216B ± 0%      216B ± 0%    ~     (all equal)
ValueBinder_BindInt64_10_fields-6          0.00B          0.00B         ~     (all equal)
AllocJSONP-6                                182B ± 2%      177B ± 2%  -2.75%  (p=0.002 n=8+8)
AllocJSON-6                                 114B ± 1%      111B ± 1%  -2.82%  (p=0.001 n=6+8)
AllocXML-6                                4.75kB ± 2%    4.82kB ± 2%  +1.49%  (p=0.013 n=8+8)
EchoStaticRoutes-6                         0.00B          0.00B         ~     (all equal)
EchoStaticRoutesMisses-6                   0.00B          0.00B         ~     (all equal)
EchoGitHubAPI-6                            0.00B          0.00B         ~     (all equal)
EchoGitHubAPIMisses-6                      0.00B          0.00B         ~     (all equal)
EchoParseAPI-6                             0.00B          0.00B         ~     (all equal)
RouterStaticRoutes-6                       0.00B          0.00B         ~     (all equal)
RouterStaticRoutesMisses-6                 0.00B          0.00B         ~     (all equal)
RouterGitHubAPI-6                          0.00B          0.00B         ~     (all equal)
RouterGitHubAPIMisses-6                    0.00B          0.00B         ~     (all equal)
RouterParseAPI-6                           0.00B          0.00B         ~     (all equal)
RouterParseAPIMisses-6                     0.00B          0.00B         ~     (all equal)
RouterGooglePlusAPI-6                      0.00B          0.00B         ~     (all equal)
RouterGooglePlusAPIMisses-6                0.00B          0.00B         ~     (all equal)
RouterParamsAndAnyAPI-6                    0.00B          0.00B         ~     (all equal)
pkg:github.com/labstack/echo/v4/middleware goos:linux goarch:amd64
Gzip-6                                    1.53kB ± 5%    1.53kB ± 3%    ~     (p=0.901 n=8+8)
Decompress-6                              4.66kB ± 0%    4.66kB ± 0%    ~     (all equal)
LoggerWithConfig_withoutMapFields-6       1.42kB ± 0%    1.42kB ± 0%    ~     (all equal)
LoggerWithConfig_withMapFields-6          1.89kB ± 0%    1.89kB ± 0%    ~     (all equal)
RequestLogger_withoutMapFields-6          1.34kB ± 0%    1.34kB ± 0%    ~     (all equal)
RequestLogger_withMapFields-6             3.00kB ± 0%    3.00kB ± 0%    ~     (all equal)

name                                    old allocs/op  new allocs/op  delta
pkg:github.com/labstack/echo/v4 goos:linux goarch:amd64
BindbindDataWithTags-6                      51.0 ± 0%      51.0 ± 0%    ~     (all equal)
DefaultBinder_BindInt64_single-6            2.00 ± 0%      2.00 ± 0%    ~     (all equal)
ValueBinder_BindInt64_single-6              0.00           0.00         ~     (all equal)
RawFunc_Int64_single-6                      0.00           0.00         ~     (all equal)
DefaultBinder_BindInt64_10_fields-6         13.0 ± 0%      13.0 ± 0%    ~     (all equal)
ValueBinder_BindInt64_10_fields-6           0.00           0.00         ~     (all equal)
AllocJSONP-6                                4.00 ± 0%      4.00 ± 0%    ~     (all equal)
AllocJSON-6                                 1.00 ± 0%      1.00 ± 0%    ~     (all equal)
AllocXML-6                                  10.0 ± 0%      10.0 ± 0%    ~     (all equal)
EchoStaticRoutes-6                          0.00           0.00         ~     (all equal)
EchoStaticRoutesMisses-6                    0.00           0.00         ~     (all equal)
EchoGitHubAPI-6                             0.00           0.00         ~     (all equal)
EchoGitHubAPIMisses-6                       0.00           0.00         ~     (all equal)
EchoParseAPI-6                              0.00           0.00         ~     (all equal)
RouterStaticRoutes-6                        0.00           0.00         ~     (all equal)
RouterStaticRoutesMisses-6                  0.00           0.00         ~     (all equal)
RouterGitHubAPI-6                           0.00           0.00         ~     (all equal)
RouterGitHubAPIMisses-6                     0.00           0.00         ~     (all equal)
RouterParseAPI-6                            0.00           0.00         ~     (all equal)
RouterParseAPIMisses-6                      0.00           0.00         ~     (all equal)
RouterGooglePlusAPI-6                       0.00           0.00         ~     (all equal)
RouterGooglePlusAPIMisses-6                 0.00           0.00         ~     (all equal)
RouterParamsAndAnyAPI-6                     0.00           0.00         ~     (all equal)
pkg:github.com/labstack/echo/v4/middleware goos:linux goarch:amd64
Gzip-6                                      16.0 ± 0%      16.0 ± 0%    ~     (all equal)
Decompress-6                                8.00 ± 0%      8.00 ± 0%    ~     (all equal)
LoggerWithConfig_withoutMapFields-6         21.0 ± 0%      21.0 ± 0%    ~     (all equal)
LoggerWithConfig_withMapFields-6            26.0 ± 0%      26.0 ± 0%    ~     (all equal)
RequestLogger_withoutMapFields-6            14.0 ± 0%      14.0 ± 0%    ~     (all equal)
RequestLogger_withMapFields-6               25.0 ± 0%      25.0 ± 0%    ~     (all equal)
```

codecov[bot]: # [Codecov](https://codecov.io/gh/labstack/echo/pull/2217?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) Report
> Merging [#2217](https://codecov.io/gh/labstack/echo/pull/2217?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) (f33b7a6) into [master](https://codecov.io/gh/labstack/echo/commit/ddb66e1ba272fe7580dae6f8543763bc4a760fbd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) (ddb66e1) will **increase** coverage by `0.02%`.
> The diff coverage is `95.83%`.

```diff
@@            Coverage Diff             @@
##           master    #2217      +/-   ##
==========================================
+ Coverage   92.27%   92.30%   +0.02%     
==========================================
  Files          37       37              
  Lines        3094     3104      +10     
==========================================
+ Hits         2855     2865      +10     
  Misses        150      150              
  Partials       89       89              
```


| [Impacted Files](https://codecov.io/gh/labstack/echo/pull/2217?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) | Coverage Δ | |
|---|---|---|
| [router.go](https://codecov.io/gh/labstack/echo/pull/2217/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-cm91dGVyLmdv) | `96.58% <95.45%> (+0.07%)` | :arrow_up: |
| [echo.go](https://codecov.io/gh/labstack/echo/pull/2217/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-ZWNoby5nbw==) | `95.19% <100.00%> (+0.01%)` | :arrow_up: |
| [group.go](https://codecov.io/gh/labstack/echo/pull/2217/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-Z3JvdXAuZ28=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/labstack/echo/pull/2217?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/labstack/echo/pull/2217?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Last update [ddb66e1...f33b7a6](https://codecov.io/gh/labstack/echo/pull/2217?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).

aldas: @lammel could you review this?

Patch:
diff --git a/echo.go b/echo.go
index 8829619..5b10d58 100644
--- a/echo.go
+++ b/echo.go
@@ -183,6 +183,8 @@ const (
 	PROPFIND = "PROPFIND"
 	// REPORT Method can be used to get information about a resource, see rfc 3253
 	REPORT = "REPORT"
+	// RouteNotFound is special method type for routes handling "route not found" (404) cases
+	RouteNotFound = "echo_route_not_found"
 )
 
 // Headers
@@ -480,6 +482,16 @@ func (e *Echo) TRACE(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
 	return e.Add(http.MethodTrace, path, h, m...)
 }
 
+// RouteNotFound registers a special-case route which is executed when no other route is found (i.e. HTTP 404 cases)
+// for current request URL.
+// Path supports static and named/any parameters just like other http method is defined. Generally path is ended with
+// wildcard/match-any character (`/*`, `/download/*` etc).
+//
+// Example: `e.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })`
+func (e *Echo) RouteNotFound(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
+	return e.Add(RouteNotFound, path, h, m...)
+}
+
 // Any registers a new route for all HTTP methods and path with matching handler
 // in the router with optional route-level middleware.
 func (e *Echo) Any(path string, handler HandlerFunc, middleware ...MiddlewareFunc) []*Route {
@@ -515,6 +527,7 @@ func (e *Echo) File(path, file string, m ...MiddlewareFunc) *Route {
 func (e *Echo) add(host, method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
 	name := handlerName(handler)
 	router := e.findRouter(host)
+	// FIXME: when handler+middleware are both nil ... make it behave like handler removal
 	router.Add(method, path, func(c Context) error {
 		h := applyMiddleware(handler, middleware...)
 		return h(c)
diff --git a/group.go b/group.go
index bba470c..28ce0dd 100644
--- a/group.go
+++ b/group.go
@@ -107,6 +107,13 @@ func (g *Group) File(path, file string) {
 	g.file(path, file, g.GET)
 }
 
+// RouteNotFound implements `Echo#RouteNotFound()` for sub-routes within the Group.
+//
+// Example: `g.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })`
+func (g *Group) RouteNotFound(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
+	return g.Add(RouteNotFound, path, h, m...)
+}
+
 // Add implements `Echo#Add()` for sub-routes within the Group.
 func (g *Group) Add(method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
 	// Combine into a new slice to avoid accidentally passing the same slice for
diff --git a/router.go b/router.go
index 6a8615d..74bc765 100644
--- a/router.go
+++ b/router.go
@@ -28,6 +28,9 @@ type (
 		isLeaf bool
 		// isHandler indicates that node has at least one handler registered to it
 		isHandler bool
+
+		// notFoundHandler is handler registered with RouteNotFound method and is executed for 404 cases
+		notFoundHandler *routeMethod
 	}
 	kind        uint8
 	children    []*node
@@ -73,6 +76,7 @@ func (m *routeMethods) isHandler() bool {
 		m.put != nil ||
 		m.trace != nil ||
 		m.report != nil
+	// RouteNotFound/404 is not considered as a handler
 }
 
 func (m *routeMethods) updateAllowHeader() {
@@ -382,6 +386,9 @@ func (n *node) addMethod(method string, h *routeMethod) {
 		n.methods.trace = h
 	case REPORT:
 		n.methods.report = h
+	case RouteNotFound:
+		n.notFoundHandler = h
+		return // RouteNotFound/404 is not considered as a handler so no further logic needs to be executed
 	}
 
 	n.methods.updateAllowHeader()
@@ -412,7 +419,7 @@ func (n *node) findMethod(method string) *routeMethod {
 		return n.methods.trace
 	case REPORT:
 		return n.methods.report
-	default:
+	default: // RouteNotFound/404 is not considered as a handler
 		return nil
 	}
 }
@@ -515,7 +522,7 @@ func (r *Router) Find(method, path string, c Context) {
 			// No matching prefix, let's backtrack to the first possible alternative node of the decision path
 			nk, ok := backtrackToNextNodeKind(staticKind)
 			if !ok {
-				return // No other possibilities on the decision path
+				return // No other possibilities on the decision path, handler will be whatever context is reset to.
 			} else if nk == paramKind {
 				goto Param
 				// NOTE: this case (backtracking from static node to previous any node) can not happen by current any matching logic. Any node is end of search currently
@@ -531,15 +538,21 @@ func (r *Router) Find(method, path string, c Context) {
 		search = search[lcpLen:]
 		searchIndex = searchIndex + lcpLen
 
-		// Finish routing if no remaining search and we are on a node with handler and matching method type
-		if search == "" && currentNode.isHandler {
-			// check if current node has handler registered for http method we are looking for. we store currentNode as
-			// best matching in case we do no find no more routes matching this path+method
-			if previousBestMatchNode == nil {
-				previousBestMatchNode = currentNode
-			}
-			if h := currentNode.findMethod(method); h != nil {
-				matchedRouteMethod = h
+		// Finish routing if is no request path remaining to search
+		if search == "" {
+			// in case of node that is handler we have exact method type match or something for 405 to use
+			if currentNode.isHandler {
+				// check if current node has handler registered for http method we are looking for. we store currentNode as
+				// best matching in case we do no find no more routes matching this path+method
+				if previousBestMatchNode == nil {
+					previousBestMatchNode = currentNode
+				}
+				if h := currentNode.findMethod(method); h != nil {
+					matchedRouteMethod = h
+					break
+				}
+			} else if currentNode.notFoundHandler != nil {
+				matchedRouteMethod = currentNode.notFoundHandler
 				break
 			}
 		}
@@ -559,7 +572,8 @@ func (r *Router) Find(method, path string, c Context) {
 			i := 0
 			l := len(search)
 			if currentNode.isLeaf {
-				// when param node does not have any children then param node should act similarly to any node - consider all remaining search as match
+				// when param node does not have any children (path param is last piece of route path) then param node should
+				// act similarly to any node - consider all remaining search as match
 				i = l
 			} else {
 				for ; i < l && search[i] != '/'; i++ {
@@ -585,13 +599,16 @@ func (r *Router) Find(method, path string, c Context) {
 			searchIndex += +len(search)
 			search = ""
 
-			// check if current node has handler registered for http method we are looking for. we store currentNode as
-			// best matching in case we do no find no more routes matching this path+method
+			if h := currentNode.findMethod(method); h != nil {
+				matchedRouteMethod = h
+				break
+			}
+			// we store currentNode as best matching in case we do not find more routes matching this path+method. Needed for 405
 			if previousBestMatchNode == nil {
 				previousBestMatchNode = currentNode
 			}
-			if h := currentNode.findMethod(method); h != nil {
-				matchedRouteMethod = h
+			if currentNode.notFoundHandler != nil {
+				matchedRouteMethod = currentNode.notFoundHandler
 				break
 			}
 		}
@@ -614,12 +631,14 @@ func (r *Router) Find(method, path string, c Context) {
 		return // nothing matched at all
 	}
 
+	// matchedHandler could be method+path handler that we matched or notFoundHandler from node with matching path
+	// user provided not found (404) handler has priority over generic method not found (405) handler or global 404 handler
 	var rPath string
 	var rPNames []string
 	if matchedRouteMethod != nil {
-		ctx.handler = matchedRouteMethod.handler
 		rPath = matchedRouteMethod.ppath
 		rPNames = matchedRouteMethod.pnames
+		ctx.handler = matchedRouteMethod.handler
 	} else {
 		// use previous match as basis. although we have no matching handler we have path match.
 		// so we can send http.StatusMethodNotAllowed (405) instead of http.StatusNotFound (404)
@@ -628,7 +647,11 @@ func (r *Router) Find(method, path string, c Context) {
 		rPath = currentNode.originalPath
 		rPNames = nil // no params here
 		ctx.handler = NotFoundHandler
-		if currentNode.isHandler {
+		if currentNode.notFoundHandler != nil {
+			rPath = currentNode.notFoundHandler.ppath
+			rPNames = currentNode.notFoundHandler.pnames
+			ctx.handler = currentNode.notFoundHandler.handler
+		} else if currentNode.isHandler {
 			ctx.Set(ContextKeyHeaderAllow, currentNode.methods.allowHeader)
 			ctx.handler = MethodNotAllowedHandler
 			if method == http.MethodOptions {


Test patch:
diff --git a/echo_test.go b/echo_test.go
index 0e1e42b..64796b3 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -766,6 +766,70 @@ func TestEchoNotFound(t *testing.T) {
 	assert.Equal(t, http.StatusNotFound, rec.Code)
 }
 
+func TestEcho_RouteNotFound(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectCode  int
+	}{
+		{
+			name:        "404, route to static not found handler /a/c/xx",
+			whenURL:     "/a/c/xx",
+			expectRoute: "GET /a/c/xx",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to path param not found handler /a/:file",
+			whenURL:     "/a/echo.exe",
+			expectRoute: "GET /a/:file",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to any not found handler /*",
+			whenURL:     "/b/echo.exe",
+			expectRoute: "GET /*",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "200, route /a/c/df to /a/c/df",
+			whenURL:     "/a/c/df",
+			expectRoute: "GET /a/c/df",
+			expectCode:  http.StatusOK,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+
+			okHandler := func(c Context) error {
+				return c.String(http.StatusOK, c.Request().Method+" "+c.Path())
+			}
+			notFoundHandler := func(c Context) error {
+				return c.String(http.StatusNotFound, c.Request().Method+" "+c.Path())
+			}
+
+			e.GET("/", okHandler)
+			e.GET("/a/c/df", okHandler)
+			e.GET("/a/b*", okHandler)
+			e.PUT("/*", okHandler)
+
+			e.RouteNotFound("/a/c/xx", notFoundHandler)  // static
+			e.RouteNotFound("/a/:file", notFoundHandler) // param
+			e.RouteNotFound("/*", notFoundHandler)       // any
+
+			req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectCode, rec.Code)
+			assert.Equal(t, tc.expectRoute, rec.Body.String())
+		})
+	}
+}
+
 func TestEchoMethodNotAllowed(t *testing.T) {
 	e := New()
 
diff --git a/group_test.go b/group_test.go
index c51fd91..24f1916 100644
--- a/group_test.go
+++ b/group_test.go
@@ -119,3 +119,68 @@ func TestGroupRouteMiddlewareWithMatchAny(t *testing.T) {
 	assert.Equal(t, "/*", m)
 
 }
+
+func TestGroup_RouteNotFound(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectCode  int
+	}{
+		{
+			name:        "404, route to static not found handler /group/a/c/xx",
+			whenURL:     "/group/a/c/xx",
+			expectRoute: "GET /group/a/c/xx",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to path param not found handler /group/a/:file",
+			whenURL:     "/group/a/echo.exe",
+			expectRoute: "GET /group/a/:file",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to any not found handler /group/*",
+			whenURL:     "/group/b/echo.exe",
+			expectRoute: "GET /group/*",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "200, route /group/a/c/df to /group/a/c/df",
+			whenURL:     "/group/a/c/df",
+			expectRoute: "GET /group/a/c/df",
+			expectCode:  http.StatusOK,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			g := e.Group("/group")
+
+			okHandler := func(c Context) error {
+				return c.String(http.StatusOK, c.Request().Method+" "+c.Path())
+			}
+			notFoundHandler := func(c Context) error {
+				return c.String(http.StatusNotFound, c.Request().Method+" "+c.Path())
+			}
+
+			g.GET("/", okHandler)
+			g.GET("/a/c/df", okHandler)
+			g.GET("/a/b*", okHandler)
+			g.PUT("/*", okHandler)
+
+			g.RouteNotFound("/a/c/xx", notFoundHandler)  // static
+			g.RouteNotFound("/a/:file", notFoundHandler) // param
+			g.RouteNotFound("/*", notFoundHandler)       // any
+
+			req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectCode, rec.Code)
+			assert.Equal(t, tc.expectRoute, rec.Body.String())
+		})
+	}
+}
diff --git a/router_test.go b/router_test.go
index 8645a26..34f325d 100644
--- a/router_test.go
+++ b/router_test.go
@@ -1101,6 +1101,191 @@ func TestRouterBacktrackingFromMultipleParamKinds(t *testing.T) {
 	}
 }
 
+func TestNotFoundRouteAnyKind(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectID    int
+		expectParam map[string]string
+	}{
+		{
+			name:        "route not existent /xx to not found handler /*",
+			whenURL:     "/xx",
+			expectRoute: "/*",
+			expectID:    4,
+			expectParam: map[string]string{"*": "xx"},
+		},
+		{
+			name:        "route not existent /a/xx to not found handler /a/*",
+			whenURL:     "/a/xx",
+			expectRoute: "/a/*",
+			expectID:    5,
+			expectParam: map[string]string{"*": "xx"},
+		},
+		{
+			name:        "route not existent /a/c/dxxx to not found handler /a/c/d*",
+			whenURL:     "/a/c/dxxx",
+			expectRoute: "/a/c/d*",
+			expectID:    6,
+			expectParam: map[string]string{"*": "xxx"},
+		},
+		{
+			name:        "route /a/c/df to /a/c/df",
+			whenURL:     "/a/c/df",
+			expectRoute: "/a/c/df",
+			expectID:    1,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			r := e.router
+
+			r.Add(http.MethodGet, "/", handlerHelper("ID", 0))
+			r.Add(http.MethodGet, "/a/c/df", handlerHelper("ID", 1))
+			r.Add(http.MethodGet, "/a/b*", handlerHelper("ID", 2))
+			r.Add(http.MethodPut, "/*", handlerHelper("ID", 3))
+
+			r.Add(RouteNotFound, "/a/c/d*", handlerHelper("ID", 6))
+			r.Add(RouteNotFound, "/a/*", handlerHelper("ID", 5))
+			r.Add(RouteNotFound, "/*", handlerHelper("ID", 4))
+
+			c := e.NewContext(nil, nil).(*context)
+			r.Find(http.MethodGet, tc.whenURL, c)
+
+			c.handler(c)
+
+			testValue, _ := c.Get("ID").(int)
+			assert.Equal(t, tc.expectID, testValue)
+			assert.Equal(t, tc.expectRoute, c.Path())
+			for param, expectedValue := range tc.expectParam {
+				assert.Equal(t, expectedValue, c.Param(param))
+			}
+			checkUnusedParamValues(t, c, tc.expectParam)
+		})
+	}
+}
+
+func TestNotFoundRouteParamKind(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectID    int
+		expectParam map[string]string
+	}{
+		{
+			name:        "route not existent /xx to not found handler /:file",
+			whenURL:     "/xx",
+			expectRoute: "/:file",
+			expectID:    4,
+			expectParam: map[string]string{"file": "xx"},
+		},
+		{
+			name:        "route not existent /a/xx to not found handler /a/:file",
+			whenURL:     "/a/xx",
+			expectRoute: "/a/:file",
+			expectID:    5,
+			expectParam: map[string]string{"file": "xx"},
+		},
+		{
+			name:        "route not existent /a/c/dxxx to not found handler /a/c/d:file",
+			whenURL:     "/a/c/dxxx",
+			expectRoute: "/a/c/d:file",
+			expectID:    6,
+			expectParam: map[string]string{"file": "xxx"},
+		},
+		{
+			name:        "route /a/c/df to /a/c/df",
+			whenURL:     "/a/c/df",
+			expectRoute: "/a/c/df",
+			expectID:    1,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			r := e.router
+
+			r.Add(http.MethodGet, "/", handlerHelper("ID", 0))
+			r.Add(http.MethodGet, "/a/c/df", handlerHelper("ID", 1))
+			r.Add(http.MethodGet, "/a/b*", handlerHelper("ID", 2))
+			r.Add(http.MethodPut, "/*", handlerHelper("ID", 3))
+
+			r.Add(RouteNotFound, "/a/c/d:file", handlerHelper("ID", 6))
+			r.Add(RouteNotFound, "/a/:file", handlerHelper("ID", 5))
+			r.Add(RouteNotFound, "/:file", handlerHelper("ID", 4))
+
+			c := e.NewContext(nil, nil).(*context)
+			r.Find(http.MethodGet, tc.whenURL, c)
+
+			c.handler(c)
+
+			testValue, _ := c.Get("ID").(int)
+			assert.Equal(t, tc.expectID, testValue)
+			assert.Equal(t, tc.expectRoute, c.Path())
+			for param, expectedValue := range tc.expectParam {
+				assert.Equal(t, expectedValue, c.Param(param))
+			}
+			checkUnusedParamValues(t, c, tc.expectParam)
+		})
+	}
+}
+
+func TestNotFoundRouteStaticKind(t *testing.T) {
+	// note: static not found handler is quite silly thing to have but we still support it
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectID    int
+		expectParam map[string]string
+	}{
+		{
+			name:        "route not existent / to not found handler /",
+			whenURL:     "/",
+			expectRoute: "/",
+			expectID:    3,
+			expectParam: map[string]string{},
+		},
+		{
+			name:        "route /a to /a",
+			whenURL:     "/a",
+			expectRoute: "/a",
+			expectID:    1,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			r := e.router
+
+			r.Add(http.MethodPut, "/", handlerHelper("ID", 0))
+			r.Add(http.MethodGet, "/a", handlerHelper("ID", 1))
+			r.Add(http.MethodPut, "/*", handlerHelper("ID", 2))
+
+			r.Add(RouteNotFound, "/", handlerHelper("ID", 3))
+
+			c := e.NewContext(nil, nil).(*context)
+			r.Find(http.MethodGet, tc.whenURL, c)
+
+			c.handler(c)
+
+			testValue, _ := c.Get("ID").(int)
+			assert.Equal(t, tc.expectID, testValue)
+			assert.Equal(t, tc.expectRoute, c.Path())
+			for param, expectedValue := range tc.expectParam {
+				assert.Equal(t, expectedValue, c.Param(param))
+			}
+			checkUnusedParamValues(t, c, tc.expectParam)
+		})
+	}
+}
+
 // Issue #1509
 func TestRouterParamStaticConflict(t *testing.T) {
 	e := New()