Repository: avenga/couper
Pull request #490 (2022-04-26 11:06:28)

Backend error handler
Couper can now catch backend related errors via error_handler.

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0cf6f23..7a5e52f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ Unreleased changes are available as `avenga/couper:edge` container.
   * `beta_health`-block to `backend`-block to enable continuous health-checks for defined backends ([#313](https://github.com/avenga/couper/pull/313))
     * `backends.<name>.health` variable to access the current health-check state _(subject to change)_
   * Log malformed duration settings ([#487](https://github.com/avenga/couper/pull/487))
+  * Error handling for `backend`, `backend_openapi_validation` and `backend_timeout` error types ([#490](https://github.com/avenga/couper/pull/490))
 
 * **Changed**
   * Permission handling: ([#477](https://github.com/avenga/couper/pull/477))
diff --git a/config/request/context_key.go b/config/request/context_key.go
index f8b1af69..0fa2dcb9 100644
--- a/config/request/context_key.go
+++ b/config/request/context_key.go
@@ -8,6 +8,7 @@ const (
 	AccessControls
 	BackendName
 	BackendParams
+	BackendTokenRequest
 	BetaGrantedPermissions
 	BetaRequiredPermission
 	BufferOptions
diff --git a/config/runtime/error_handler.go b/config/runtime/error_handler.go
index e77e4ed7..a16c8ef9 100644
--- a/config/runtime/error_handler.go
+++ b/config/runtime/error_handler.go
@@ -12,9 +12,33 @@ import (
 	"github.com/avenga/couper/handler"
 )
 
-var wildcardMap = map[string][]string{
-	"api":      {"beta_insufficient_permissions"},
-	"endpoint": {"beta_insufficient_permissions", "sequence", "unexpected_status"},
+type superTypeMap map[string][]string
+
+var superTypesMapsByContext = map[string]superTypeMap{
+	"api": {
+		"backend": {
+			"backend_openapi_validation",
+			"backend_timeout",
+		},
+		"*": {
+			"backend_openapi_validation",
+			"backend_timeout",
+			"beta_insufficient_permissions",
+		},
+	},
+	"endpoint": {
+		"backend": {
+			"backend_openapi_validation",
+			"backend_timeout",
+		},
+		"*": {
+			"backend_openapi_validation",
+			"backend_timeout",
+			"beta_insufficient_permissions",
+			"sequence",
+			"unexpected_status",
+		},
+	},
 }
 
 func newErrorHandler(ctx *hcl.EvalContext, opts *protectedOptions, log *logrus.Entry,
@@ -33,19 +57,21 @@ func newErrorHandler(ctx *hcl.EvalContext, opts *protectedOptions, log *logrus.E
 			}
 		}
 
-		// this works if wildcard is the only "super kind"
-		if mappedKinds, mkExists := wildcardMap[ref]; mkExists {
-			// expand wildcard:
-			// * set wildcard error handler for mapped kinds, no error handler for this kind is already set
-			// * remove wildcard error handler for wildcard
-			if wcHandler, wcExists := handlersPerKind["*"]; wcExists {
-				for _, mk := range mappedKinds {
-					if _, exists := handlersPerKind[mk]; !exists {
-						handlersPerKind[mk] = wcHandler
+		if superKindMap, mapExists := superTypesMapsByContext[ref]; mapExists {
+			// expand super-kinds:
+			// * set super-kind error handler for mapped sub-kinds, if no error handler for this sub-kind is already set
+			// * remove super-kind error handler for super-kind
+			for superKind, subKinds := range superKindMap {
+				if skHandler, skExists := handlersPerKind[superKind]; skExists {
+					for _, subKind := range subKinds {
+						if _, exists := handlersPerKind[subKind]; !exists {
+							handlersPerKind[subKind] = skHandler
+						}
 					}
+
+					delete(handlersPerKind, superKind)
 				}
 			}
-			delete(handlersPerKind, "*")
 		}
 
 		for k, h := range handlersPerKind {
diff --git a/docs/ERRORS.md b/docs/ERRORS.md
index bd4eaa4d..96db5ba5 100644
--- a/docs/ERRORS.md
+++ b/docs/ERRORS.md
@@ -56,11 +56,17 @@ All errors have a specific type. You can find it in the log field `error_type`.
 
 | Type (and super types)                          | Description                                                                                             | Default handling                                                            |
 |:------------------------------------------------|:--------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------|
+| `backend`                                       | All catchable `backend` related errors | Send error template with status `502`. |
+| `backend_openapi_validation` (`backend`)        | Client request or backend response is invalid | Send error template with status code `400` for invalid client request or `502` for invalid backend response. |
+| `backend_timeout` (`backend`)                   | A backend request timed out | Send error template with status `504`. |
 | `beta_insufficient_permissions`                 | The permission required for the requested operation is not in the permissions granted to the requester. | Send error template with status `403`.                                      |
 
 ### Endpoint error types
 
 | Type (and super types)                          | Description                                                                                      | Default handling                                                            |
 |:------------------------------------------------|:-------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------|
+| `backend`                                       | All catchable `backend` related errors | Send error template with status `502`. |
+| `backend_openapi_validation` (`backend`)        | Client request or backend response is invalid | Send error template with status code `400` for invalid client request or `502` for invalid backend response. |
+| `backend_timeout` (`backend`)                   | A backend request timed out | Send error template with status `504`. |
 | `sequence`                                      | A `request` or `proxy` block request has been failed while depending on another one              | Send error template with status `502`.                                      |
 | `unexpected_status`                             | A `request` or `proxy` block response status code does not match the to `expected_status` list   | Send error template with status `502`.                                      |
diff --git a/errors/couper.go b/errors/couper.go
index 896ce451..082cbe39 100644
--- a/errors/couper.go
+++ b/errors/couper.go
@@ -10,20 +10,18 @@ import (
 const Wildcard = "*"
 
 var (
-	AccessControl     = &Error{synopsis: "access control error", kinds: []string{"access_control"}, httpStatus: http.StatusForbidden}
-	Backend           = &Error{synopsis: "backend error", httpStatus: http.StatusBadGateway}
-	BackendTimeout    = &Error{synopsis: "backend timeout error", httpStatus: http.StatusGatewayTimeout}
-	BackendValidation = &Error{synopsis: "backend validation error", kinds: []string{"backend_validation"}, httpStatus: http.StatusBadRequest}
-	ClientRequest     = &Error{synopsis: "client request error", httpStatus: http.StatusBadRequest}
-	Endpoint          = &Error{synopsis: "endpoint error", kinds: []string{"endpoint"}, httpStatus: http.StatusBadGateway}
-	Evaluation        = &Error{synopsis: "expression evaluation error", kinds: []string{"evaluation"}, httpStatus: http.StatusInternalServerError}
-	Configuration     = &Error{synopsis: "configuration error", kinds: []string{"configuration"}, httpStatus: http.StatusInternalServerError}
-	MethodNotAllowed  = &Error{synopsis: "method not allowed error", httpStatus: http.StatusMethodNotAllowed}
-	Proxy             = &Error{synopsis: "proxy error", httpStatus: http.StatusBadGateway}
-	Request           = &Error{synopsis: "request error", httpStatus: http.StatusBadGateway}
-	RouteNotFound     = &Error{synopsis: "route not found error", httpStatus: http.StatusNotFound}
-	Server            = &Error{synopsis: "internal server error", httpStatus: http.StatusInternalServerError}
-	ServerShutdown    = &Error{synopsis: "server shutdown error", httpStatus: http.StatusInternalServerError}
+	AccessControl    = &Error{synopsis: "access control error", kinds: []string{"access_control"}, httpStatus: http.StatusForbidden}
+	Backend          = &Error{synopsis: "backend error", kinds: []string{"backend"}, httpStatus: http.StatusBadGateway}
+	ClientRequest    = &Error{synopsis: "client request error", httpStatus: http.StatusBadRequest}
+	Endpoint         = &Error{synopsis: "endpoint error", kinds: []string{"endpoint"}, httpStatus: http.StatusBadGateway}
+	Evaluation       = &Error{synopsis: "expression evaluation error", kinds: []string{"evaluation"}, httpStatus: http.StatusInternalServerError}
+	Configuration    = &Error{synopsis: "configuration error", kinds: []string{"configuration"}, httpStatus: http.StatusInternalServerError}
+	MethodNotAllowed = &Error{synopsis: "method not allowed error", httpStatus: http.StatusMethodNotAllowed}
+	Proxy            = &Error{synopsis: "proxy error", httpStatus: http.StatusBadGateway}
+	Request          = &Error{synopsis: "request error", httpStatus: http.StatusBadGateway}
+	RouteNotFound    = &Error{synopsis: "route not found error", httpStatus: http.StatusNotFound}
+	Server           = &Error{synopsis: "internal server error", httpStatus: http.StatusInternalServerError}
+	ServerShutdown   = &Error{synopsis: "server shutdown error", httpStatus: http.StatusInternalServerError}
 )
 
 func TypeToSnake(t interface{}) string {
diff --git a/errors/type_defintions.go b/errors/type_defintions.go
index 11467faa..e3f6ad2b 100644
--- a/errors/type_defintions.go
+++ b/errors/type_defintions.go
@@ -22,7 +22,9 @@ var Definitions = []*Error{
 
 	AccessControl.Kind("beta_insufficient_permissions"),
 
-	BackendValidation,
+	Backend,
+	Backend.Kind("backend_openapi_validation").Status(http.StatusBadRequest),
+	Backend.Kind("backend_timeout").Status(http.StatusGatewayTimeout),
 
 	Endpoint.Kind("sequence"),
 	Endpoint.Kind("unexpected_status"),
diff --git a/errors/types_generated.go b/errors/types_generated.go
index 22d13b76..05d2a5fa 100644
--- a/errors/types_generated.go
+++ b/errors/types_generated.go
@@ -13,8 +13,10 @@ var (
 	Saml2                       = Definitions[8]
 	Saml                        = Definitions[9]
 	BetaInsufficientPermissions = Definitions[10]
-	Sequence                    = Definitions[12]
-	UnexpectedStatus            = Definitions[13]
+	BackendOpenapiValidation    = Definitions[12]
+	BackendTimeout              = Definitions[13]
+	Sequence                    = Definitions[14]
+	UnexpectedStatus            = Definitions[15]
 )
 
 // typeDefinitions holds all related error definitions which are
@@ -35,7 +37,9 @@ var types = typeDefinitions{
 	"saml2":                          Saml2,
 	"saml":                           Saml,
 	"beta_insufficient_permissions":  BetaInsufficientPermissions,
-	"backend_validation":             BackendValidation,
+	"backend":                        Backend,
+	"backend_openapi_validation":     BackendOpenapiValidation,
+	"backend_timeout":                BackendTimeout,
 	"sequence":                       Sequence,
 	"unexpected_status":              UnexpectedStatus,
 }
diff --git a/handler/transport/backend.go b/handler/transport/backend.go
index 9373978e..27434feb 100644
--- a/handler/transport/backend.go
+++ b/handler/transport/backend.go
@@ -216,7 +216,7 @@ func (b *Backend) RoundTrip(req *http.Request) (*http.Response, error) {
 func (b *Backend) openAPIValidate(req *http.Request, tc *Config, deadlineErr <-chan error) (*http.Response, error) {
 	requestValidationInput, err := b.openAPIValidator.ValidateRequest(req)
 	if err != nil {
-		return nil, errors.BackendValidation.Label(b.name).Kind("backend_request_validation").With(err)
+		return nil, errors.BackendOpenapiValidation.Label(b.name).With(err)
 	}
 
 	beresp, err := b.innerRoundTrip(req, tc, deadlineErr)
@@ -225,8 +225,7 @@ func (b *Backend) openAPIValidate(req *http.Request, tc *Config, deadlineErr <-c
 	}
 
 	if err = b.openAPIValidator.ValidateResponse(beresp, requestValidationInput); err != nil {
-		return nil, errors.BackendValidation.Label(b.name).Kind("backend_response_validation").
-			With(err).Status(http.StatusBadGateway)
+		return nil, errors.BackendOpenapiValidation.Label(b.name).With(err).Status(http.StatusBadGateway)
 	}
 
 	return beresp, nil
@@ -283,19 +282,17 @@ func (b *Backend) innerRoundTrip(req *http.Request, tc *Config, deadlineErr <-ch
 	return beresp, nil
 }
 
-const backendTokenRequest = "backendTokenRequest"
-
 func (b *Backend) withTokenRequest(req *http.Request) error {
 	if b.tokenRequest == nil {
 		return nil
 	}
 
-	trValue, _ := req.Context().Value(backendTokenRequest).(string)
+	trValue, _ := req.Context().Value(request.BackendTokenRequest).(string)
 	if trValue != "" { // prevent loop
 		return nil
 	}
 
-	ctx := context.WithValue(req.Context(), backendTokenRequest, "tr")
+	ctx := context.WithValue(req.Context(), request.BackendTokenRequest, "tr")
 	// Reset for upstream transport; prevent mixing values.
 	// tokenRequest will have their own backend configuration.
 	ctx = context.WithValue(ctx, request.BackendParams, nil)
@@ -310,7 +307,7 @@ func (b *Backend) withRetryTokenRequest(req *http.Request, res *http.Response) (
 		return false, nil
 	}
 
-	trValue, _ := req.Context().Value(backendTokenRequest).(string)
+	trValue, _ := req.Context().Value(request.BackendTokenRequest).(string)
 	if trValue != "" { // prevent loop
 		return false, nil
 	}


Test patch:
diff --git a/handler/transport/backend_test.go b/handler/transport/backend_test.go
index 8b25be45..e798b2e7 100644
--- a/handler/transport/backend_test.go
+++ b/handler/transport/backend_test.go
@@ -203,7 +203,7 @@ func TestBackend_RoundTrip_Validation(t *testing.T) {
 			&config.OpenAPI{File: "testdata/upstream.yaml"},
 			http.MethodPost,
 			"/get",
-			"backend validation error",
+			"backend error",
 			"'POST /get': method not allowed",
 		},
 		{
@@ -219,7 +219,7 @@ func TestBackend_RoundTrip_Validation(t *testing.T) {
 			&config.OpenAPI{File: "testdata/upstream.yaml"},
 			http.MethodGet,
 			"/get?404",
-			"backend validation error",
+			"backend error",
 			"status is not supported",
 		},
 		{
diff --git a/handler/validation/openapi_test.go b/handler/validation/openapi_test.go
index 579e3154..970f90ea 100644
--- a/handler/validation/openapi_test.go
+++ b/handler/validation/openapi_test.go
@@ -67,10 +67,10 @@ func TestOpenAPIValidator_ValidateRequest(t *testing.T) {
 		wantBody   bool
 		wantErrLog string
 	}{
-		{"GET without required query", "/a?b", nil, false, `backend validation error: parameter "b" in query has an error: value is required but missing`},
+		{"GET without required query", "/a?b", nil, false, `backend error: parameter "b" in query has an error: value is required but missing`},
 		{"GET with required query", "/a?b=value", nil, false, ""},
 		{"GET with required path", "/a/value", nil, false, ""},
-		{"GET with required path missing", "/a//", nil, false, `backend validation error: parameter "b" in query has an error: value is required but missing`},
+		{"GET with required path missing", "/a//", nil, false, `backend error: parameter "b" in query has an error: value is required but missing`},
 		{"GET with optional query", "/b", nil, false, ""},
 		{"GET with optional path param", "/b/a", nil, false, ""},
 		{"GET with required json body", "/json", strings.NewReader(`["hans", "wurst"]`), true, ""},
diff --git a/server/http_endpoints_test.go b/server/http_endpoints_test.go
index 0701776e..c1d153ac 100644
--- a/server/http_endpoints_test.go
+++ b/server/http_endpoints_test.go
@@ -790,7 +790,7 @@ func TestEndpointSequenceBackendTimeout(t *testing.T) {
 		}
 
 		path := entry.Data["request"].(logging.Fields)["path"]
-		if entry.Message == "backend timeout error: anonymous_3_23: deadline exceeded" {
+		if entry.Message == "backend error: anonymous_3_23: deadline exceeded" {
 			ctxDeadlineSeen = true
 			if path != "/" {
 				t.Errorf("expected '/' to fail")
diff --git a/server/http_error_handler_test.go b/server/http_error_handler_test.go
index cd6842c6..e7cb7d80 100644
--- a/server/http_error_handler_test.go
+++ b/server/http_error_handler_test.go
@@ -1,6 +1,8 @@
 package server_test
 
 import (
+	"bytes"
+	"io"
 	"net/http"
 	"strings"
 	"testing"
@@ -123,6 +125,58 @@ func TestAccessControl_ErrorHandler_Configuration_Error(t *testing.T) {
 	}
 }
 
+func TestErrorHandler_Backend(t *testing.T) {
+	client := test.NewHTTPClient()
+
+	shutdown, _ := newCouper("testdata/integration/error_handler/05_couper.hcl", test.New(t))
+	defer shutdown()
+
+	type testcase struct {
+		path      string
+		expBody   string
+		expStatus int
+	}
+
+	for _, tc := range []testcase{
+		{"/api-backend", `{"api":"backend"}`, 405},
+		{"/api-backend-timeout", `{"api":"backend-timeout"}`, 405},
+		{"/api-backend-validation", `{"api":"backend-validation"}`, 405},
+		{"/api-anything", `{"api":"backend-backend-validation"}`, 405},
+		{"/backend", `{"endpoint":"backend"}`, 405},
+		{"/backend-timeout", `{"endpoint":"backend-timeout"}`, 405},
+		{"/backend-validation", `{"endpoint":"backend-validation"}`, 405},
+		{"/anything", `{"endpoint":"backend-backend-validation"}`, 405},
+		{"/c", `endpoint:backend_openapi_validation`, 405},
+		{"/d", `endpoint:backend`, 405},
+		{"/e", `endpoint:backend_openapi_validation`, 405},
+		{"/f", `endpoint:*`, 405},
+		{"/g", `endpoint:backend`, 405},
+		{"/h", `endpoint:*`, 405},
+	} {
+		t.Run(tc.path, func(st *testing.T) {
+			helper := test.New(st)
+
+			req, err := http.NewRequest(http.MethodGet, "http://anyserver:8080"+tc.path, nil)
+			helper.Must(err)
+
+			res, err := client.Do(req)
+			helper.Must(err)
+
+			if res.StatusCode != tc.expStatus {
+				st.Fatalf("want: %d, got: %d", tc.expStatus, res.StatusCode)
+			}
+
+			resBytes, err := io.ReadAll(res.Body)
+			defer res.Body.Close()
+			helper.Must(err)
+
+			if !bytes.Contains(resBytes, []byte(tc.expBody)) {
+				st.Fatalf("want: %s, got: %s", tc.expBody, resBytes)
+			}
+		})
+	}
+}
+
 func TestAccessControl_ErrorHandler_Permissions(t *testing.T) {
 	client := test.NewHTTPClient()
 
diff --git a/server/testdata/integration/error_handler/01_schema.yaml b/server/testdata/integration/error_handler/01_schema.yaml
new file mode 100644
index 00000000..3eb8bc2b
--- /dev/null
+++ b/server/testdata/integration/error_handler/01_schema.yaml
@@ -0,0 +1,13 @@
+openapi: '3'
+info:
+  title: 'Test'
+  version: 'v1.2.3'
+paths:
+  /api-backend-validation:
+    get:
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+        required: true
diff --git a/server/testdata/integration/error_handler/02_schema.yaml b/server/testdata/integration/error_handler/02_schema.yaml
new file mode 100644
index 00000000..6a2de47d
--- /dev/null
+++ b/server/testdata/integration/error_handler/02_schema.yaml
@@ -0,0 +1,10 @@
+openapi: '3'
+info:
+  title: 'Test'
+  version: 'v1.2.3'
+paths:
+  /anything:
+    get:
+      responses:
+        405:
+          description: OK
diff --git a/server/testdata/integration/error_handler/05_couper.hcl b/server/testdata/integration/error_handler/05_couper.hcl
new file mode 100644
index 00000000..161673b5
--- /dev/null
+++ b/server/testdata/integration/error_handler/05_couper.hcl
@@ -0,0 +1,339 @@
+server {
+  api {
+    endpoint "/api-backend" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+          timeout = "1ns"
+        }
+      }
+    }
+
+    error_handler "backend" {
+      response {
+        status = 405
+        json_body = {
+          "api": "backend"
+        }
+      }
+    }
+  }
+
+  api {
+    endpoint "/api-backend-timeout" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+          timeout = "1ns"
+        }
+      }
+    }
+
+    error_handler "backend_timeout" {
+      response {
+        status = 405
+        json_body = {
+          "api": "backend-timeout"
+        }
+      }
+    }
+  }
+
+  api {
+    endpoint "/api-backend-validation" {
+      proxy {
+        backend {
+          origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "01_schema.yaml"
+          }
+        }
+      }
+    }
+
+    error_handler "backend_openapi_validation" {
+      response {
+        status = 405
+        json_body = {
+          "api": "backend-validation"
+        }
+      }
+    }
+  }
+
+  api {
+    endpoint "/api-anything" {
+      proxy {
+        backend {
+          origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "02_schema.yaml"
+          }
+        }
+      }
+    }
+
+    error_handler "backend_openapi_validation" {
+      response {
+        status = 405
+        json_body = {
+          "api": "backend-backend-validation"
+        }
+      }
+    }
+  }
+
+  endpoint "/backend" {
+    proxy {
+      backend {
+        origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+        timeout = "1ns"
+      }
+    }
+
+    error_handler "backend" {
+      response {
+        status = 405
+        json_body = {
+          "endpoint": "backend"
+        }
+      }
+    }
+  }
+
+  endpoint "/backend-timeout" {
+    proxy {
+      backend {
+        origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+        timeout = "1ns"
+      }
+    }
+
+    error_handler "backend_timeout" {
+      response {
+        status = 405
+        json_body = {
+          "endpoint": "backend-timeout"
+        }
+      }
+    }
+  }
+
+  endpoint "/backend-validation" {
+    proxy {
+      backend {
+        origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+        openapi {
+          file = "01_schema.yaml"
+        }
+      }
+    }
+
+    error_handler "backend_openapi_validation" {
+      response {
+        status = 405
+        json_body = {
+          "endpoint": "backend-validation"
+        }
+      }
+    }
+  }
+
+  endpoint "/anything" {
+    proxy {
+      backend {
+        origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+        openapi {
+          file = "02_schema.yaml"
+        }
+      }
+    }
+
+    error_handler "backend_openapi_validation" {
+      response {
+        status = 405
+        json_body = {
+          "endpoint": "backend-backend-validation"
+        }
+      }
+    }
+  }
+
+  api {
+    endpoint "/c" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "01_schema.yaml"
+          }
+        }
+      }
+
+      error_handler "backend_openapi_validation" {
+        // should win
+        response {
+          status = 405
+          body = "endpoint:backend_openapi_validation"
+        }
+      }
+    }
+
+    error_handler "backend" {
+      response {
+        status = 400
+        body = "api:backend"
+      }
+    }
+  }
+
+  api {
+    endpoint "/d" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "01_schema.yaml"
+          }
+        }
+      }
+
+      error_handler "backend" {
+        // should win
+        response {
+          status = 405
+          body = "endpoint:backend"
+        }
+      }
+    }
+
+    error_handler "backend_openapi_validation" {
+      response {
+        status = 400
+        body = "api:backend_openapi_validation"
+      }
+    }
+  }
+
+  api {
+    endpoint "/e" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "01_schema.yaml"
+          }
+        }
+      }
+
+      error_handler "backend_openapi_validation" {
+        // should win
+        response {
+          status = 405
+          body = "endpoint:backend_openapi_validation"
+        }
+      }
+    }
+
+    error_handler "*" {
+      response {
+        status = 400
+        body = "api:*"
+      }
+    }
+  }
+
+  api {
+    endpoint "/f" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "01_schema.yaml"
+          }
+        }
+      }
+
+      error_handler "*" {
+        // should win
+        response {
+          status = 405
+          body = "endpoint:*"
+        }
+      }
+    }
+
+    error_handler "backend_openapi_validation" {
+      response {
+        status = 400
+        body = "api:backend_openapi_validation"
+      }
+    }
+  }
+
+  api {
+    endpoint "/g" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "01_schema.yaml"
+          }
+        }
+      }
+
+      error_handler "backend" {
+        // should win
+        response {
+          status = 405
+          body = "endpoint:backend"
+        }
+      }
+    }
+
+    error_handler "*" {
+      response {
+        status = 400
+        body = "api:*"
+      }
+    }
+  }
+
+  api {
+    endpoint "/h" {
+      proxy {
+        backend {
+          origin  = "${env.COUPER_TEST_BACKEND_ADDR}"
+
+          openapi {
+            file = "01_schema.yaml"
+          }
+        }
+      }
+
+      error_handler "*" {
+        // should win
+        response {
+          status = 405
+          body = "endpoint:*"
+        }
+      }
+    }
+
+    error_handler "backend" {
+      response {
+        status = 400
+        body = "api:backend"
+      }
+    }
+  }
+}