diff --git a/nima/tests/integration/webserver/webserver/src/test/java/io/helidon/nima/tests/integration/server/ErrorHandlingTest.java b/nima/tests/integration/webserver/webserver/src/test/java/io/helidon/nima/tests/integration/server/ErrorHandlingTest.java new file mode 100644 index 00000000000..007af2ad677 --- /dev/null +++ b/nima/tests/integration/webserver/webserver/src/test/java/io/helidon/nima/tests/integration/server/ErrorHandlingTest.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.tests.integration.server; + +import io.helidon.common.http.Http; +import io.helidon.nima.testing.junit5.webserver.DirectClient; +import io.helidon.nima.testing.junit5.webserver.RoutingTest; +import io.helidon.nima.testing.junit5.webserver.SetUpRoute; +import io.helidon.nima.webclient.http1.Http1Client; +import io.helidon.nima.webclient.http1.Http1ClientResponse; +import io.helidon.nima.webserver.http.ErrorHandler; +import io.helidon.nima.webserver.http.Filter; +import io.helidon.nima.webserver.http.FilterChain; +import io.helidon.nima.webserver.http.HttpRouting; +import io.helidon.nima.webserver.http.RoutingRequest; +import io.helidon.nima.webserver.http.RoutingResponse; +import io.helidon.nima.webserver.http.ServerRequest; +import io.helidon.nima.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; + +import static io.helidon.common.testing.http.junit5.HttpHeaderMatcher.hasHeader; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +@RoutingTest +class ErrorHandlingTest { + private static final Http.HeaderName CONTROL_HEADER = Http.Header.create("X-HELIDON-JUNIT"); + private static final Http.HeaderValue FIRST = Http.Header.create(CONTROL_HEADER, "first"); + private static final Http.HeaderValue SECOND = Http.Header.create(CONTROL_HEADER, "second"); + private static final Http.HeaderValue ROUTING = Http.Header.create(CONTROL_HEADER, "routing"); + private static final Http.HeaderValue CUSTOM = Http.Header.create(CONTROL_HEADER, "custom"); + + private final Http1Client client; + + ErrorHandlingTest(DirectClient client) { + this.client = client; + } + + @SetUpRoute + static void routing(HttpRouting.Builder builder) { + builder.error(FirstException.class, new FirstHandler()) + .error(SecondException.class, new SecondHandler()) + .error(CustomRoutingException.class, new CustomRoutingHandler()) + .addFilter(new FirstFilter()) + .addFilter(new SecondFilter()) + .get("/", ErrorHandlingTest::handler); + } + + @Test + void testOk() { + String response = client.get() + .request(String.class); + assertThat(response, is("Done")); + } + + @Test + void testFirst() { + String response = client.get() + .header(FIRST) + .request(String.class); + assertThat(response, is("First")); + } + + @Test + void testSecond() { + String response = client.get() + .header(SECOND) + .request(String.class); + assertThat(response, is("Second")); + } + + @Test + void testCustom() { + String response = client.get() + .header(CUSTOM) + .request(String.class); + assertThat(response, is("Custom")); + } + + @Test + void testUnhandled() { + try (Http1ClientResponse response = client.get() + .header(ROUTING) + .request()) { + assertThat(response.status(), is(Http.Status.INTERNAL_SERVER_ERROR_500)); + assertThat(response.headers(), hasHeader(Http.HeaderValues.CONTENT_LENGTH_ZERO)); + } + } + + private static void handler(ServerRequest req, ServerResponse res) throws Exception { + if (req.headers().contains(ROUTING)) { + throw new RoutingException(); + } + if (req.headers().contains(CUSTOM)) { + throw new CustomRoutingException(); + } + res.send("Done"); + } + + private static class FirstFilter implements Filter { + @Override + public void filter(FilterChain chain, RoutingRequest req, RoutingResponse res) { + if (req.headers().contains(FIRST)) { + throw new FirstException(); + } + chain.proceed(); + } + } + + private static class SecondFilter implements Filter { + @Override + public void filter(FilterChain chain, RoutingRequest req, RoutingResponse res) { + if (req.headers().contains(SECOND)) { + throw new SecondException(); + } + chain.proceed(); + } + } + + private static class FirstHandler implements ErrorHandler { + @Override + public void handle(ServerRequest req, ServerResponse res, FirstException throwable) { + res.send("First"); + } + } + + private static class SecondHandler implements ErrorHandler { + @Override + public void handle(ServerRequest req, ServerResponse res, SecondException throwable) { + res.send("Second"); + } + } + + private static class CustomRoutingHandler implements ErrorHandler { + @Override + public void handle(ServerRequest req, ServerResponse res, CustomRoutingException throwable) { + res.send("Custom"); + } + } + + private static class FirstException extends RuntimeException { + } + + private static class SecondException extends RuntimeException { + } + + private static class RoutingException extends Exception { + + } + + private static class CustomRoutingException extends RoutingException { + + } +} diff --git a/nima/webserver/webserver/pom.xml b/nima/webserver/webserver/pom.xml index 3f9d3a64fa5..b4074a54b4b 100644 --- a/nima/webserver/webserver/pom.xml +++ b/nima/webserver/webserver/pom.xml @@ -65,10 +65,25 @@ junit-jupiter-api test + + junit-jupiter-params + org.junit.jupiter + test + + + mockito-core + org.mockito + test + org.hamcrest hamcrest-all test + + helidon-common-testing-junit5 + io.helidon.common.testing + test + diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/Executable.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/ErrorHandler.java similarity index 55% rename from nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/Executable.java rename to nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/ErrorHandler.java index 6ea5dd844a1..78f44b123ff 100644 --- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/Executable.java +++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/ErrorHandler.java @@ -17,15 +17,20 @@ package io.helidon.nima.webserver.http; /** - * A runnable that can throw a checked exception. - * This is to allow users to throw exception from their routes (and these will either end in an - * {@link io.helidon.common.http.InternalServerException}, or will be handled by exception handler. + * The routing error handler. + * Can be mapped to the error cause in {@link io.helidon.nima.webserver.http.HttpRouting}. + * + * @param type of throwable handled by this handler + * @see io.helidon.nima.webserver.http.HttpRouting.Builder#error(Class, ErrorHandler) */ -interface Executable { +@FunctionalInterface +public interface ErrorHandler { /** - * Execute with a possible checked exception. + * Error handling consumer. * - * @throws Exception any exception + * @param req the server request + * @param res the server response + * @param throwable the cause of the error */ - void execute() throws Exception; + void handle(ServerRequest req, ServerResponse res, T throwable); } diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/ErrorHandlers.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/ErrorHandlers.java index 3f472d0447d..b6151e514af 100644 --- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/ErrorHandlers.java +++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/ErrorHandlers.java @@ -18,6 +18,10 @@ import java.io.UncheckedIOException; import java.net.SocketException; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; import io.helidon.common.http.BadRequestException; import io.helidon.common.http.DirectHandler; @@ -32,8 +36,25 @@ */ public final class ErrorHandlers { private static final System.Logger LOGGER = System.getLogger(ErrorHandlers.class.getName()); + private final IdentityHashMap, ErrorHandler> errorHandlers; - ErrorHandlers() { + private ErrorHandlers(IdentityHashMap, ErrorHandler> errorHandlers) { + this.errorHandlers = errorHandlers; + } + + /** + * Create error handlers. + * + * @param errorHandlers map of type to error handler + * @return new error handlers + */ + public static ErrorHandlers create(Map, ErrorHandler> errorHandlers) { + return new ErrorHandlers(new IdentityHashMap<>(errorHandlers)); + } + + @Override + public String toString() { + return "ErrorHandlers for " + errorHandlers.keySet(); } /** @@ -45,9 +66,9 @@ public final class ErrorHandlers { * @param response HTTP server response * @param task task to execute */ - public void runWithErrorHandling(ConnectionContext ctx, ServerRequest request, ServerResponse response, Executable task) { + public void runWithErrorHandling(ConnectionContext ctx, ServerRequest request, ServerResponse response, Callable task) { try { - task.execute(); + task.call(); } catch (CloseConnectionException | UncheckedIOException e) { // these errors must "bubble up" throw e; @@ -65,10 +86,26 @@ public void runWithErrorHandling(ConnectionContext ctx, ServerRequest request, S } catch (InternalServerException e) { // this is the place error handling must be done // check if error handler exists for cause - if so, use it - if (hasErrorHandler(e.getCause())) { - handleError(ctx, request, response, e.getCause()); + ErrorHandler errorHandler = null; + Throwable exception = null; + + if (e.getCause() != null) { + var maybeEh = errorHandler(e.getCause().getClass()); + if (maybeEh.isPresent()) { + errorHandler = maybeEh.get(); + exception = e.getCause(); + } + } + + if (errorHandler == null) { + errorHandler = errorHandler(e.getClass()).orElse(null); + exception = e; + } + + if (errorHandler == null) { + unhandledError(ctx, request, response, exception); } else { - handleError(ctx, request, response, e); + handleError(ctx, request, response, exception, errorHandler); } } catch (HttpException e) { handleError(ctx, request, response, e); @@ -82,6 +119,26 @@ public void runWithErrorHandling(ConnectionContext ctx, ServerRequest request, S } } + @SuppressWarnings("unchecked") + Optional> errorHandler(Class exceptionClass) { + // then look for error handlers that handle supertypes of this exception from lower to higher + Class throwableClass = exceptionClass; + while (true) { + // first look for exact match + ErrorHandler errorHandler = errorHandlers.get(throwableClass); + if (errorHandler != null) { + return Optional.of((ErrorHandler) errorHandler); + } + if (!Throwable.class.isAssignableFrom(throwableClass)) { + return Optional.empty(); + } + if (throwableClass == Throwable.class) { + return Optional.empty(); + } + throwableClass = (Class) throwableClass.getSuperclass(); + } + } + private void handleRequestException(ConnectionContext ctx, ServerRequest request, ServerResponse response, @@ -102,12 +159,13 @@ private void handleRequestException(ConnectionContext ctx, ctx.directHandlers().handle(e, response, keepAlive); } - private boolean hasErrorHandler(Throwable cause) { - // TODO needs implementation (separate issue) - return true; + private void handleError(ConnectionContext ctx, ServerRequest request, ServerResponse response, Throwable e) { + errorHandler(e.getClass()) + .ifPresentOrElse(it -> handleError(ctx, request, response, e, (ErrorHandler) it), + () -> unhandledError(ctx, request, response, e)); } - private void handleError(ConnectionContext ctx, ServerRequest request, ServerResponse response, Throwable e) { + private void unhandledError(ConnectionContext ctx, ServerRequest request, ServerResponse response, Throwable e) { // to be handled by error handler handleRequestException(ctx, request, response, RequestException.builder() .cause(e) @@ -128,4 +186,17 @@ private void handleError(ConnectionContext ctx, ServerRequest request, ServerRes .request(DirectTransportRequest.create(request.prologue(), request.headers())) .build()); } + + private void handleError(ConnectionContext ctx, + ServerRequest request, + ServerResponse response, + Throwable e, + ErrorHandler it) { + try { + it.handle(request, response, e); + } catch (Exception ex) { + ctx.log(LOGGER, System.Logger.Level.TRACE, "Failed to handle exception.", ex); + unhandledError(ctx, request, response, e); + } + } } diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/Filters.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/Filters.java index 56a026047d7..686e5522297 100644 --- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/Filters.java +++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/Filters.java @@ -18,6 +18,7 @@ import java.util.Iterator; import java.util.List; +import java.util.concurrent.Callable; import io.helidon.common.http.Http; import io.helidon.common.http.HttpException; @@ -44,7 +45,7 @@ private Filters(ErrorHandlers errorHandlers, List filters) { /** * Create filters. * - * @param errorHandlers + * @param errorHandlers error handlers to handle thrown exceptions * @param filters list of filters to use * @return filters */ @@ -71,7 +72,7 @@ public void afterStop() { * @param routingExecutor this handler is called after all filters finish processing * (unless a filter does not invoke the chain) */ - public void filter(ConnectionContext ctx, RoutingRequest request, RoutingResponse response, Executable routingExecutor) { + public void filter(ConnectionContext ctx, RoutingRequest request, RoutingResponse response, Callable routingExecutor) { if (noFilters) { errorHandlers.runWithErrorHandling(ctx, request, response, routingExecutor); return; @@ -86,16 +87,16 @@ private static final class FilterChainImpl implements FilterChain { private final ConnectionContext ctx; private final ErrorHandlers errorHandlers; private final Iterator filters; - private final Executable routingExecutor; - private RoutingRequest request; - private RoutingResponse response; + private final Callable routingExecutor; + private final RoutingRequest request; + private final RoutingResponse response; private FilterChainImpl(ConnectionContext ctx, ErrorHandlers errorHandlers, List filters, RoutingRequest request, RoutingResponse response, - Executable routingExecutor) { + Callable routingExecutor) { this.ctx = ctx; this.errorHandlers = errorHandlers; this.filters = filters.iterator(); @@ -122,8 +123,9 @@ public void proceed() { } } - private void runNextFilter() { + private Void runNextFilter() { filters.next().filter(this, request, response); + return null; } } diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/HttpRouting.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/HttpRouting.java index ef25ad68f65..f0f8cf38087 100644 --- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/HttpRouting.java +++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http/HttpRouting.java @@ -17,7 +17,10 @@ package io.helidon.nima.webserver.http; import java.util.ArrayList; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -42,14 +45,15 @@ public final class HttpRouting implements Routing { private final Filters filters; private final ServiceRoute rootRoute; - // todo configure on HTTP routing - private final ErrorHandlers errorHandlers = new ErrorHandlers(); + private final ErrorHandlers errorHandlers; private final List features; private HttpRouting(Builder builder) { + this.errorHandlers = ErrorHandlers.create(builder.errorHandlers); this.filters = Filters.create(errorHandlers, List.copyOf(builder.filters)); this.rootRoute = builder.rootRules.build(); this.features = List.copyOf(builder.features); + } /** @@ -125,6 +129,7 @@ public static class Builder implements HttpRules, io.helidon.common.Builder filters = new ArrayList<>(); private final ServiceRules rootRules = new ServiceRules(); private final List features = new ArrayList<>(); + private final Map, ErrorHandler> errorHandlers = new IdentityHashMap<>(); private Builder() { } @@ -158,6 +163,19 @@ public Builder addFeature(Supplier feature) { return this; } + /** + * Registers an error handler that handles the given type of exceptions. + * + * @param exceptionClass the type of exception to handle by this handler + * @param handler the error handler + * @param exception type + * @return updated builder + */ + public Builder error(Class exceptionClass, ErrorHandler handler) { + this.errorHandlers.put(exceptionClass, handler); + return this; + } + @Override public Builder register(Supplier... service) { rootRules.register(service); @@ -303,7 +321,7 @@ public Builder any(String pattern, Handler handler) { } } - private static final class RoutingExecutor implements Executable { + private static final class RoutingExecutor implements Callable { private final ConnectionContext ctx; private final RoutingRequest request; private final RoutingResponse response; @@ -320,12 +338,12 @@ private RoutingExecutor(ConnectionContext ctx, } @Override - public void execute() throws Exception { + public Void call() throws Exception { // initial attempt - most common case, handled separately RoutingResult result = doRoute(ctx, request, response); if (result == RoutingResult.FINISH) { - return; + return null; } if (result == RoutingResult.NONE) { throw new NotFoundException("Endpoint not found"); @@ -346,7 +364,7 @@ public void execute() throws Exception { // finished and done if (result == RoutingResult.FINISH) { - return; + return null; } throw new NotFoundException("Endpoint not found"); } diff --git a/nima/webserver/webserver/src/test/java/io/helidon/nima/webserver/http/ErrorHandlersTest.java b/nima/webserver/webserver/src/test/java/io/helidon/nima/webserver/http/ErrorHandlersTest.java new file mode 100644 index 00000000000..d0b72a95b67 --- /dev/null +++ b/nima/webserver/webserver/src/test/java/io/helidon/nima/webserver/http/ErrorHandlersTest.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.nima.webserver.http; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import io.helidon.common.http.Http; +import io.helidon.common.http.HttpPrologue; +import io.helidon.common.uri.UriFragment; +import io.helidon.common.uri.UriPath; +import io.helidon.common.uri.UriQuery; +import io.helidon.nima.http.media.ReadableEntityBase; +import io.helidon.nima.webserver.ConnectionContext; + +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; + +import static io.helidon.common.testing.junit5.OptionalMatcher.optionalEmpty; +import static io.helidon.common.testing.junit5.OptionalMatcher.optionalValue; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ErrorHandlersTest { + static Stream testData() { + return Stream.of( + new TestData(ErrorHandlers.create(Map.of()), + optionalEmpty(), + optionalEmpty(), + optionalEmpty(), + optionalEmpty(), + optionalEmpty(), + optionalEmpty()), + new TestData(ErrorHandlers.create(Map.of(Throwable.class, new TestHandler<>("Throwable"))), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class))), + new TestData(ErrorHandlers.create(Map.of(Exception.class, new TestHandler<>("Exception"))), + optionalEmpty(), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class))), + new TestData(ErrorHandlers.create(Map.of(RuntimeException.class, new TestHandler<>("RuntimeException"))), + optionalEmpty(), + optionalEmpty(), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalEmpty()), + new TestData(ErrorHandlers.create(Map.of(TopRuntimeException.class, new TestHandler<>("TopRuntimeException"))), + optionalEmpty(), + optionalEmpty(), + optionalEmpty(), + optionalValue(instanceOf(TestHandler.class)), + optionalValue(instanceOf(TestHandler.class)), + optionalEmpty()), + new TestData(ErrorHandlers.create(Map.of(ChildRuntimeException.class, + new TestHandler<>("ChildRuntimeException"))), + optionalEmpty(), + optionalEmpty(), + optionalEmpty(), + optionalEmpty(), + optionalValue(instanceOf(TestHandler.class)), + optionalEmpty()) + ); + } + + @ParameterizedTest + @MethodSource("testData") + void testHandleFound(TestData testData) { + ErrorHandlers handlers = testData.handlers(); + + assertAll( + () -> assertThat(handlers.errorHandler(Throwable.class), testData.tMatcher()), + () -> assertThat(handlers.errorHandler(Exception.class), testData.eMatcher()), + () -> assertThat(handlers.errorHandler(RuntimeException.class), testData.rtMatcher()), + () -> assertThat(handlers.errorHandler(TopRuntimeException.class), testData.trtMatcher()), + () -> assertThat(handlers.errorHandler(ChildRuntimeException.class), testData.crtMatcher()), + () -> assertThat(handlers.errorHandler(OtherException.class), testData.oMatcher()) + ); + } + + @Test + void testHandler() { + ErrorHandlers handlers = ErrorHandlers.create(Map.of(TopRuntimeException.class, + (req, res, t) -> res.send(t.getMessage()))); + + testHandler(handlers, new TopRuntimeException(), "Top"); + testHandler(handlers, new ChildRuntimeException(), "Child"); + testNoHandler(handlers, new OtherException(), "Other"); + } + + private void testNoHandler(ErrorHandlers handlers, Exception e, String message) { + ServerRequest req = mock(ServerRequest.class); + ServerResponse res = mock(ServerResponse.class); + ConnectionContext ctx = mock(ConnectionContext.class); + + when(req.prologue()).thenReturn(HttpPrologue.create("http", + "1.0", + Http.Method.GET, + UriPath.create("/"), + UriQuery.empty(), + UriFragment.empty())); + when(req.content()).thenReturn(ReadableEntityBase.empty()); + when(ctx.directHandlers()).thenReturn(DirectHandlers.builder().build()); + + handlers.runWithErrorHandling(ctx, req, res, () -> { + throw e; + }); + + var status = ArgumentCaptor.forClass(Http.Status.class); + verify(res).status(status.capture()); + assertThat(status.getValue(), is(Http.Status.INTERNAL_SERVER_ERROR_500)); + + var sent = ArgumentCaptor.forClass(byte[].class); + verify(res).send(sent.capture()); + assertThat(sent.getValue(), is("Other".getBytes(StandardCharsets.UTF_8))); + } + + private void testHandler(ErrorHandlers handlers, Exception e, String message) { + ServerRequest req = mock(ServerRequest.class); + ServerResponse res = mock(ServerResponse.class); + ConnectionContext ctx = mock(ConnectionContext.class); + + handlers.runWithErrorHandling(ctx, req, res, () -> { + throw e; + }); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(res).send(captor.capture()); + assertThat(captor.getValue(), is(message)); + } + + private static class TopRuntimeException extends RuntimeException { + private TopRuntimeException() { + this("Top"); + } + + public TopRuntimeException(String message) { + super(message); + } + } + + private static class ChildRuntimeException extends TopRuntimeException { + private ChildRuntimeException() { + super("Child"); + } + } + + private static class OtherException extends Exception { + public OtherException() { + super("Other"); + } + } + + private record TestData(ErrorHandlers handlers, + Matcher>> tMatcher, + Matcher>> eMatcher, + Matcher>> rtMatcher, + Matcher>> trtMatcher, + Matcher>> crtMatcher, + Matcher>> oMatcher) { + } + + private static class TestHandler implements ErrorHandler { + private final String message; + + TestHandler(String message) { + this.message = message; + } + + @Override + public void handle(ServerRequest req, ServerResponse res, T throwable) { + } + + @Override + public String toString() { + return message; + } + } +} \ No newline at end of file