diff --git a/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/dmf/amqp/DmfApiAutoConfiguration.java b/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/dmf/amqp/DmfApiAutoConfiguration.java index 006ec364a..a6610db31 100644 --- a/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/dmf/amqp/DmfApiAutoConfiguration.java +++ b/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/dmf/amqp/DmfApiAutoConfiguration.java @@ -27,7 +27,7 @@ public class DmfApiAutoConfiguration { /** * Create default error handler bean. - * + * * @return the default error handler bean */ @Bean @@ -35,5 +35,4 @@ public class DmfApiAutoConfiguration { public ErrorHandler errorHandler() { return new ConditionalRejectingErrorHandler(); } - } diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AbstractAmqpErrorHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AbstractAmqpErrorHandler.java new file mode 100644 index 000000000..e7e84454e --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AbstractAmqpErrorHandler.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import org.springframework.amqp.AmqpRejectAndDontRequeueException; + +/** + * An abstract error handler for errors resulting from AMQP. + */ +public abstract class AbstractAmqpErrorHandler implements AmqpErrorHandler{ + + @Override + public void doHandle(Throwable throwable, AmqpErrorHandlerChain chain) { + // retrieving the cause of throwable as it contains the actual class of + // exception + final Throwable cause = throwable.getCause(); + if (getExceptionClass().isAssignableFrom(cause.getClass())) { + throw new AmqpRejectAndDontRequeueException(getErrorMessage(throwable)); + } else { + chain.handle(throwable); + } + } + + /** + * Returns the class of the exception. + * + * @return + * the exception class + */ + public abstract Class getExceptionClass(); + + /** + * Returns the customized error message. + * + * @return + * the customized error message + */ + public String getErrorMessage(Throwable throwable){ + return AmqpErrorMessageComposer.constructErrorMessage(throwable); + } + +} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java index dec0f0549..0816a1d7b 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java @@ -8,9 +8,7 @@ */ package org.eclipse.hawkbit.amqp; -import java.time.Duration; -import java.util.Map; - +import com.google.common.collect.Maps; import org.eclipse.hawkbit.api.ArtifactUrlHandler; import org.eclipse.hawkbit.api.HostnameResolver; import org.eclipse.hawkbit.cache.DownloadIdCache; @@ -53,7 +51,9 @@ import org.springframework.retry.backoff.ExponentialBackOffPolicy; import org.springframework.retry.support.RetryTemplate; import org.springframework.util.ErrorHandler; -import com.google.common.collect.Maps; +import java.time.Duration; +import java.util.List; +import java.util.Map; /** * Spring configuration for AMQP based DMF communication for indirect device @@ -80,15 +80,48 @@ public class AmqpConfiguration { private ServiceMatcher serviceMatcher; /** - * Register the bean for the custom error handler. + * Creates a custom error handler bean. * - * @return custom error handler + * @param handlers + * list of {@link AmqpErrorHandler} handlers + + * @return the delegating error handler bean */ @Bean - @ConditionalOnMissingBean(ErrorHandler.class) - public ErrorHandler errorHandler() { - return new ConditionalRejectingErrorHandler( - new DelayedRequeueExceptionStrategy(amqpProperties.getRequeueDelay())); + @ConditionalOnMissingBean + public ErrorHandler errorHandler(final List handlers) { + return new DelegatingConditionalErrorHandler(handlers, new ConditionalRejectingErrorHandler( + new DelayedRequeueExceptionStrategy(amqpProperties.getRequeueDelay()))); + } + + /** + * Error handler bean for all target attributes related fatal errors + * + * @return the invalid target attribute exception handler bean + */ + @Bean + public AmqpErrorHandler invalidTargetAttributeConditionalExceptionHandler() { + return new InvalidTargetAttributeExceptionHandler(); + } + + /** + * Error handler bean for entity not found errors + * + * @return the entity not found exception handler bean + */ + @Bean + public AmqpErrorHandler entityNotFoundExceptionHandler() { + return new EntityNotFoundExceptionHandler(); + } + + /** + * Error handler bean for amqp message conversion errors + * + * @return the amqp message conversion exception handler bean + */ + @Bean + public AmqpErrorHandler messageConversionExceptionHandler() { + return new MessageConversionExceptionHandler(); } /** diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandler.java new file mode 100644 index 000000000..9580dd135 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandler.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +/** + * Interface declaration of {@link AmqpErrorHandler} that handles errors based on the + * types of exception. + */ +@FunctionalInterface +public interface AmqpErrorHandler { + + /** + * Handles the error based on the type of exception + * + * @param throwable + * the throwable + * @param chain + * an {@link AmqpErrorHandlerChain} + */ +void doHandle(final Throwable throwable, final AmqpErrorHandlerChain chain); + +} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandlerChain.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandlerChain.java new file mode 100644 index 000000000..535b9de19 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandlerChain.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import org.springframework.util.ErrorHandler; + +import java.util.Iterator; +import java.util.List; + +/** + * An error handler chain that delegates the error to the matching error handler based on the type of exception + */ +public final class AmqpErrorHandlerChain { + private final Iterator iterator; + private final ErrorHandler defaultHandler; + + /** + * Constructor. + * + * @param iterator + * the {@link AmqpErrorHandler} iterator + * @param defaultHandler + * the default handler + */ + private AmqpErrorHandlerChain(Iterator iterator, ErrorHandler defaultHandler) { + this.iterator = iterator; + this.defaultHandler = defaultHandler; + } + + /** + * Returns an {@link AmqpErrorHandlerChain} + * + * @param errorHandlers + * {@link List} of error handlers + * @param defaultHandler + * the default error handler + * @return an {@link AmqpErrorHandlerChain} + */ + public static AmqpErrorHandlerChain getHandlerChain(final List errorHandlers, final ErrorHandler defaultHandler) { + return new AmqpErrorHandlerChain(errorHandlers.iterator(), defaultHandler); + } + + /** + * Handles the error based on the type of exception + * + * @param error + * the throwable containing the cause of exception + */ + public void handle(final Throwable error) { + if (iterator.hasNext()) { + final AmqpErrorHandler handler = iterator.next(); + handler.doHandle(error, this); + } else { + defaultHandler.handleError(error); + } + } +} + diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorMessageComposer.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorMessageComposer.java new file mode 100644 index 000000000..14b8c8327 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorMessageComposer.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.support.ListenerExecutionFailedException; + +/** + * Class that composes a meaningful error message and enhances it with properties from failed message + */ +public final class AmqpErrorMessageComposer { + + private AmqpErrorMessageComposer() { + } + + /** + * Constructs an error message based on failed message content + * + * @param throwable + * the throwable containing failed message content + * @return + * meaningful error message + */ + public static String constructErrorMessage(final Throwable throwable) { + StringBuilder completeErrorMessage = new StringBuilder(); + final String mainErrorMsg = throwable.getCause().getMessage(); + + if (throwable instanceof ListenerExecutionFailedException) { + Collection failedMessages = ((ListenerExecutionFailedException) throwable).getFailedMessages(); + // since the intended message content is always on top of the collection, we only extract the first one + final Message failedMessage = failedMessages.iterator().next(); + final byte[] amqpFailedMsgBody = failedMessage.getBody(); + final Map amqpFailedMsgHeaders = failedMessage.getMessageProperties().getHeaders(); + + String amqpFailedMsgConcatenatedHeaders = amqpFailedMsgHeaders.keySet().stream() + .map(key -> key + "=" + amqpFailedMsgHeaders.get(key)).collect(Collectors.joining(", ", "{", "}")); + completeErrorMessage.append(mainErrorMsg).append(new String(amqpFailedMsgBody)) + .append(amqpFailedMsgConcatenatedHeaders); + return completeErrorMessage.toString(); + } + return mainErrorMsg; + } +} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelegatingConditionalErrorHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelegatingConditionalErrorHandler.java new file mode 100644 index 000000000..568ed6bf7 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelegatingConditionalErrorHandler.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import java.util.List; +import javax.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.util.ErrorHandler; + +/** + * An error handler delegates error handling to the matching {@link AmqpErrorHandler} based on the type of exception + */ +public class DelegatingConditionalErrorHandler implements ErrorHandler { + private static final Logger LOG = LoggerFactory.getLogger(DelegatingConditionalErrorHandler.class); + private final List handlers; + private final ErrorHandler defaultHandler; + + /** + * Constructor + * + * @param handlers + * {@link List} of error handlers + * @param defaultHandler + * the default error handler + */ + public DelegatingConditionalErrorHandler(final List handlers, @NotNull final ErrorHandler defaultHandler) { + this.handlers = handlers; + this.defaultHandler = defaultHandler; + } + + @Override + public void handleError(final Throwable t) { + if (t.getCause() == null) { + LOG.error("Cannot handle the error as the cause of the error is null!"); + return; + } + + if (includesAmqpRejectException(t.getCause())) { + LOG.error("Received an AmqpRejectAndDontRequeueException due to {}", t.getCause().getMessage()); + return; + } + + AmqpErrorHandlerChain.getHandlerChain(handlers, defaultHandler).handle(t); + } + + private boolean includesAmqpRejectException(final Throwable t) { + if (t instanceof AmqpRejectAndDontRequeueException){ + return true; + } + if (t.getCause() != null) { + return includesAmqpRejectException(t.getCause()); + } + return false; + } +} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/EntityNotFoundExceptionHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/EntityNotFoundExceptionHandler.java new file mode 100644 index 000000000..0984a7a0f --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/EntityNotFoundExceptionHandler.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; + +/** + * An error handler for entity not found exception resulting from AMQP. + */ +public class EntityNotFoundExceptionHandler extends AbstractAmqpErrorHandler { + + @Override + public Class getExceptionClass() { + return EntityNotFoundException.class; + } +} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/InvalidTargetAttributeExceptionHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/InvalidTargetAttributeExceptionHandler.java new file mode 100644 index 000000000..45ed32755 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/InvalidTargetAttributeExceptionHandler.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException; + +/** + * An error handler for all invalid target attributes resulting from AMQP. + */ +public class InvalidTargetAttributeExceptionHandler extends AbstractAmqpErrorHandler { + + @Override + public Class getExceptionClass() { + return InvalidTargetAttributeException.class; + } +} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/MessageConversionExceptionHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/MessageConversionExceptionHandler.java new file mode 100644 index 000000000..0a3590e18 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/MessageConversionExceptionHandler.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import java.util.Optional; + +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import org.springframework.amqp.support.converter.MessageConversionException; + +/** + * An error handler for message conversion exception resulting from AMQP. + */ +public class MessageConversionExceptionHandler extends AbstractAmqpErrorHandler { + + @Override + public Class getExceptionClass() { + return MessageConversionException.class; + } + + @Override + public String getErrorMessage(Throwable throwable) { + final String errorMessage = super.getErrorMessage(throwable); + //since the detailed error message lies in the first parent of current throwable we retrieve it + // and append it to the errorMessage + final Optional detailedErrorMessage = getFirstAncestralErrorMessage(throwable.getCause()); + return detailedErrorMessage.isPresent()? (detailedErrorMessage.get() + errorMessage) : errorMessage; + } + + private Optional getFirstAncestralErrorMessage(final Throwable throwable) { + if(throwable.getCause() instanceof InvalidFormatException) { + return Optional.of(throwable.getCause().getMessage()); + } + return Optional.empty(); + } +} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/DelegatingAmqpErrorHandlerTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/DelegatingAmqpErrorHandlerTest.java new file mode 100644 index 000000000..68dc2b73f --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/DelegatingAmqpErrorHandlerTest.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2021 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.amqp; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import io.qameta.allure.Description; +import io.qameta.allure.Feature; +import io.qameta.allure.Story; + +import org.junit.jupiter.api.Test; +import org.springframework.util.ErrorHandler; + +import java.util.ArrayList; +import java.util.List; + +@Feature("Unit Tests - Delegating Conditional Error Handler") +@Story("Delegating Conditional Error Handler") +public class DelegatingAmqpErrorHandlerTest { + + @Test + @Description("Verifies that with a list of conditional error handlers, the error is delegated to specific handler.") + public void verifyDelegationHandling(){ + List handlers = new ArrayList<>(); + handlers.add(new IllegalArgumentExceptionHandler()); + handlers.add(new IndexOutOfBoundsExceptionHandler()); + assertThatExceptionOfType(IllegalArgumentException.class) + .as("Expected handled exception to be of type IllegalArgumentException") + .isThrownBy(() -> new DelegatingConditionalErrorHandler(handlers, new DefaultErrorHandler()) + .handleError(new Throwable(new IllegalArgumentException()))); + } + + @Test + @Description("Verifies that default handler is used if no handlers are defined for the specific exception.") + public void verifyDefaultDelegationHandling(){ + List handlers = new ArrayList<>(); + handlers.add(new IllegalArgumentExceptionHandler()); + handlers.add(new IndexOutOfBoundsExceptionHandler()); + assertThatExceptionOfType(RuntimeException.class) + .as("Expected handled exception to be of type RuntimeException") + .isThrownBy(() -> new DelegatingConditionalErrorHandler(handlers, new DefaultErrorHandler()) + .handleError(new Throwable(new NullPointerException()))); + } + + // Test class + public class IllegalArgumentExceptionHandler implements AmqpErrorHandler { + + @Override + public void doHandle(final Throwable t, final AmqpErrorHandlerChain chain) { + if (t.getCause() instanceof IllegalArgumentException) { + throw new IllegalArgumentException(t.getMessage()); + } else { + chain.handle(t); + } + } + } + + // Test class + public class IndexOutOfBoundsExceptionHandler implements AmqpErrorHandler { + + @Override + public void doHandle(final Throwable t, final AmqpErrorHandlerChain chain) { + if (t.getCause() instanceof IndexOutOfBoundsException) { + throw new IndexOutOfBoundsException(t.getMessage()); + } else { + chain.handle(t); + } + } + } + + // Test class + public class DefaultErrorHandler implements ErrorHandler { + + @Override + public void + handleError(Throwable t) { + throw new RuntimeException(t); + } + } +}