From 5595b5aaa554cbb2e2edbb45f63356bef380cf52 Mon Sep 17 00:00:00 2001 From: hyangtack Date: Mon, 27 Nov 2017 08:57:15 +0900 Subject: [PATCH] Add exception handler for annotated http service (#847) Motivation: Currently, there is no way to send a customized response when an exception is raised from an annotated http service method. We only have an HttpResponseException which sends an http status code without http body. Modifications: - Add `@ExceptionHandler` annotation in order to specify exception handler on annotated method. - Add ExceptionHandlerFunction to handle exception. - Rename HttpResponseException to HttpStatusException. - Add HttpResponseException which holds an HttpResponse as its member. - Rename `@Decorate` to `@Decorator` and make it repeatable. - Remove ResourceNotFoundException and ServiceUnavailableException. Use HttpStatusException instead. Result: We can specify exception handlers on annotated method. --- .../client/encoding/HttpDecodedResponse.java | 8 +- .../armeria/common/HttpMessageAggregator.java | 7 +- .../common/stream/FilteredStreamMessage.java | 21 +- .../armeria/server/AnnotatedHttpService.java | 38 +-- .../server/AnnotatedHttpServiceMethod.java | 101 ++++++- .../armeria/server/AnnotatedHttpServices.java | 97 +++++-- .../armeria/server/HttpHeaderPathMapping.java | 6 +- .../armeria/server/HttpResponseException.java | 65 ++--- .../server/HttpResponseSubscriber.java | 53 +++- .../armeria/server/HttpServerHandler.java | 11 +- .../armeria/server/HttpStatusException.java | 85 ++++++ .../server/ResourceNotFoundException.java | 46 ---- .../server/ServiceUnavailableException.java | 47 ---- .../{Decorate.java => Decorator.java} | 12 +- .../armeria/server/annotation/Decorators.java | 34 +++ .../server/annotation/ExceptionHandler.java | 39 +++ .../annotation/ExceptionHandlerFunction.java | 50 ++++ .../server/annotation/ExceptionHandlers.java | 34 +++ .../composition/AbstractCompositeService.java | 5 +- .../server/encoding/HttpEncodedResponse.java | 3 +- .../throttling/ThrottlingRpcService.java | 7 +- .../AnnotatedHttpServiceDecorationTest.java | 15 +- ...otatedHttpServiceExceptionHandlerTest.java | 258 ++++++++++++++++++ .../server/AnnotatedHttpServiceTest.java | 20 +- .../server/HttpResponseExceptionTest.java | 29 +- .../armeria/server/tomcat/TomcatService.java | 4 +- 26 files changed, 825 insertions(+), 270 deletions(-) create mode 100644 core/src/main/java/com/linecorp/armeria/server/HttpStatusException.java delete mode 100644 core/src/main/java/com/linecorp/armeria/server/ResourceNotFoundException.java delete mode 100644 core/src/main/java/com/linecorp/armeria/server/ServiceUnavailableException.java rename core/src/main/java/com/linecorp/armeria/server/annotation/{Decorate.java => Decorator.java} (76%) create mode 100644 core/src/main/java/com/linecorp/armeria/server/annotation/Decorators.java create mode 100644 core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandler.java create mode 100644 core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlerFunction.java create mode 100644 core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlers.java create mode 100644 core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceExceptionHandlerTest.java diff --git a/core/src/main/java/com/linecorp/armeria/client/encoding/HttpDecodedResponse.java b/core/src/main/java/com/linecorp/armeria/client/encoding/HttpDecodedResponse.java index 343ae3ce9b7..d967195af69 100644 --- a/core/src/main/java/com/linecorp/armeria/client/encoding/HttpDecodedResponse.java +++ b/core/src/main/java/com/linecorp/armeria/client/encoding/HttpDecodedResponse.java @@ -91,10 +91,10 @@ protected void beforeComplete(Subscriber subscriber) { } @Override - protected void beforeError(Subscriber subscriber, Throwable cause) { - if (responseDecoder == null) { - return; + protected Throwable beforeError(Subscriber subscriber, Throwable cause) { + if (responseDecoder != null) { + responseDecoder.finish(); } - responseDecoder.finish(); + return cause; } } diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpMessageAggregator.java b/core/src/main/java/com/linecorp/armeria/common/HttpMessageAggregator.java index 37605cf8d1b..2feaa9b52ab 100644 --- a/core/src/main/java/com/linecorp/armeria/common/HttpMessageAggregator.java +++ b/core/src/main/java/com/linecorp/armeria/common/HttpMessageAggregator.java @@ -116,8 +116,11 @@ public void accept(Void unused, Throwable cause) { content = HttpData.of(merged); } - final AggregatedHttpMessage aggregated = onSuccess(content); - future.complete(aggregated); + try { + future.complete(onSuccess(content)); + } catch (Throwable e) { + future.completeExceptionally(e); + } } private void fail(Throwable cause) { diff --git a/core/src/main/java/com/linecorp/armeria/common/stream/FilteredStreamMessage.java b/core/src/main/java/com/linecorp/armeria/common/stream/FilteredStreamMessage.java index c13ce9b7845..ea56b5050ac 100644 --- a/core/src/main/java/com/linecorp/armeria/common/stream/FilteredStreamMessage.java +++ b/core/src/main/java/com/linecorp/armeria/common/stream/FilteredStreamMessage.java @@ -23,6 +23,8 @@ import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A {@link StreamMessage} that filters objects as they are published. The filtering @@ -31,6 +33,8 @@ */ public abstract class FilteredStreamMessage implements StreamMessage { + private static final Logger logger = LoggerFactory.getLogger(FilteredStreamMessage.class); + private final StreamMessage delegate; /** @@ -64,9 +68,12 @@ protected void beforeComplete(Subscriber subscriber) {} /** * A callback executed just before calling {@link Subscriber#onError(Throwable)} on {@code subscriber}. * Override this method to execute any cleanup logic that may be needed before failing the - * subscription. + * subscription. This method may rewrite the {@code cause} and then return a new one so that the new + * {@link Throwable} would be passed to {@link Subscriber#onError(Throwable)}. */ - protected void beforeError(Subscriber subscriber, Throwable cause) {} + protected Throwable beforeError(Subscriber subscriber, Throwable cause) { + return cause; + } @Override public boolean isOpen() { @@ -136,8 +143,14 @@ public void onNext(T t) { @Override public void onError(Throwable t) { - beforeError(delegate, t); - delegate.onError(t); + final Throwable filteredCause = beforeError(delegate, t); + if (filteredCause != null) { + delegate.onError(filteredCause); + } else { + logger.warn("{}#beforeError() returned null. Using the original exception:", + FilteredStreamMessage.this.getClass().getName(), t.toString()); + delegate.onError(t); + } } @Override diff --git a/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpService.java b/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpService.java index bb87457d7ac..7abb57984a1 100644 --- a/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpService.java +++ b/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpService.java @@ -23,12 +23,9 @@ import java.util.function.Function; import com.google.common.base.MoreObjects; -import com.google.common.base.Throwables; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; -import com.linecorp.armeria.common.HttpStatus; -import com.linecorp.armeria.common.util.Exceptions; /** * {@link PathMapping} and their corresponding {@link BiFunction}. @@ -43,7 +40,8 @@ final class AnnotatedHttpService implements HttpService { /** * The {@link BiFunction} that will handle the request actually. */ - private final BiFunction function; + private final BiFunction> function; /** * A decorator of this service. @@ -55,7 +53,8 @@ final class AnnotatedHttpService implements HttpService { * Creates a new instance. */ AnnotatedHttpService(HttpHeaderPathMapping pathMapping, - BiFunction function, + BiFunction> function, Function, ? extends Service> decorator) { this.pathMapping = requireNonNull(pathMapping, "pathMapping"); @@ -90,36 +89,15 @@ boolean overlaps(AnnotatedHttpService entry) { @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { - - final Object ret; - try { - ret = function.apply(ctx, req); - } catch (IllegalArgumentException ignore) { - throw new HttpResponseException(HttpStatus.BAD_REQUEST); - } - - if (!(ret instanceof CompletionStage)) { - return HttpResponse.ofFailure(new IllegalStateException( - "illegal return type: " + ret.getClass().getSimpleName())); - } - - @SuppressWarnings("unchecked") - CompletionStage castStage = (CompletionStage) ret; - return HttpResponse.from(castStage.handle((httpResponse, throwable) -> { - if (throwable != null) { - if (Throwables.getRootCause(throwable) instanceof IllegalArgumentException) { - return HttpResponse.of(HttpStatus.BAD_REQUEST); - } - return Exceptions.throwUnsafely(throwable); - } - return httpResponse; - })); + return HttpResponse.from(function.apply(ctx, req)); } @Override public String toString() { return MoreObjects.toStringHelper(this) .add("pathMapping", pathMapping) - .add("function", function).toString(); + .add("function", function) + .add("decorator", decorator) + .toString(); } } diff --git a/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServiceMethod.java b/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServiceMethod.java index b75f9463ee6..e6711945f2a 100644 --- a/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServiceMethod.java +++ b/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServiceMethod.java @@ -21,7 +21,6 @@ import static com.linecorp.armeria.internal.DefaultValues.getSpecifiedValue; import static java.util.Objects.requireNonNull; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.util.List; @@ -34,13 +33,17 @@ import javax.annotation.Nullable; +import org.reactivestreams.Subscriber; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.base.Throwables; import com.google.common.collect.ImmutableList; import com.linecorp.armeria.common.AggregatedHttpMessage; +import com.linecorp.armeria.common.FilteredHttpResponse; import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpObject; import com.linecorp.armeria.common.HttpParameters; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; @@ -50,6 +53,7 @@ import com.linecorp.armeria.common.util.SafeCloseable; import com.linecorp.armeria.internal.Types; import com.linecorp.armeria.server.annotation.Default; +import com.linecorp.armeria.server.annotation.ExceptionHandlerFunction; import com.linecorp.armeria.server.annotation.Header; import com.linecorp.armeria.server.annotation.Param; import com.linecorp.armeria.server.annotation.ResponseConverter; @@ -71,11 +75,15 @@ final class AnnotatedHttpServiceMethod implements BiFunction parameters; private final boolean isAsynchronous; private final AggregationStrategy aggregationStrategy; + private final List exceptionHandlers; - AnnotatedHttpServiceMethod(Object object, Method method, PathMapping pathMapping) { + AnnotatedHttpServiceMethod(Object object, Method method, PathMapping pathMapping, + List exceptionHandlers) { this.object = requireNonNull(object, "object"); this.method = requireNonNull(method, "method"); requireNonNull(pathMapping, "pathMapping"); + this.exceptionHandlers = ImmutableList.copyOf( + requireNonNull(exceptionHandlers, "exceptionHandlers")); parameters = parameters(method, pathMapping.paramNames()); final Class returnType = method.getReturnType(); @@ -118,7 +126,8 @@ public Object apply(ServiceRequestContext ctx, HttpRequest req) { return CompletableFuture.supplyAsync(() -> invoke(ctx, req, null), ctx.blockingTaskExecutor()); } - BiFunction withConverter(ResponseConverter converter) { + BiFunction> withConverter(ResponseConverter converter) { return (ctx, req) -> executeSyncOrAsync(ctx, req).thenApply(obj -> { try (SafeCloseable ignored = RequestContext.push(ctx, false)) { @@ -127,9 +136,8 @@ BiFunction withConverter(ResponseCon }); } - BiFunction withConverters( - Map, ResponseConverter> converters) { - + BiFunction> withConverters(Map, ResponseConverter> converters) { return (ctx, req) -> executeSyncOrAsync(ctx, req).thenApply(obj -> { try (SafeCloseable ignored = RequestContext.push(ctx, false)) { @@ -140,22 +148,55 @@ BiFunction withConverters( private CompletionStage executeSyncOrAsync(ServiceRequestContext ctx, HttpRequest req) { final Object ret = apply(ctx, req); - return ret instanceof CompletionStage ? - (CompletionStage) ret : CompletableFuture.completedFuture(ret); + if (ret instanceof CompletionStage) { + return ((CompletionStage) ret).handle((obj, cause) -> { + if (cause == null) { + return obj; + } + final HttpResponse response = getResponseForCause(ctx, req, cause); + if (response != null) { + return response; + } else { + return Exceptions.throwUnsafely(cause); + } + }); + } + if (ret instanceof HttpResponse) { + return CompletableFuture.completedFuture( + new ExceptionFilteredHttpResponse(ctx, req, (HttpResponse) ret)); + } + return CompletableFuture.completedFuture(ret); } private Object invoke(ServiceRequestContext ctx, HttpRequest req, @Nullable AggregatedHttpMessage message) { try (SafeCloseable ignored = RequestContext.push(ctx, false)) { return method.invoke(object, parameterValues(ctx, req, parameters, message)); - } catch (Exception e) { - if (e instanceof InvocationTargetException) { - final Throwable cause = e.getCause(); - if (cause != null) { - return Exceptions.throwUnsafely(cause); + } catch (Throwable cause) { + final HttpResponse response = getResponseForCause(ctx, req, cause); + if (response != null) { + return response; + } + return Exceptions.throwUnsafely(cause); + } + } + + /** + * Returns a {@link HttpResponse} which is created by {@link ExceptionHandlerFunction}. + */ + private HttpResponse getResponseForCause(ServiceRequestContext ctx, HttpRequest req, + Throwable cause) { + final Throwable rootCause = Throwables.getRootCause(cause); + for (ExceptionHandlerFunction func : exceptionHandlers) { + if (func.accept(rootCause)) { + try { + return func.handle(ctx, req, rootCause); + } catch (Exception e) { + logger.warn("Unexpected exception from an exception handler {}:", + func.getClass().getName(), e); } } - return Exceptions.throwUnsafely(e); } + return ExceptionHandlerFunction.DEFAULT.handle(ctx, req, rootCause); } /** @@ -229,7 +270,7 @@ private static Parameter createOptionalSupportedParam(java.lang.reflect.Paramete type = parameterInfo.getType(); } - return new Parameter(paramType, (!isOptionalType && aDefault == null), isOptionalType, + return new Parameter(paramType, !isOptionalType && aDefault == null, isOptionalType, validateAndNormalizeSupportedType(type), paramValue, defaultValue); } @@ -509,6 +550,36 @@ private static ResponseConverter findResponseConverter( throw new IllegalArgumentException("Converter not available for: " + type.getSimpleName()); } + /** + * Intercepts a {@link Throwable} raised from {@link HttpResponse} and then rewrites it as an + * {@link HttpResponseException} by {@link ExceptionHandlerFunction}. + */ + private class ExceptionFilteredHttpResponse extends FilteredHttpResponse { + + private final ServiceRequestContext ctx; + private final HttpRequest req; + + ExceptionFilteredHttpResponse(ServiceRequestContext ctx, HttpRequest req, + HttpResponse delegate) { + super(delegate); + this.ctx = ctx; + this.req = req; + } + + @Override + protected HttpObject filter(HttpObject obj) { + return obj; + } + + @Override + protected Throwable beforeError(Subscriber subscriber, + Throwable cause) { + final HttpResponse response = getResponseForCause(ctx, req, cause); + return response != null ? HttpResponseException.of(response) + : cause; + } + } + /** * Parameter entry, which will be used to invoke the {@link AnnotatedHttpService}. */ diff --git a/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServices.java b/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServices.java index 9237e1c9421..05f6735958a 100644 --- a/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServices.java +++ b/core/src/main/java/com/linecorp/armeria/server/AnnotatedHttpServices.java @@ -48,14 +48,18 @@ import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.RequestContext; import com.linecorp.armeria.internal.DefaultValues; import com.linecorp.armeria.server.annotation.ConsumeType; import com.linecorp.armeria.server.annotation.ConsumeTypes; import com.linecorp.armeria.server.annotation.Converter; import com.linecorp.armeria.server.annotation.Converter.Unspecified; -import com.linecorp.armeria.server.annotation.Decorate; +import com.linecorp.armeria.server.annotation.Decorator; import com.linecorp.armeria.server.annotation.Delete; +import com.linecorp.armeria.server.annotation.ExceptionHandler; +import com.linecorp.armeria.server.annotation.ExceptionHandlerFunction; import com.linecorp.armeria.server.annotation.Get; import com.linecorp.armeria.server.annotation.Head; import com.linecorp.armeria.server.annotation.Options; @@ -76,13 +80,26 @@ final class AnnotatedHttpServices { private static final Logger logger = LoggerFactory.getLogger(AnnotatedHttpServices.class); /** - * A {@link DecoratingServiceFunction} map for reusing. + * A {@link DecoratingServiceFunction} map for reusing decorators. */ private static final ConcurrentMap< Class>, DecoratingServiceFunction> decoratingServiceFunctions = new ConcurrentHashMap<>(); + /** + * An {@link ExceptionHandlerFunction} map for reusing exception handlers. + */ + private static final ConcurrentMap< + Class, ExceptionHandlerFunction> + exceptionHandlerFunctions = new ConcurrentHashMap<>(); + + /** + * A default {@link ExceptionHandlerFunction} list. + */ + private static final List defaultExceptionHandlers = + ImmutableList.of(new DefaultExceptionHandler()); + /** * Mapping from HTTP method annotation to {@link HttpMethod}, like following. *
    @@ -306,7 +323,7 @@ private static ResponseConverter converter(Method method) { throw new IllegalArgumentException( "@Converter annotation can't be marked on a method with a target specified."); } - return newInstance(converter.value()); + return newInstance(converter.value(), Converter.class); } throw new IllegalArgumentException("@Converter annotation can't be repeated on a method."); @@ -326,32 +343,28 @@ private static Map, ResponseConverter> converters(Class clazz) { throw new IllegalArgumentException( "@Converter annotation must have a target type specified."); } - builder.put(target, newInstance(converter.value())); + builder.put(target, newInstance(converter.value(), Converter.class)); } return builder.build(); } /** - * Returns a decorator chain which is specified by {@link Decorate} annotations. + * Returns a decorator chain which is specified by {@link Decorator} annotations. */ private static Function, ? extends Service> decorator(Method method) { - final Decorate decorate = method.getAnnotation(Decorate.class); - if (decorate == null) { - return Function.identity(); - } - - final Class>[] c = decorate.value(); - if (c.length == 0) { + final Decorator[] decorators = method.getAnnotationsByType(Decorator.class); + if (decorators.length == 0) { return Function.identity(); } // Respect the order of decorators which is specified by a user. The first one is first applied. Function, - ? extends Service> decorator = newDecorator(c[c.length - 1]); - for (int i = c.length - 2; i >= 0; i--) { - decorator = decorator.andThen(newDecorator(c[i])); + ? extends Service> + decorator = newDecorator(decorators[decorators.length - 1].value()); + for (int i = decorators.length - 2; i >= 0; i--) { + decorator = decorator.andThen(newDecorator(decorators[i].value())); } return decorator; } @@ -364,20 +377,38 @@ private static Map, ResponseConverter> converters(Class clazz) { ? extends Service> newDecorator( Class> clazz) { return service -> new FunctionalDecoratingService<>( - service, decoratingServiceFunctions.computeIfAbsent(clazz, AnnotatedHttpServices::newInstance)); + service, decoratingServiceFunctions.computeIfAbsent(clazz, type -> + newInstance(type, Decorator.class))); + } + + /** + * Returns an exception handler list which is specified by {@link ExceptionHandler} annotations. + */ + private static List exceptionHandlers(Class clazz, Method method) { + ExceptionHandler[] handlers = method.getAnnotationsByType(ExceptionHandler.class); + if (handlers.length == 0) { + handlers = clazz.getAnnotationsByType(ExceptionHandler.class); + } + if (handlers.length == 0) { + return defaultExceptionHandlers; + } + return Arrays.stream(handlers) + .map(h -> exceptionHandlerFunctions.computeIfAbsent(h.value(), type -> + newInstance(type, ExceptionHandler.class))) + .collect(toImmutableList()); } /** * Returns a new instance of the specified {@link Class}. */ - private static T newInstance(Class clazz) { + private static T newInstance(Class clazz, + Class annotation) { try { final Constructor constructor = clazz.getDeclaredConstructor(); constructor.setAccessible(true); return constructor.newInstance(); } catch (Exception e) { - throw new IllegalStateException("A decorator function class specified in @" + - Decorate.class.getSimpleName() + + throw new IllegalStateException("A class specified in @" + annotation.getSimpleName() + " annotation must have an accessible default constructor: " + clazz.getName(), e); } @@ -404,9 +435,8 @@ private static AnnotatedHttpService build(String pathPrefix, Object object, Meth final HttpHeaderPathMapping pathMapping = new HttpHeaderPathMapping(pathStringMapping(pathPrefix, method, methodAnnotations), methods, consumeTypes(method, clazz), produceTypes(method, clazz)); - - final AnnotatedHttpServiceMethod function = new AnnotatedHttpServiceMethod(object, method, pathMapping); - + final AnnotatedHttpServiceMethod function = + new AnnotatedHttpServiceMethod(object, method, pathMapping, exceptionHandlers(clazz, method)); final Set parameterNames = function.pathParamNames(); final Set expectedParamNames = pathMapping.paramNames(); if (!expectedParamNames.containsAll(parameterNames)) { @@ -520,4 +550,27 @@ public String toString() { return '[' + PrefixPathMapping.PREFIX + pathPrefix + ", " + mapping + ']'; } } + + /** + * A default exception handler is used when a user does not specify exception handlers + * by {@link ExceptionHandler} annotation. + */ + private static class DefaultExceptionHandler implements ExceptionHandlerFunction { + @Override + public HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause) { + if (cause instanceof IllegalArgumentException) { + return HttpResponse.of(HttpStatus.BAD_REQUEST); + } + + if (cause instanceof HttpStatusException) { + return HttpResponse.of(((HttpStatusException) cause).httpStatus()); + } + + if (cause instanceof HttpResponseException) { + return ((HttpResponseException) cause).httpResponse(); + } + + return ExceptionHandlerFunction.DEFAULT.handle(ctx, req, cause); + } + } } diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpHeaderPathMapping.java b/core/src/main/java/com/linecorp/armeria/server/HttpHeaderPathMapping.java index 300ddbf6cb6..ae1ddae59f0 100644 --- a/core/src/main/java/com/linecorp/armeria/server/HttpHeaderPathMapping.java +++ b/core/src/main/java/com/linecorp/armeria/server/HttpHeaderPathMapping.java @@ -85,7 +85,7 @@ public PathMappingResult apply(PathMappingContext mappingCtx) { // '415 Unsupported Media Type' and '406 Not Acceptable' is more specific than // '405 Method Not Allowed'. So 405 would be set if there is no status code set before. if (!mappingCtx.delayedThrowable().isPresent()) { - mappingCtx.delayThrowable(HttpResponseException.of(HttpStatus.METHOD_NOT_ALLOWED)); + mappingCtx.delayThrowable(HttpStatusException.of(HttpStatus.METHOD_NOT_ALLOWED)); } return PathMappingResult.empty(); } @@ -102,7 +102,7 @@ public PathMappingResult apply(PathMappingContext mappingCtx) { } } if (!found) { - mappingCtx.delayThrowable(HttpResponseException.of(HttpStatus.UNSUPPORTED_MEDIA_TYPE)); + mappingCtx.delayThrowable(HttpStatusException.of(HttpStatus.UNSUPPORTED_MEDIA_TYPE)); return PathMappingResult.empty(); } } @@ -132,7 +132,7 @@ public PathMappingResult apply(PathMappingContext mappingCtx) { } } - mappingCtx.delayThrowable(HttpResponseException.of(HttpStatus.NOT_ACCEPTABLE)); + mappingCtx.delayThrowable(HttpStatusException.of(HttpStatus.NOT_ACCEPTABLE)); return PathMappingResult.empty(); } diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpResponseException.java b/core/src/main/java/com/linecorp/armeria/server/HttpResponseException.java index 13a44845009..534f453d02b 100644 --- a/core/src/main/java/com/linecorp/armeria/server/HttpResponseException.java +++ b/core/src/main/java/com/linecorp/armeria/server/HttpResponseException.java @@ -17,71 +17,66 @@ import static java.util.Objects.requireNonNull; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - +import com.linecorp.armeria.common.AggregatedHttpMessage; import com.linecorp.armeria.common.Flags; +import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; -import com.linecorp.armeria.common.util.Exceptions; /** - * A {@link RuntimeException} that is raised when an armeria internal http exception has occurred. - * This class is the general class of exceptions produced by a failed request or a reset stream. + * A {@link RuntimeException} that is raised to send an HTTP response with the content specified + * by a user. This class holds an {@link HttpResponse} which would be sent back to the client who + * sent the corresponding request. + * + * @see HttpStatusException */ public class HttpResponseException extends RuntimeException { - private static final Map EXCEPTIONS = new ConcurrentHashMap<>(); - /** - * Returns a new {@link HttpResponseException} instance with the HTTP status code. + * Returns a new {@link HttpResponseException} instance with the specified HTTP status code. */ public static HttpResponseException of(int statusCode) { return of(HttpStatus.valueOf(statusCode)); } /** - * Returns a new {@link HttpResponseException} instance with the {@link HttpStatus}. + * Returns a new {@link HttpResponseException} instance with the specified {@link HttpStatus}. */ public static HttpResponseException of(HttpStatus httpStatus) { requireNonNull(httpStatus, "httpStatus"); - if (Flags.verboseExceptions()) { - return new HttpResponseException(httpStatus); - } else { - final int statusCode = httpStatus.code(); - return EXCEPTIONS.computeIfAbsent(statusCode, code -> - Exceptions.clearTrace(new HttpResponseException(code))); - } + return new HttpResponseException(HttpResponse.of(httpStatus)); } - private static final long serialVersionUID = 3487991462085151316L; - - private final HttpStatus httpStatus; + /** + * Returns a new {@link HttpResponseException} instance with the specified {@link AggregatedHttpMessage}. + */ + public static HttpResponseException of(AggregatedHttpMessage httpMessage) { + return of(requireNonNull(httpMessage, "httpMessage").toHttpResponse()); + } /** - * Creates a new instance with HTTP status code. + * Returns a new {@link HttpResponseException} instance with the specified {@link HttpResponse}. */ - public HttpResponseException(int statusCode) { - this(HttpStatus.valueOf(statusCode)); + public static HttpResponseException of(HttpResponse httpResponse) { + return new HttpResponseException(httpResponse); } + private static final long serialVersionUID = 3487991462085151316L; + + private final HttpResponse httpResponse; + /** - * Creates a new instance. + * Creates a new instance with the specified {@link HttpResponse}. */ - public HttpResponseException(HttpStatus httpStatus) { - super(requireNonNull(httpStatus, "httpStatus").toString()); - if (100 <= httpStatus.code() && httpStatus.code() < 400) { - throw new IllegalArgumentException( - "httpStatus: " + httpStatus + - " (expected: a status that's neither informational, success nor redirection)"); - } - this.httpStatus = httpStatus; + protected HttpResponseException(HttpResponse httpResponse) { + this.httpResponse = requireNonNull(httpResponse, "httpResponse"); } /** - * Returns the {@link HttpStatus} that will be sent to a client. + * Returns the {@link HttpResponse} which would be sent back to the client who sent the + * corresponding request. */ - public HttpStatus httpStatus() { - return httpStatus; + public HttpResponse httpResponse() { + return httpResponse; } @Override diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpResponseSubscriber.java b/core/src/main/java/com/linecorp/armeria/server/HttpResponseSubscriber.java index ea6bb5bfe3f..ff4a0a1f52a 100644 --- a/core/src/main/java/com/linecorp/armeria/server/HttpResponseSubscriber.java +++ b/core/src/main/java/com/linecorp/armeria/server/HttpResponseSubscriber.java @@ -26,14 +26,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.linecorp.armeria.common.AggregatedHttpMessage; import com.linecorp.armeria.common.HttpData; -import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpHeaders; import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpObject; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.HttpStatusClass; -import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.logging.RequestLogBuilder; import com.linecorp.armeria.internal.HttpObjectEncoder; @@ -49,6 +48,11 @@ final class HttpResponseSubscriber implements Subscriber, RequestTim private static final Logger logger = LoggerFactory.getLogger(HttpResponseSubscriber.class); + private static final AggregatedHttpMessage INTERNAL_SERVER_ERROR_MESSAGE = + AggregatedHttpMessage.of(HttpStatus.INTERNAL_SERVER_ERROR); + private static final AggregatedHttpMessage SERVICE_UNAVAILABLE_MESSAGE = + AggregatedHttpMessage.of(HttpStatus.SERVICE_UNAVAILABLE); + enum State { NEEDS_HEADERS, NEEDS_DATA_OR_TRAILING_HEADERS, @@ -123,7 +127,7 @@ private void onTimeout() { requestTimeoutHandler.run(); } else { failAndRespond(RequestTimeoutException.get(), - HttpStatus.SERVICE_UNAVAILABLE, Http2Error.INTERNAL_ERROR); + SERVICE_UNAVAILABLE_MESSAGE, Http2Error.INTERNAL_ERROR); } } } @@ -181,7 +185,9 @@ public void onNext(HttpObject o) { } switch (statusCode) { - case 204: case 205: case 304: + case 204: + case 205: + case 304: // These responses are not allowed to have content so we always close the stream even if // not explicitly set. endOfStream = true; @@ -216,12 +222,28 @@ public void onNext(HttpObject o) { @Override public void onError(Throwable cause) { if (cause instanceof HttpResponseException) { - failAndRespond(cause, ((HttpResponseException) cause).httpStatus(), Http2Error.CANCEL); + // Timeout may occur when the aggregation of the error response takes long. + // If timeout occurs, respond with 503 Service Unavailable. + ((HttpResponseException) cause).httpResponse() + .aggregate(ctx.executor()) + .whenCompleteAsync((message, throwable) -> { + if (throwable != null) { + failAndRespond(throwable, + INTERNAL_SERVER_ERROR_MESSAGE, + Http2Error.CANCEL); + } else { + failAndRespond(cause, message, Http2Error.CANCEL); + } + }, ctx.executor()); + } else if (cause instanceof HttpStatusException) { + failAndRespond(cause, + AggregatedHttpMessage.of(((HttpStatusException) cause).httpStatus()), + Http2Error.CANCEL); } else { logger.warn("{} Unexpected exception from a service or a response publisher: {}", ctx.channel(), service(), cause); - failAndRespond(cause, HttpStatus.INTERNAL_SERVER_ERROR, Http2Error.INTERNAL_ERROR); + failAndRespond(cause, INTERNAL_SERVER_ERROR_MESSAGE, Http2Error.INTERNAL_ERROR); } } @@ -291,12 +313,9 @@ private void setDone() { subscription.cancel(); } - private void failAndRespond(Throwable cause, HttpStatus status, Http2Error error) { - final HttpData content = status.toHttpData(); - final HttpHeaders headers = - HttpHeaders.of(status) - .setObject(HttpHeaderNames.CONTENT_TYPE, MediaType.PLAIN_TEXT_UTF_8) - .setInt(HttpHeaderNames.CONTENT_LENGTH, content.length()); + private void failAndRespond(Throwable cause, AggregatedHttpMessage message, Http2Error error) { + final HttpHeaders headers = message.headers(); + final HttpData content = message.content(); logBuilder().responseHeaders(headers); @@ -308,8 +327,12 @@ private void failAndRespond(Throwable cause, HttpStatus status, Http2Error error if (wroteNothing(state)) { // Did not write anything yet; we can send an error response instead of resetting the stream. - responseEncoder.writeHeaders(ctx, id, streamId, headers, false); - responseEncoder.writeData(ctx, id, streamId, content, true); + if (content.isEmpty()) { + responseEncoder.writeHeaders(ctx, id, streamId, headers, true); + } else { + responseEncoder.writeHeaders(ctx, id, streamId, headers, false); + responseEncoder.writeData(ctx, id, streamId, content, true); + } } else { // Wrote something already; we have to reset/cancel the stream. responseEncoder.writeReset(ctx, id, streamId, error); @@ -333,7 +356,7 @@ private boolean cancelTimeout() { private IllegalStateException newIllegalStateException(String msg) { final IllegalStateException cause = new IllegalStateException(msg); - failAndRespond(cause, HttpStatus.INTERNAL_SERVER_ERROR, Http2Error.INTERNAL_ERROR); + failAndRespond(cause, INTERNAL_SERVER_ERROR_MESSAGE, Http2Error.INTERNAL_ERROR); return cause; } } diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java b/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java index 403a81c2771..b63a6488ca4 100644 --- a/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java +++ b/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java @@ -261,7 +261,8 @@ private void handleRequest(ChannelHandlerContext ctx, DecodedHttpRequest req) th final PathMapped mapped; try { mapped = host.findServiceConfig(mappingCtx); - } catch (HttpResponseException cause) { + } catch (HttpStatusException cause) { + // We do not need to handle HttpResponseException here because we do not use it internally. respond(ctx, req, cause.httpStatus()); return; } catch (Throwable cause) { @@ -287,15 +288,17 @@ private void handleRequest(ChannelHandlerContext ctx, DecodedHttpRequest req) th try (SafeCloseable ignored = RequestContext.push(reqCtx)) { final RequestLogBuilder logBuilder = reqCtx.logBuilder(); - final HttpResponse res; + HttpResponse res; try { req.init(reqCtx); res = service.serve(reqCtx, req); + } catch (HttpResponseException cause) { + res = cause.httpResponse(); } catch (Throwable cause) { logBuilder.endRequest(cause); logBuilder.endResponse(cause); - if (cause instanceof HttpResponseException) { - respond(ctx, req, ((HttpResponseException) cause).httpStatus()); + if (cause instanceof HttpStatusException) { + respond(ctx, req, ((HttpStatusException) cause).httpStatus()); } else { logger.warn("{} Unexpected exception: {}, {}", reqCtx, service, req, cause); respond(ctx, req, HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpStatusException.java b/core/src/main/java/com/linecorp/armeria/server/HttpStatusException.java new file mode 100644 index 00000000000..dc0c8a56830 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/HttpStatusException.java @@ -0,0 +1,85 @@ +/* + * Copyright 2017 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.server; + +import static java.util.Objects.requireNonNull; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.linecorp.armeria.common.Flags; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.util.Exceptions; + +/** + * A {@link RuntimeException} that is raised to send a simplistic HTTP response with minimal content + * by a {@link Service}. It is a general exception raised by a failed request or a reset stream. + * + * @see HttpResponseException + */ +public final class HttpStatusException extends RuntimeException { + + private static final Map EXCEPTIONS = new ConcurrentHashMap<>(); + + /** + * Returns a new {@link HttpStatusException} instance with the specified HTTP status code. + */ + public static HttpStatusException of(int statusCode) { + return of(HttpStatus.valueOf(statusCode)); + } + + /** + * Returns a new {@link HttpStatusException} instance with the specified {@link HttpStatus}. + */ + public static HttpStatusException of(HttpStatus httpStatus) { + requireNonNull(httpStatus, "httpStatus"); + if (Flags.verboseExceptions()) { + return new HttpStatusException(httpStatus); + } else { + final int statusCode = httpStatus.code(); + return EXCEPTIONS.computeIfAbsent(statusCode, code -> + Exceptions.clearTrace(new HttpStatusException(HttpStatus.valueOf(code)))); + } + } + + private static final long serialVersionUID = 3341744805097308847L; + + private final HttpStatus httpStatus; + + /** + * Creates a new instance with the specified {@link HttpStatus}. + */ + private HttpStatusException(HttpStatus httpStatus) { + super(requireNonNull(httpStatus, "httpStatus").toString()); + this.httpStatus = httpStatus; + } + + /** + * Returns the {@link HttpStatus} which would be sent back to the client who sent the + * corresponding request. + */ + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public Throwable fillInStackTrace() { + if (Flags.verboseExceptions()) { + return super.fillInStackTrace(); + } + return this; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/ResourceNotFoundException.java b/core/src/main/java/com/linecorp/armeria/server/ResourceNotFoundException.java deleted file mode 100644 index aa11d26cb70..00000000000 --- a/core/src/main/java/com/linecorp/armeria/server/ResourceNotFoundException.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2016 LINE Corporation - * - * LINE Corporation licenses this file to you 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: - * - * https://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 com.linecorp.armeria.server; - -import com.linecorp.armeria.common.Flags; -import com.linecorp.armeria.common.HttpStatus; -import com.linecorp.armeria.common.util.Exceptions; - -/** - * A {@link RuntimeException} raised when a {@link Service} failed to find a resource. - */ -public final class ResourceNotFoundException extends HttpResponseException { - - private static final long serialVersionUID = 1268757990666737813L; - - private static final ResourceNotFoundException INSTANCE = - Exceptions.clearTrace(new ResourceNotFoundException()); - - /** - * Returns a {@link ResourceNotFoundException} which may be a singleton or a new instance, depending on - * whether {@link Flags#verboseExceptions() the verbose exception mode} is enabled. - */ - public static ResourceNotFoundException get() { - return Flags.verboseExceptions() ? new ResourceNotFoundException() : INSTANCE; - } - - /** - * Creates a new instance. - */ - private ResourceNotFoundException() { - super(HttpStatus.NOT_FOUND); - } -} diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceUnavailableException.java b/core/src/main/java/com/linecorp/armeria/server/ServiceUnavailableException.java deleted file mode 100644 index 06075a2e9e9..00000000000 --- a/core/src/main/java/com/linecorp/armeria/server/ServiceUnavailableException.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2015 LINE Corporation - * - * LINE Corporation licenses this file to you 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: - * - * https://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 com.linecorp.armeria.server; - -import com.linecorp.armeria.common.Flags; -import com.linecorp.armeria.common.HttpStatus; -import com.linecorp.armeria.common.util.Exceptions; - -/** - * A {@link RuntimeException} that is raised when a requested invocation cannot be served. - */ -public final class ServiceUnavailableException extends HttpResponseException { - - private static final long serialVersionUID = -9092895165959388396L; - - private static final ServiceUnavailableException INSTANCE = - Exceptions.clearTrace(new ServiceUnavailableException()); - - /** - * Returns a {@link ServiceUnavailableException} which may be a singleton or a new instance, depending on - * whether {@link Flags#verboseExceptions() the verbose exception mode} is enabled. - */ - public static ServiceUnavailableException get() { - return Flags.verboseExceptions() ? new ServiceUnavailableException() : INSTANCE; - } - - /** - * Creates a new instance. - */ - private ServiceUnavailableException() { - super(HttpStatus.SERVICE_UNAVAILABLE); - } -} diff --git a/core/src/main/java/com/linecorp/armeria/server/annotation/Decorate.java b/core/src/main/java/com/linecorp/armeria/server/annotation/Decorator.java similarity index 76% rename from core/src/main/java/com/linecorp/armeria/server/annotation/Decorate.java rename to core/src/main/java/com/linecorp/armeria/server/annotation/Decorator.java index fe14567b4b4..4d3f572303c 100644 --- a/core/src/main/java/com/linecorp/armeria/server/annotation/Decorate.java +++ b/core/src/main/java/com/linecorp/armeria/server/annotation/Decorator.java @@ -17,6 +17,7 @@ package com.linecorp.armeria.server.annotation; import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @@ -26,16 +27,17 @@ import com.linecorp.armeria.server.DecoratingServiceFunction; /** - * Specifies {@link DecoratingServiceFunction} classes which handle a {@link HttpRequest} before invoking + * Specifies a {@link DecoratingServiceFunction} class which handles an {@link HttpRequest} before invoking * an annotated service method. */ +@Repeatable(Decorators.class) @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) -public @interface Decorate { +public @interface Decorator { /** - * An array of the {@link DecoratingServiceFunction} classes. Each class specified in the {@code value} - * must have an accessible default constructor. + * {@link DecoratingServiceFunction} implementation type. The specified class must have an accessible + * default constructor. */ - Class>[] value(); + Class> value(); } diff --git a/core/src/main/java/com/linecorp/armeria/server/annotation/Decorators.java b/core/src/main/java/com/linecorp/armeria/server/annotation/Decorators.java new file mode 100644 index 00000000000..01d0efb4051 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/annotation/Decorators.java @@ -0,0 +1,34 @@ +/* + * Copyright 2017 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.server.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The containing annotation type for {@link Decorator}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Decorators { + /** + * An array of {@link Decorator}s. + */ + Decorator[] value(); +} diff --git a/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandler.java b/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandler.java new file mode 100644 index 00000000000..596da2eee55 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandler.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.server.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Specifies an {@link ExceptionHandlerFunction} class which handles exceptions throwing from an + * annotated service method. + */ +@Repeatable(ExceptionHandlers.class) +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface ExceptionHandler { + + /** + * {@link ExceptionHandlerFunction} implementation type. The specified class must have an accessible + * default constructor. + */ + Class value(); +} diff --git a/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlerFunction.java b/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlerFunction.java new file mode 100644 index 00000000000..b498314dc3f --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlerFunction.java @@ -0,0 +1,50 @@ +/* + * Copyright 2017 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.server.annotation; + +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestContext; + +/** + * An interface for exception handler. + * + * @see ExceptionHandler + */ +@FunctionalInterface +public interface ExceptionHandlerFunction { + + /** + * A default exception handler function. It returns an {@link HttpResponse} with + * {@code 500 Internal Server Error} status code. + */ + ExceptionHandlerFunction DEFAULT = + (ctx, req, cause) -> HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR); + + /** + * Returns whether the specified {@code cause} is acceptable. + */ + default boolean accept(Throwable cause) { + return true; + } + + /** + * Returns an {@link HttpResponse} which would be sent back to the client who sent the {@code req}. + */ + HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause); +} diff --git a/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlers.java b/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlers.java new file mode 100644 index 00000000000..f2c29cbf13d --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/annotation/ExceptionHandlers.java @@ -0,0 +1,34 @@ +/* + * Copyright 2016 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.server.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The containing annotation type for {@link ExceptionHandler}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface ExceptionHandlers { + /** + * An array of {@link ExceptionHandler}s. + */ + ExceptionHandler[] value(); +} diff --git a/core/src/main/java/com/linecorp/armeria/server/composition/AbstractCompositeService.java b/core/src/main/java/com/linecorp/armeria/server/composition/AbstractCompositeService.java index 67c92662359..d93e61c0fe0 100644 --- a/core/src/main/java/com/linecorp/armeria/server/composition/AbstractCompositeService.java +++ b/core/src/main/java/com/linecorp/armeria/server/composition/AbstractCompositeService.java @@ -25,15 +25,16 @@ import com.google.common.collect.ImmutableList; +import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.Request; import com.linecorp.armeria.common.RequestContext; import com.linecorp.armeria.common.Response; import com.linecorp.armeria.common.metric.MeterIdPrefix; import com.linecorp.armeria.common.util.SafeCloseable; +import com.linecorp.armeria.server.HttpStatusException; import com.linecorp.armeria.server.PathMapped; import com.linecorp.armeria.server.PathMapping; import com.linecorp.armeria.server.PathMappingContext; -import com.linecorp.armeria.server.ResourceNotFoundException; import com.linecorp.armeria.server.Router; import com.linecorp.armeria.server.Routers; import com.linecorp.armeria.server.Server; @@ -138,7 +139,7 @@ public O serve(ServiceRequestContext ctx, I req) throws Exception { final PathMappingContext mappingCtx = ctx.pathMappingContext(); final PathMapped> mapped = findService(mappingCtx.overridePath(ctx.mappedPath())); if (!mapped.isPresent()) { - throw ResourceNotFoundException.get(); + throw HttpStatusException.of(HttpStatus.NOT_FOUND); } final Optional childPrefix = mapped.mapping().prefix(); diff --git a/core/src/main/java/com/linecorp/armeria/server/encoding/HttpEncodedResponse.java b/core/src/main/java/com/linecorp/armeria/server/encoding/HttpEncodedResponse.java index 690a1a9765a..cb6d3a09705 100644 --- a/core/src/main/java/com/linecorp/armeria/server/encoding/HttpEncodedResponse.java +++ b/core/src/main/java/com/linecorp/armeria/server/encoding/HttpEncodedResponse.java @@ -131,8 +131,9 @@ protected void beforeComplete(Subscriber subscriber) { } @Override - protected void beforeError(Subscriber subscriber, Throwable cause) { + protected Throwable beforeError(Subscriber subscriber, Throwable cause) { closeEncoder(); + return cause; } private void closeEncoder() { diff --git a/core/src/main/java/com/linecorp/armeria/server/throttling/ThrottlingRpcService.java b/core/src/main/java/com/linecorp/armeria/server/throttling/ThrottlingRpcService.java index 3e31f2df08f..f82658cce7b 100644 --- a/core/src/main/java/com/linecorp/armeria/server/throttling/ThrottlingRpcService.java +++ b/core/src/main/java/com/linecorp/armeria/server/throttling/ThrottlingRpcService.java @@ -21,11 +21,12 @@ import javax.annotation.Nullable; +import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.RpcRequest; import com.linecorp.armeria.common.RpcResponse; +import com.linecorp.armeria.server.HttpStatusException; import com.linecorp.armeria.server.Service; import com.linecorp.armeria.server.ServiceRequestContext; -import com.linecorp.armeria.server.ServiceUnavailableException; /** * Decorates a RPC {@link Service} to throttle incoming requests. @@ -52,11 +53,11 @@ protected ThrottlingRpcService(Service delegate, /** * Invoked when {@code req} is throttled. By default, this method responds with a - * {@link ServiceUnavailableException}. + * {@link HttpStatusException} with {@code 503 Service Unavailable}. */ @Override protected RpcResponse onFailure(ServiceRequestContext ctx, RpcRequest req, @Nullable Throwable cause) throws Exception { - return RpcResponse.ofFailure(ServiceUnavailableException.get()); + return RpcResponse.ofFailure(HttpStatusException.of(HttpStatus.SERVICE_UNAVAILABLE)); } } diff --git a/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceDecorationTest.java b/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceDecorationTest.java index 0790d70a3d0..9fecf2902b3 100644 --- a/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceDecorationTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceDecorationTest.java @@ -35,7 +35,7 @@ import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.server.TestConverters.UnformattedStringConverter; import com.linecorp.armeria.server.annotation.Converter; -import com.linecorp.armeria.server.annotation.Decorate; +import com.linecorp.armeria.server.annotation.Decorator; import com.linecorp.armeria.server.annotation.Get; import com.linecorp.armeria.server.logging.LoggingService; import com.linecorp.armeria.testing.server.ServerRule; @@ -66,21 +66,22 @@ protected void failed(Throwable e, Description description) { public static class MyDecorationService1 { @Get("/tooManyRequests") - @Decorate(AlwaysTooManyRequestsDecorator.class) + @Decorator(AlwaysTooManyRequestsDecorator.class) public String tooManyRequests(ServiceRequestContext ctx, HttpRequest req) { validateContextAndRequest(ctx, req); return "OK"; } @Get("/locked") - @Decorate({ FallThroughDecorator.class, AlwaysLockedDecorator.class }) + @Decorator(FallThroughDecorator.class) + @Decorator(AlwaysLockedDecorator.class) public String locked(ServiceRequestContext ctx, HttpRequest req) { validateContextAndRequest(ctx, req); return "OK"; } @Get("/ok") - @Decorate(FallThroughDecorator.class) + @Decorator(FallThroughDecorator.class) public String ok(ServiceRequestContext ctx, HttpRequest req) { validateContextAndRequest(ctx, req); return "OK"; @@ -92,14 +93,14 @@ public static class MyDecorationService2 extends MyDecorationService1 { @Override @Get("/override") - @Decorate(AlwaysTooManyRequestsDecorator.class) + @Decorator(AlwaysTooManyRequestsDecorator.class) public String ok(ServiceRequestContext ctx, HttpRequest req) { validateContextAndRequest(ctx, req); return "OK"; } @Get("/added") - @Decorate(FallThroughDecorator.class) + @Decorator(FallThroughDecorator.class) public String added(ServiceRequestContext ctx, HttpRequest req) { validateContextAndRequest(ctx, req); return "OK"; @@ -116,7 +117,7 @@ public HttpResponse serve(Service delegate, ServiceRequestContext ctx, HttpRequest req) throws Exception { validateContextAndRequest(ctx, req); - throw new HttpResponseException(HttpStatus.TOO_MANY_REQUESTS); + throw HttpStatusException.of(HttpStatus.TOO_MANY_REQUESTS); } } diff --git a/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceExceptionHandlerTest.java b/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceExceptionHandlerTest.java new file mode 100644 index 00000000000..966f3e5dbc2 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceExceptionHandlerTest.java @@ -0,0 +1,258 @@ +/* + * Copyright 2017 LINE Corporation + * + * LINE Corporation licenses this file to you 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: + * + * https://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 com.linecorp.armeria.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.ClassRule; +import org.junit.Test; + +import com.linecorp.armeria.client.HttpClient; +import com.linecorp.armeria.common.AggregatedHttpMessage; +import com.linecorp.armeria.common.DefaultHttpResponse; +import com.linecorp.armeria.common.HttpHeaders; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.RequestContext; +import com.linecorp.armeria.server.TestConverters.UnformattedStringConverter; +import com.linecorp.armeria.server.annotation.Converter; +import com.linecorp.armeria.server.annotation.ExceptionHandler; +import com.linecorp.armeria.server.annotation.ExceptionHandlerFunction; +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.logging.LoggingService; +import com.linecorp.armeria.testing.internal.AnticipatedException; +import com.linecorp.armeria.testing.server.ServerRule; + +public class AnnotatedHttpServiceExceptionHandlerTest { + + @ClassRule + public static final ServerRule rule = new ServerRule() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + sb.annotatedService("/1", new MyService1(), + LoggingService.newDecorator()); + + sb.annotatedService("/2", new MyService2(), + LoggingService.newDecorator()); + + sb.annotatedService("/3", new MyService3(), + LoggingService.newDecorator()); + + sb.annotatedService("/4", new MyService4(), + LoggingService.newDecorator()); + + sb.defaultRequestTimeoutMillis(500L); + } + }; + + @Converter(target = String.class, value = UnformattedStringConverter.class) + @ExceptionHandler(NoExceptionHandler.class) + @ExceptionHandler(AnticipatedExceptionHandler1.class) + public static class MyService1 { + + @Get("/sync") + public String sync(ServiceRequestContext ctx, HttpRequest req) { + throw new AnticipatedException("Oops!"); + } + + @Get("/async") + public CompletionStage async(ServiceRequestContext ctx, HttpRequest req) { + return completeExceptionallyLater(ctx); + } + + @Get("/resp1") + public HttpResponse httpResponse(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.from(raiseExceptionImmediately()); + } + + @Get("/resp2") + @ExceptionHandler(NoExceptionHandler.class) + @ExceptionHandler(AnticipatedExceptionHandler2.class) + public HttpResponse asyncHttpResponse(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.from(completeExceptionallyLater(ctx)); + } + } + + // No exception handler is specified. + @Converter(target = String.class, value = UnformattedStringConverter.class) + public static class MyService2 { + + @Get("/sync") + public String sync(ServiceRequestContext ctx, HttpRequest req) { + throw new IllegalArgumentException("Oops!"); + } + } + + @Converter(target = String.class, value = UnformattedStringConverter.class) + @ExceptionHandler(BadExceptionHandler1.class) + public static class MyService3 { + + @Get("/bad1") + public HttpResponse bad1(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.from(completeExceptionallyLater(ctx)); + } + + @Get("/bad2") + @ExceptionHandler(BadExceptionHandler2.class) + public HttpResponse bad2(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.from(completeExceptionallyLater(ctx)); + } + } + + @ExceptionHandler(AnticipatedExceptionHandler3.class) + public static class MyService4 extends MyService1 { + @Get("/handler3") + public HttpResponse handler3(ServiceRequestContext ctx, HttpRequest req) { + return HttpResponse.from(completeExceptionallyLater(ctx)); + } + } + + private static CompletionStage raiseExceptionImmediately() { + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new AnticipatedException("Oops!")); + return future; + } + + private static CompletionStage completeExceptionallyLater(ServiceRequestContext ctx) { + final CompletableFuture future = new CompletableFuture<>(); + // Execute 100 ms later. + ctx.eventLoop().schedule( + () -> future.completeExceptionally(new AnticipatedException("Oops!")), + 100, TimeUnit.MILLISECONDS); + return future; + } + + static class NoExceptionHandler implements ExceptionHandlerFunction { + static final AtomicInteger counter = new AtomicInteger(); + + @Override + public boolean accept(Throwable cause) { + // Not accept any exception. But should be called this method. + counter.incrementAndGet(); + return false; + } + + @Override + public HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause) { + return null; + } + } + + static class AnticipatedExceptionHandler1 implements ExceptionHandlerFunction { + @Override + public HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause) { + return HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "handler1"); + } + } + + static class AnticipatedExceptionHandler2 implements ExceptionHandlerFunction { + @Override + public HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause) { + return HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "handler2"); + } + } + + static class AnticipatedExceptionHandler3 implements ExceptionHandlerFunction { + @Override + public HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause) { + return HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "handler3"); + } + } + + static class BadExceptionHandler1 implements ExceptionHandlerFunction { + @Override + public HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause) { + final DefaultHttpResponse response = new DefaultHttpResponse(); + response.write(HttpHeaders.of(HttpStatus.OK)); + // Timeout may occur before responding. + ctx.eventLoop().schedule((Runnable) response::close, 10, TimeUnit.SECONDS); + return response; + } + } + + static class BadExceptionHandler2 implements ExceptionHandlerFunction { + @Override + public HttpResponse handle(RequestContext ctx, HttpRequest req, Throwable cause) { + final DefaultHttpResponse response = new DefaultHttpResponse(); + // Make invalid response. + response.write(HttpStatus.OK.toHttpData()); + response.close(); + return response; + } + } + + @Test + public void testExceptionHandler() throws Exception { + final HttpClient client = HttpClient.of(rule.uri("/")); + + AggregatedHttpMessage response; + + NoExceptionHandler.counter.set(0); + + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/1/sync")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.OK); + assertThat(response.content().toStringUtf8()).isEqualTo("handler1"); + + assertThat(NoExceptionHandler.counter.get()).isEqualTo(1); + + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/1/async")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.OK); + assertThat(response.content().toStringUtf8()).isEqualTo("handler1"); + + assertThat(NoExceptionHandler.counter.get()).isEqualTo(2); + + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/1/resp1")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.OK); + assertThat(response.content().toStringUtf8()).isEqualTo("handler1"); + + assertThat(NoExceptionHandler.counter.get()).isEqualTo(3); + + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/1/resp2")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.OK); + assertThat(response.content().toStringUtf8()).isEqualTo("handler2"); + + assertThat(NoExceptionHandler.counter.get()).isEqualTo(4); + + // By default exception handler + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/2/sync")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.BAD_REQUEST); + + // Timeout because of bad exception handler + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/3/bad1")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + + // Internal server error would be returned due to invalid response. + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/3/bad2")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + + NoExceptionHandler.counter.set(0); + + response = client.execute(HttpHeaders.of(HttpMethod.GET, "/4/handler3")).aggregate().join(); + assertThat(response.headers().status()).isEqualTo(HttpStatus.OK); + assertThat(response.content().toStringUtf8()).isEqualTo("handler3"); + + assertThat(NoExceptionHandler.counter.get()).isZero(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceTest.java b/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceTest.java index 98c590cefcf..5a01ea6368d 100644 --- a/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/AnnotatedHttpServiceTest.java @@ -121,7 +121,7 @@ protected void configure(ServerBuilder sb) throws Exception { LoggingService.newDecorator()); sb.annotatedService("/11", new MyAnnotatedService11(), - LoggingService.newDecorator()); + LoggingService.newDecorator()); } }; @@ -513,18 +513,18 @@ public CompletableFuture asyncThrowWrapped() { @Get("/syncThrow401") public String sync401() { - throw new HttpResponseException(HttpStatus.UNAUTHORIZED); + throw HttpStatusException.of(HttpStatus.UNAUTHORIZED); } @Get("/asyncThrow401") public CompletableFuture async401() { - throw new HttpResponseException(HttpStatus.UNAUTHORIZED); + throw HttpStatusException.of(HttpStatus.UNAUTHORIZED); } @Get("/asyncThrowWrapped401") public CompletableFuture asyncThrowWrapped401() { return CompletableFuture.supplyAsync(() -> { - throw new HttpResponseException(HttpStatus.UNAUTHORIZED); + throw HttpStatusException.of(HttpStatus.UNAUTHORIZED); }); } } @@ -558,10 +558,10 @@ public String headerDefault(RequestContext ctx, @Get("/headerWithParam") public String headerWithParam(RequestContext ctx, - @Header("username") @Default("hello") String username, - @Header("password") @Default("world") Optional password, - @Param("extra") Optional extra, - @Param("number") int number) { + @Header("username") @Default("hello") String username, + @Header("password") @Default("world") Optional password, + @Param("extra") Optional extra, + @Param("number") int number) { validateContext(ctx); return username + "/" + password.get() + "/" + extra.orElse("(null)") + "/" + number; } @@ -569,8 +569,8 @@ public String headerWithParam(RequestContext ctx, @Get @Path("/headerWithoutValue") public String headerWithoutValue(RequestContext ctx, - @Param("username") @Default("hello") String username, - @Param("password") String password) { + @Header("username") @Default("hello") String username, + @Header("password") String password) { validateContext(ctx); return username + "/" + password; } diff --git a/core/src/test/java/com/linecorp/armeria/server/HttpResponseExceptionTest.java b/core/src/test/java/com/linecorp/armeria/server/HttpResponseExceptionTest.java index a08dcc35e2d..a53fe5f9b98 100644 --- a/core/src/test/java/com/linecorp/armeria/server/HttpResponseExceptionTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/HttpResponseExceptionTest.java @@ -16,26 +16,29 @@ package com.linecorp.armeria.server; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.Test; +import com.linecorp.armeria.common.AggregatedHttpMessage; +import com.linecorp.armeria.common.DefaultHttpResponse; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpHeaders; import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; public class HttpResponseExceptionTest { @Test - public void httpStatus() throws Exception { - HttpResponseException exception = new HttpResponseException(HttpStatus.INTERNAL_SERVER_ERROR) { - private static final long serialVersionUID = -1132103140930994783L; - }; - assertThat(exception.httpStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - } + public void testHttpResponse() throws Exception { + final DefaultHttpResponse response = new DefaultHttpResponse(); + final HttpResponseException exception = HttpResponseException.of(response); + response.write(HttpHeaders.of(HttpStatus.INTERNAL_SERVER_ERROR) + .add(HttpHeaderNames.CONTENT_TYPE, + MediaType.PLAIN_TEXT_UTF_8.toString())); + response.close(); - @Test - public void onlyAcceptErrorHttpStatus() throws Exception { - assertThatThrownBy(() -> new HttpResponseException(HttpStatus.ACCEPTED) { - private static final long serialVersionUID = -1132103140930994783L; - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("(expected: a status that's neither informational, success nor redirection)"); + final AggregatedHttpMessage message = exception.httpResponse().aggregate().join(); + assertThat(message.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(message.headers().get(HttpHeaderNames.CONTENT_TYPE)) + .isEqualTo(MediaType.PLAIN_TEXT_UTF_8.toString()); } } diff --git a/tomcat/src/main/java/com/linecorp/armeria/server/tomcat/TomcatService.java b/tomcat/src/main/java/com/linecorp/armeria/server/tomcat/TomcatService.java index fb6b84e2c57..0a8215862ab 100644 --- a/tomcat/src/main/java/com/linecorp/armeria/server/tomcat/TomcatService.java +++ b/tomcat/src/main/java/com/linecorp/armeria/server/tomcat/TomcatService.java @@ -61,12 +61,12 @@ import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.util.CompletionActions; import com.linecorp.armeria.server.HttpService; +import com.linecorp.armeria.server.HttpStatusException; import com.linecorp.armeria.server.Server; import com.linecorp.armeria.server.ServerListener; import com.linecorp.armeria.server.ServerListenerAdapter; import com.linecorp.armeria.server.ServiceConfig; import com.linecorp.armeria.server.ServiceRequestContext; -import com.linecorp.armeria.server.ServiceUnavailableException; import io.netty.util.AsciiString; @@ -353,7 +353,7 @@ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exc final Adapter coyoteAdapter = connector().getProtocolHandler().getAdapter(); if (coyoteAdapter == null) { // Tomcat is not configured / stopped. - throw ServiceUnavailableException.get(); + throw HttpStatusException.of(HttpStatus.SERVICE_UNAVAILABLE); } final DefaultHttpResponse res = new DefaultHttpResponse();