Repository: caddyserver/caddy (language: go)

Issue:
Caddyfile `handle_errors` optional status code argument 
The [`handle_errors`](https://caddyserver.com/docs/caddyfile/directives/handle_errors) directive is useful for implementing custom error pages and such.

Currently, its ergonomics are not great, because matching errors by status code needs to be done via a `handle` with an `expression` matcher (see examples in the docs).

I suggest that we add an optional status code argument (or variadic multiple status codes) to `handle_errors` as a shortcut for wrapping the routes in a status code matcher. For example:

```
handle_errors 404 410 {
	<directives...>
}
```

This would effectively be the same as:

```
handle_errors {
	@codes expression `{http.error.status_code} in [404, 410]`
	handle @codes {
		<directives...>		
	}
}
```

Using `handle_errors` this way multiple times should simply append more routes. A `handle_errors` with no arguments (no matcher) should always be inserted last to ensure it acts as a fallback.


Discussion / hints:
I like this idea.
Hi, 
I would like to work on this task. Can I pick it up?
Sure @pkkht, PRs welcome.
Ah,, unable to contribute due to change in circumstances. Sorry about that!
Hello, I would like to contribute. Can I pick this issue up? 
Sure 👍 
Thank you! I am on it then! 
In the description of the issue, does **multiple** status codes just mean two status codes? From the looks of it we are looking at ranges here right? 

Also, how do you suggest going about enforcing that a handle_error directive without arguments should come last? 
> does **multiple** status codes just mean two status codes? From the looks of it we are looking at ranges here right?

Not only 2. It can be any number of arguments or something like `4xx`, see example in the [Response Matcher of `reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#response-matcher).

> how do you suggest going about enforcing that a handle_error directive without arguments should come last?

You can either keep it aside in the parsing process then append it at the end after parsing all other handlers, or manage the sorting logic in your implementation of the [`sort.Interface`](https://pkg.go.dev/sort#Interface).
Sorry for the multiple follow ups and maybe the "trivial" questions,  but I guess I am a little bit confused on how I should be approaching this. Is the idea here to build a new `handle_errors` parser from scratch where we programmatically add expressions based on the status codes arguments (instead of the user having to type them)? 

Also, we need to make sure that if someone has the arguments, they are not allowed to have the current `expression / handle` syntax or are we getting rid of that syntax completely? 
See `handle_path` for example:

https://github.com/caddyserver/caddy/blob/18f34290d26d10b6dd62c848b6bd5180d56d7f3a/modules/caddyhttp/rewrite/caddyfile.go#L155

Basically just need to update `func parseHandleErrors` to call `h.RemainingArgs()` and if that slice is non-empty then you create a `caddyhttp.MatchExpression` (with field `Expr` set) which is a string like `"{http.error.status_code} in [" + strings.Join(", ", args) + "]"` effectively.

Bonus points (not required, but nice to have) for handling `4xx` style strings (which means any 400-499 status, a convention we support in most places with status code matching), which should separately expand to something like `"{http.error.status_code} >= 200 && {http.error.status_code} <= 299"` given `2xx`, and join that with an `||` if there's any args with no `xx`.

So `handle_errors 2xx 404 {` should produce a matcher like:

```
({http.error.status_code} >= 200 && {http.error.status_code} <= 299) || {http.error.status_code} in [404]
```

In addition, the code in `httptype.go` doing `if errorSubrouteVals, ok := sblock.pile["error_route"]; ok {` needs to be updated to sort the `error_route` pile such that any of the ones with matchers go first, and ones with no matcher go last.

Run `caddy adapt --pretty` while developing to see what the JSON output looks like, make sure it looks correct. You can write adapt tests as well in files in `caddytest/integration/caddyfile_adapt` which assert that for a given Caddyfile, the correct JSON is produced. You can see an example of a sort like that from earlier in that same file with `sort.SliceStable(p.serverBlocks`, or even more relevant, look at `func sortRoutes(` in `directives.go` (maybe that function can be reused here? Not sure but worth a try).
Thank you for the detailed response! 

Fix patch:
diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go
index 3b56e0739a9..bf95a361698 100644
--- a/caddyconfig/httpcaddyfile/builtins.go
+++ b/caddyconfig/httpcaddyfile/builtins.go
@@ -844,10 +844,67 @@ func parseHandle(h Helper) (caddyhttp.MiddlewareHandler, error) {
 }
 
 func parseHandleErrors(h Helper) ([]ConfigValue, error) {
-	subroute, err := ParseSegmentAsSubroute(h)
+	h.Next()
+	args := h.RemainingArgs()
+	expression := ""
+	if len(args) > 0 {
+		expression = ""
+		codes := []string{}
+		for _, val := range args {
+			if len(val) != 3 {
+				return nil, h.Errf("bad status value '%s'", val)
+			}
+			if strings.HasSuffix(val, "xx") {
+				val = val[:1]
+				_, err := strconv.Atoi(val)
+				if err != nil {
+					return nil, h.Errf("bad status value '%s': %v", val, err)
+				}
+				if expression != "" {
+					expression += " || "
+				}
+				expression += fmt.Sprintf("{http.error.status_code} >= %s00 && {http.error.status_code} <= %s99", val, val)
+				continue
+			}
+			_, err := strconv.Atoi(val)
+			if err != nil {
+				return nil, h.Errf("bad status value '%s': %v", val, err)
+			}
+			codes = append(codes, val)
+		}
+		if len(codes) > 0 {
+			if expression != "" {
+				expression += " || "
+			}
+			expression += "{http.error.status_code} in [" + strings.Join(codes, ", ") + "]"
+		}
+		// Reset cursor position to get ready for ParseSegmentAsSubroute
+		h.Reset()
+		h.Next()
+		h.RemainingArgs()
+		h.Prev()
+	} else {
+		// If no arguments present reset the cursor position to get ready for ParseSegmentAsSubroute
+		h.Prev()
+	}
+
+	handler, err := ParseSegmentAsSubroute(h)
 	if err != nil {
 		return nil, err
 	}
+	subroute, ok := handler.(*caddyhttp.Subroute)
+	if !ok {
+		return nil, h.Errf("segment was not parsed as a subroute")
+	}
+
+	if expression != "" {
+		statusMatcher := caddy.ModuleMap{
+			"expression": h.JSON(caddyhttp.MatchExpression{Expr: expression}),
+		}
+		for i := range subroute.Routes {
+			subroute.Routes[i].MatcherSetsRaw = []caddy.ModuleMap{statusMatcher}
+		}
+	}
 	return []ConfigValue{
 		{
 			Class: "error_route",
diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go
index bc2b125ef1e..066df3014f7 100644
--- a/caddyconfig/httpcaddyfile/httptype.go
+++ b/caddyconfig/httpcaddyfile/httptype.go
@@ -774,10 +774,19 @@ func (st *ServerType) serversFromPairings(
 				if srv.Errors == nil {
 					srv.Errors = new(caddyhttp.HTTPErrorConfig)
 				}
+				sort.SliceStable(errorSubrouteVals, func(i, j int) bool {
+					sri, srj := errorSubrouteVals[i].Value.(*caddyhttp.Subroute), errorSubrouteVals[j].Value.(*caddyhttp.Subroute)
+					if len(sri.Routes[0].MatcherSetsRaw) == 0 && len(srj.Routes[0].MatcherSetsRaw) != 0 {
+						return false
+					}
+					return true
+				})
+				errorsSubroute := &caddyhttp.Subroute{}
 				for _, val := range errorSubrouteVals {
 					sr := val.Value.(*caddyhttp.Subroute)
-					srv.Errors.Routes = appendSubrouteToRouteList(srv.Errors.Routes, sr, matcherSetsEnc, p, warnings)
+					errorsSubroute.Routes = append(errorsSubroute.Routes, sr.Routes...)
 				}
+				srv.Errors.Routes = appendSubrouteToRouteList(srv.Errors.Routes, errorsSubroute, matcherSetsEnc, p, warnings)
 			}
 
 			// add log associations


Test patch:
diff --git a/caddytest/integration/caddyfile_adapt/error_multi_site_blocks.txt b/caddytest/integration/caddyfile_adapt/error_multi_site_blocks.txt
new file mode 100644
index 00000000000..0e84a13c2d3
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/error_multi_site_blocks.txt
@@ -0,0 +1,245 @@
+foo.localhost {
+	root * /srv
+	error /private* "Unauthorized" 410
+	error /fivehundred* "Internal Server Error" 500
+
+	handle_errors 5xx {
+		respond "Error In range [500 .. 599]"
+	}
+	handle_errors 410 {
+		respond "404 or 410 error"
+	}
+}
+
+bar.localhost {
+	root * /srv
+	error /private* "Unauthorized" 410
+	error /fivehundred* "Internal Server Error" 500
+
+	handle_errors 5xx {
+		respond "Error In range [500 .. 599] from second site"
+	}
+	handle_errors 410 {
+		respond "404 or 410 error from second site"
+	}
+}
+----------
+{
+	"apps": {
+		"http": {
+			"servers": {
+				"srv0": {
+					"listen": [
+						":443"
+					],
+					"routes": [
+						{
+							"match": [
+								{
+									"host": [
+										"foo.localhost"
+									]
+								}
+							],
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "vars",
+													"root": "/srv"
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Internal Server Error",
+													"handler": "error",
+													"status_code": 500
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/fivehundred*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Unauthorized",
+													"handler": "error",
+													"status_code": 410
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/private*"
+													]
+												}
+											]
+										}
+									]
+								}
+							],
+							"terminal": true
+						},
+						{
+							"match": [
+								{
+									"host": [
+										"bar.localhost"
+									]
+								}
+							],
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "vars",
+													"root": "/srv"
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Internal Server Error",
+													"handler": "error",
+													"status_code": 500
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/fivehundred*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Unauthorized",
+													"handler": "error",
+													"status_code": 410
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/private*"
+													]
+												}
+											]
+										}
+									]
+								}
+							],
+							"terminal": true
+						}
+					],
+					"errors": {
+						"routes": [
+							{
+								"match": [
+									{
+										"host": [
+											"foo.localhost"
+										]
+									}
+								],
+								"handle": [
+									{
+										"handler": "subroute",
+										"routes": [
+											{
+												"handle": [
+													{
+														"body": "404 or 410 error",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} in [410]"
+													}
+												]
+											},
+											{
+												"handle": [
+													{
+														"body": "Error In range [500 .. 599]",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} \u003e= 500 \u0026\u0026 {http.error.status_code} \u003c= 599"
+													}
+												]
+											}
+										]
+									}
+								],
+								"terminal": true
+							},
+							{
+								"match": [
+									{
+										"host": [
+											"bar.localhost"
+										]
+									}
+								],
+								"handle": [
+									{
+										"handler": "subroute",
+										"routes": [
+											{
+												"handle": [
+													{
+														"body": "404 or 410 error from second site",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} in [410]"
+													}
+												]
+											},
+											{
+												"handle": [
+													{
+														"body": "Error In range [500 .. 599] from second site",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} \u003e= 500 \u0026\u0026 {http.error.status_code} \u003c= 599"
+													}
+												]
+											}
+										]
+									}
+								],
+								"terminal": true
+							}
+						]
+					}
+				}
+			}
+		}
+	}
+}
diff --git a/caddytest/integration/caddyfile_adapt/error_range_codes.txt b/caddytest/integration/caddyfile_adapt/error_range_codes.txt
new file mode 100644
index 00000000000..46b70c8e384
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/error_range_codes.txt
@@ -0,0 +1,120 @@
+{
+	http_port 3010
+}
+localhost:3010 {
+	root * /srv
+	error /private* "Unauthorized" 410
+	error /hidden* "Not found" 404
+
+	handle_errors 4xx {
+		respond "Error in the [400 .. 499] range"
+	}
+}
+----------
+{
+	"apps": {
+		"http": {
+			"http_port": 3010,
+			"servers": {
+				"srv0": {
+					"listen": [
+						":3010"
+					],
+					"routes": [
+						{
+							"match": [
+								{
+									"host": [
+										"localhost"
+									]
+								}
+							],
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "vars",
+													"root": "/srv"
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Unauthorized",
+													"handler": "error",
+													"status_code": 410
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/private*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Not found",
+													"handler": "error",
+													"status_code": 404
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/hidden*"
+													]
+												}
+											]
+										}
+									]
+								}
+							],
+							"terminal": true
+						}
+					],
+					"errors": {
+						"routes": [
+							{
+								"match": [
+									{
+										"host": [
+											"localhost"
+										]
+									}
+								],
+								"handle": [
+									{
+										"handler": "subroute",
+										"routes": [
+											{
+												"handle": [
+													{
+														"body": "Error in the [400 .. 499] range",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
+													}
+												]
+											}
+										]
+									}
+								],
+								"terminal": true
+							}
+						]
+					}
+				}
+			}
+		}
+	}
+}
diff --git a/caddytest/integration/caddyfile_adapt/error_range_simple_codes.txt b/caddytest/integration/caddyfile_adapt/error_range_simple_codes.txt
new file mode 100644
index 00000000000..70158830c26
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/error_range_simple_codes.txt
@@ -0,0 +1,153 @@
+{
+	http_port 2099
+}
+localhost:2099 {
+	root * /srv
+	error /private* "Unauthorized" 410
+	error /threehundred* "Moved Permanently" 301
+	error /internalerr* "Internal Server Error" 500
+
+	handle_errors 500 3xx {
+		respond "Error code is equal to 500 or in the [300..399] range"
+	}
+	handle_errors 4xx {
+		respond "Error in the [400 .. 499] range"
+	}
+}
+----------
+{
+	"apps": {
+		"http": {
+			"http_port": 2099,
+			"servers": {
+				"srv0": {
+					"listen": [
+						":2099"
+					],
+					"routes": [
+						{
+							"match": [
+								{
+									"host": [
+										"localhost"
+									]
+								}
+							],
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "vars",
+													"root": "/srv"
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Moved Permanently",
+													"handler": "error",
+													"status_code": 301
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/threehundred*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Internal Server Error",
+													"handler": "error",
+													"status_code": 500
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/internalerr*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Unauthorized",
+													"handler": "error",
+													"status_code": 410
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/private*"
+													]
+												}
+											]
+										}
+									]
+								}
+							],
+							"terminal": true
+						}
+					],
+					"errors": {
+						"routes": [
+							{
+								"match": [
+									{
+										"host": [
+											"localhost"
+										]
+									}
+								],
+								"handle": [
+									{
+										"handler": "subroute",
+										"routes": [
+											{
+												"handle": [
+													{
+														"body": "Error in the [400 .. 499] range",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
+													}
+												]
+											},
+											{
+												"handle": [
+													{
+														"body": "Error code is equal to 500 or in the [300..399] range",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} \u003e= 300 \u0026\u0026 {http.error.status_code} \u003c= 399 || {http.error.status_code} in [500]"
+													}
+												]
+											}
+										]
+									}
+								],
+								"terminal": true
+							}
+						]
+					}
+				}
+			}
+		}
+	}
+}
diff --git a/caddytest/integration/caddyfile_adapt/error_simple_codes.txt b/caddytest/integration/caddyfile_adapt/error_simple_codes.txt
new file mode 100644
index 00000000000..5ac5863e32d
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/error_simple_codes.txt
@@ -0,0 +1,120 @@
+{
+	http_port 3010
+}
+localhost:3010 {
+	root * /srv
+	error /private* "Unauthorized" 410
+	error /hidden* "Not found" 404
+
+	handle_errors 404 410 {
+		respond "404 or 410 error"
+	}
+}
+----------
+{
+	"apps": {
+		"http": {
+			"http_port": 3010,
+			"servers": {
+				"srv0": {
+					"listen": [
+						":3010"
+					],
+					"routes": [
+						{
+							"match": [
+								{
+									"host": [
+										"localhost"
+									]
+								}
+							],
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "vars",
+													"root": "/srv"
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Unauthorized",
+													"handler": "error",
+													"status_code": 410
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/private*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Not found",
+													"handler": "error",
+													"status_code": 404
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/hidden*"
+													]
+												}
+											]
+										}
+									]
+								}
+							],
+							"terminal": true
+						}
+					],
+					"errors": {
+						"routes": [
+							{
+								"match": [
+									{
+										"host": [
+											"localhost"
+										]
+									}
+								],
+								"handle": [
+									{
+										"handler": "subroute",
+										"routes": [
+											{
+												"handle": [
+													{
+														"body": "404 or 410 error",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} in [404, 410]"
+													}
+												]
+											}
+										]
+									}
+								],
+								"terminal": true
+							}
+						]
+					}
+				}
+			}
+		}
+	}
+}
diff --git a/caddytest/integration/caddyfile_adapt/error_sort.txt b/caddytest/integration/caddyfile_adapt/error_sort.txt
new file mode 100644
index 00000000000..63701cccba9
--- /dev/null
+++ b/caddytest/integration/caddyfile_adapt/error_sort.txt
@@ -0,0 +1,148 @@
+{
+	http_port 2099
+}
+localhost:2099 {
+	root * /srv
+	error /private* "Unauthorized" 410
+	error /hidden* "Not found" 404
+	error /internalerr* "Internal Server Error" 500
+
+	handle_errors {
+		respond "Fallback route: code outside the [400..499] range"
+	}
+	handle_errors 4xx {
+		respond "Error in the [400 .. 499] range"
+	}
+}
+----------
+{
+	"apps": {
+		"http": {
+			"http_port": 2099,
+			"servers": {
+				"srv0": {
+					"listen": [
+						":2099"
+					],
+					"routes": [
+						{
+							"match": [
+								{
+									"host": [
+										"localhost"
+									]
+								}
+							],
+							"handle": [
+								{
+									"handler": "subroute",
+									"routes": [
+										{
+											"handle": [
+												{
+													"handler": "vars",
+													"root": "/srv"
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Internal Server Error",
+													"handler": "error",
+													"status_code": 500
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/internalerr*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Unauthorized",
+													"handler": "error",
+													"status_code": 410
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/private*"
+													]
+												}
+											]
+										},
+										{
+											"handle": [
+												{
+													"error": "Not found",
+													"handler": "error",
+													"status_code": 404
+												}
+											],
+											"match": [
+												{
+													"path": [
+														"/hidden*"
+													]
+												}
+											]
+										}
+									]
+								}
+							],
+							"terminal": true
+						}
+					],
+					"errors": {
+						"routes": [
+							{
+								"match": [
+									{
+										"host": [
+											"localhost"
+										]
+									}
+								],
+								"handle": [
+									{
+										"handler": "subroute",
+										"routes": [
+											{
+												"handle": [
+													{
+														"body": "Error in the [400 .. 499] range",
+														"handler": "static_response"
+													}
+												],
+												"match": [
+													{
+														"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
+													}
+												]
+											},
+											{
+												"handle": [
+													{
+														"body": "Fallback route: code outside the [400..499] range",
+														"handler": "static_response"
+													}
+												]
+											}
+										]
+									}
+								],
+								"terminal": true
+							}
+						]
+					}
+				}
+			}
+		}
+	}
+}
diff --git a/caddytest/integration/caddyfile_test.go b/caddytest/integration/caddyfile_test.go
index 0db7e794787..f9a98fb3855 100644
--- a/caddytest/integration/caddyfile_test.go
+++ b/caddytest/integration/caddyfile_test.go
@@ -496,3 +496,91 @@ func TestUriReplace(t *testing.T) {
 
 	tester.AssertGetResponse("http://localhost:9080/endpoint?test={%20content%20}", 200, "test=%7B%20content%20%7D")
 }
+func TestHandleErrorSimpleCodes(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`{
+		admin localhost:2999
+		http_port     9080
+	}
+	localhost:9080 {
+		root * /srv
+		error /private* "Unauthorized" 410
+		error /hidden* "Not found" 404
+	
+		handle_errors 404 410 {
+			respond "404 or 410 error"
+		}
+	}`, "caddyfile")
+	// act and assert
+	tester.AssertGetResponse("http://localhost:9080/private", 410, "404 or 410 error")
+	tester.AssertGetResponse("http://localhost:9080/hidden", 404, "404 or 410 error")
+}
+
+func TestHandleErrorRange(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`{
+		admin localhost:2999
+		http_port     9080
+	}
+	localhost:9080 {
+		root * /srv
+		error /private* "Unauthorized" 410
+		error /hidden* "Not found" 404
+
+		handle_errors 4xx {
+			respond "Error in the [400 .. 499] range"
+		}
+	}`, "caddyfile")
+	// act and assert
+	tester.AssertGetResponse("http://localhost:9080/private", 410, "Error in the [400 .. 499] range")
+	tester.AssertGetResponse("http://localhost:9080/hidden", 404, "Error in the [400 .. 499] range")
+}
+
+func TestHandleErrorSort(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`{
+		admin localhost:2999
+		http_port     9080
+	}
+	localhost:9080 {
+		root * /srv
+		error /private* "Unauthorized" 410
+		error /hidden* "Not found" 404
+		error /internalerr* "Internal Server Error" 500
+
+		handle_errors {
+			respond "Fallback route: code outside the [400..499] range"
+		}
+		handle_errors 4xx {
+			respond "Error in the [400 .. 499] range"
+		}
+	}`, "caddyfile")
+	// act and assert
+	tester.AssertGetResponse("http://localhost:9080/internalerr", 500, "Fallback route: code outside the [400..499] range")
+	tester.AssertGetResponse("http://localhost:9080/hidden", 404, "Error in the [400 .. 499] range")
+}
+
+func TestHandleErrorRangeAndCodes(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`{
+		admin localhost:2999
+		http_port     9080
+	}
+	localhost:9080 {
+		root * /srv
+		error /private* "Unauthorized" 410
+		error /threehundred* "Moved Permanently" 301
+		error /internalerr* "Internal Server Error" 500
+
+		handle_errors 500 3xx {
+			respond "Error code is equal to 500 or in the [300..399] range"
+		}
+		handle_errors 4xx {
+			respond "Error in the [400 .. 499] range"
+		}
+	}`, "caddyfile")
+	// act and assert
+	tester.AssertGetResponse("http://localhost:9080/internalerr", 500, "Error code is equal to 500 or in the [300..399] range")
+	tester.AssertGetResponse("http://localhost:9080/threehundred", 301, "Error code is equal to 500 or in the [300..399] range")
+	tester.AssertGetResponse("http://localhost:9080/private", 410, "Error in the [400 .. 499] range")
+}