Fix AMQP retries when attribute characters are invalid (#2327)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-03-26 16:53:48 +02:00
committed by GitHub
parent 836f3dc20f
commit 21ec2e581a
16 changed files with 290 additions and 518 deletions

View File

@@ -137,8 +137,9 @@ class DdiConfigDataTest extends AbstractDDiApiIntegrationTest {
} }
@Test @Test
@Description("We verify that the config data (i.e. device attributes like serial number, hardware revision etc.) " + @Description("""
"upload quota is enforced to protect the server from malicious attempts.") We verify that the config data (i.e. device attributes like serial number, hardware revision etc.)
upload quota is enforced to protect the server from malicious attempts.""")
void putTooMuchConfigData() throws Exception { void putTooMuchConfigData() throws Exception {
testdataFactory.createTarget(TARGET1_ID); testdataFactory.createTarget(TARGET1_ID);
@@ -148,15 +149,16 @@ class DdiConfigDataTest extends AbstractDDiApiIntegrationTest {
attributes.put("dsafsdf" + i, "sdsds" + i); attributes.put("dsafsdf" + i, "sdsds" + i);
} }
mvc.perform(put(TARGET1_CONFIG_DATA_PATH, tenantAware.getCurrentTenant()) mvc.perform(put(TARGET1_CONFIG_DATA_PATH, tenantAware.getCurrentTenant())
.content(JsonBuilder.configData(attributes).toString()).contentType(MediaType.APPLICATION_JSON)) .content(JsonBuilder.configData(attributes).toString())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()); .andExpect(status().isOk());
mvc.perform(put(TARGET1_CONFIG_DATA_PATH, tenantAware.getCurrentTenant()) mvc.perform(put(TARGET1_CONFIG_DATA_PATH, tenantAware.getCurrentTenant())
.content(JsonBuilder.configData(Map.of("on too many", "sdsds")).toString()).contentType(MediaType.APPLICATION_JSON)) .content(JsonBuilder.configData(Map.of("on too many", "sdsds")).toString())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden()) .andExpect(status().isForbidden())
.andExpect(jsonPath("$.exceptionClass", equalTo(AssignmentQuotaExceededException.class.getName()))) .andExpect(jsonPath("$.exceptionClass", equalTo(AssignmentQuotaExceededException.class.getName())))
.andExpect(jsonPath("$.errorCode", equalTo(SpServerError.SP_QUOTA_EXCEEDED.getKey()))); .andExpect(jsonPath("$.errorCode", equalTo(SpServerError.SP_QUOTA_EXCEEDED.getKey())));
} }
@Test @Test

View File

@@ -1,45 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.amqp;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
/**
* An abstract error handler for errors resulting from AMQP.
*/
public abstract class AbstractAmqpErrorHandler<T> 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<T> getExceptionClass();
/**
* Returns the customized error message.
*
* @return the customized error message
*/
public String getErrorMessage(Throwable throwable) {
return AmqpErrorMessageComposer.constructErrorMessage(throwable);
}
}

View File

@@ -9,11 +9,14 @@
*/ */
package org.eclipse.hawkbit.amqp; package org.eclipse.hawkbit.amqp;
import java.sql.SQLException;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.artifact.repository.urlhandler.ArtifactUrlHandler; import org.eclipse.hawkbit.artifact.repository.urlhandler.ArtifactUrlHandler;
import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings; import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings;
@@ -36,11 +39,14 @@ import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin; import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler; import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler;
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
import org.springframework.amqp.rabbit.listener.RabbitListenerContainerFactory; import org.springframework.amqp.rabbit.listener.RabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer; import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.amqp.SimpleRabbitListenerContainerFactoryConfigurer; import org.springframework.boot.autoconfigure.amqp.SimpleRabbitListenerContainerFactoryConfigurer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -65,7 +71,8 @@ public class AmqpConfiguration {
private final ConnectionFactory rabbitConnectionFactory; private final ConnectionFactory rabbitConnectionFactory;
private ServiceMatcher serviceMatcher; private ServiceMatcher serviceMatcher;
public AmqpConfiguration(final AmqpProperties amqpProperties, final AmqpDeadletterProperties amqpDeadletterProperties, final ConnectionFactory rabbitConnectionFactory) { public AmqpConfiguration(final AmqpProperties amqpProperties, final AmqpDeadletterProperties amqpDeadletterProperties,
final ConnectionFactory rabbitConnectionFactory) {
this.amqpProperties = amqpProperties; this.amqpProperties = amqpProperties;
this.amqpDeadletterProperties = amqpDeadletterProperties; this.amqpDeadletterProperties = amqpDeadletterProperties;
this.rabbitConnectionFactory = rabbitConnectionFactory; this.rabbitConnectionFactory = rabbitConnectionFactory;
@@ -76,48 +83,24 @@ public class AmqpConfiguration {
this.serviceMatcher = serviceMatcher; this.serviceMatcher = serviceMatcher;
} }
@Bean
public FatalExceptionStrategy sqlFatalSQLExceptionStrategy(final AmqpProperties amqpProperties) {
return new SqlFatalExceptionStrategy(amqpProperties.getFatalSqlExceptionPolicy());
}
/** /**
* Creates a custom error handler bean. * Creates a custom error handler bean.
* *
* @param handlers list of {@link AmqpErrorHandler} handlers * @param fatalExceptionStrategies list of {@link FatalExceptionStrategy} handlers. isFatal will be called for causes,
* up to the first fatal, so the implementation don't need to iterate over the causes.
* @return the delegating error handler bean * @return the delegating error handler bean
*/ */
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
public ErrorHandler errorHandler(final List<AmqpErrorHandler> handlers) { public ErrorHandler errorHandler(
return new DelegatingConditionalErrorHandler( final List<FatalExceptionStrategy> fatalExceptionStrategies,
handlers, @Value("${hawkbit.dmf.rabbitmq.fatalExceptionTypes:}") final List<String> fatalExceptionTypes) {
new ConditionalRejectingErrorHandler(new DelayedRequeueExceptionStrategy(amqpProperties.getRequeueDelay()))); return new ConditionalRejectingErrorHandler(new RequeueExceptionStrategy(fatalExceptionStrategies, fatalExceptionTypes));
}
/**
* 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();
} }
/** /**
@@ -303,4 +286,39 @@ public class AmqpConfiguration {
args.put("x-max-length", 1_000); args.put("x-max-length", 1_000);
return args; return args;
} }
@ToString
private static class SqlFatalExceptionStrategy implements FatalExceptionStrategy {
private final boolean fatalByDefault;
private final List<Integer> unlessErrorCodeIn;
private final List<String> unlessSqlStateIn;
private final List<Pattern> unlessMessageMatches;
public SqlFatalExceptionStrategy(final AmqpProperties.FatalSqlExceptionPolicy fatalSqlExceptions) {
this.fatalByDefault = fatalSqlExceptions.isByDefault();
this.unlessErrorCodeIn = fatalSqlExceptions.getUnlessErrorCodeIn();
this.unlessSqlStateIn = fatalSqlExceptions.getUnlessSqlStateIn();
this.unlessMessageMatches = fatalSqlExceptions.getUnlessMessageMatches();
}
@Override
public boolean isFatal(final Throwable t) {
if (t instanceof SQLException sqlException) {
if (unlessErrorCodeIn.contains(sqlException.getErrorCode())) {
return !fatalByDefault;
} else if (unlessSqlStateIn.contains(sqlException.getSQLState())) {
return !fatalByDefault;
} else {
for (final Pattern pattern : unlessMessageMatches) {
if (pattern.matcher(sqlException.getMessage()).matches()) {
return !fatalByDefault;
}
}
return fatalByDefault;
}
}
return false;
}
}
} }

View File

@@ -1,25 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
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);
}

View File

@@ -1,60 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.amqp;
import java.util.Iterator;
import java.util.List;
import org.springframework.util.ErrorHandler;
/**
* 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<AmqpErrorHandler> iterator;
private final ErrorHandler defaultHandler;
/**
* Constructor.
*
* @param iterator the {@link AmqpErrorHandler} iterator
* @param defaultHandler the default handler
*/
private AmqpErrorHandlerChain(Iterator<AmqpErrorHandler> 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<AmqpErrorHandler> 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);
}
}
}

View File

@@ -9,19 +9,21 @@
*/ */
package org.eclipse.hawkbit.amqp; package org.eclipse.hawkbit.amqp;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
/** /**
* Bean which holds the necessary properties for configuring the AMQP * Bean which holds the necessary properties for configuring the AMQP connection.
* connection.
*/ */
@Data @Data
@ConfigurationProperties("hawkbit.dmf.rabbitmq") @ConfigurationProperties("hawkbit.dmf.rabbitmq")
public class AmqpProperties { public class AmqpProperties {
private static final int DEFAULT_QUEUE_DECLARATION_RETRIES = 50; private static final int DEFAULT_QUEUE_DECLARATION_RETRIES = 50;
private static final long DEFAULT_REQUEUE_DELAY = 0;
/** /**
* Enable DMF API based on AMQP 0.9 * Enable DMF API based on AMQP 0.9
@@ -44,8 +46,7 @@ public class AmqpProperties {
private String receiverQueue = "dmf_receiver"; private String receiverQueue = "dmf_receiver";
/** /**
* Authentication request called by 3rd party artifact storages for download * Authentication request called by 3rd party artifact storages for download authorizations.
* authorizations.
*/ */
private String authenticationReceiverQueue = "authentication_receiver"; private String authenticationReceiverQueue = "authentication_receiver";
@@ -56,14 +57,37 @@ public class AmqpProperties {
/** /**
* The number of retry attempts when passive queue declaration fails. * The number of retry attempts when passive queue declaration fails.
* Passive queue declaration occurs when the consumer starts or, when * Passive queue declaration occurs when the consumer starts or, when consuming from multiple queues, when not all queues were
* consuming from multiple queues, when not all queues were available during * available during initialization.
* initialization.
*/ */
private int declarationRetries = DEFAULT_QUEUE_DECLARATION_RETRIES; private int declarationRetries = DEFAULT_QUEUE_DECLARATION_RETRIES;
/** /**
* Delay for messages that are requeued in milliseconds. * Represents which {@link }SQLExceptions} should be considered fatal. By default, (without any configuration) it's simply disabled.
*/ */
private long requeueDelay = DEFAULT_REQUEUE_DELAY; private final FatalSqlExceptionPolicy fatalSqlExceptionPolicy = new FatalSqlExceptionPolicy();
@Data
public static class FatalSqlExceptionPolicy {
/**
* The mode of the policy. If set to {@code true}, the every {@link java.sql.SQLException} would be assessed as fatal unless
* matching the filters. Otherwise, every {@link java.sql.SQLException} will be assessed as non-fatal unless matching the filter.
* The {@link java.sql.SQLException} that matches the filters are considered non-fatal if byDefault is {@code true} and fatal otherwise.
*/
private boolean byDefault = false;
/**
* Error codes of the {@link java.sql.SQLException} that will be excluded from the default fatal policy. DB depended.
*/
private final List<Integer> unlessErrorCodeIn = new ArrayList<>();
/**
* SQL states of the {@link java.sql.SQLException} that will be excluded from the default fatal policy. DB depended.
*/
private final List<String> unlessSqlStateIn = new ArrayList<>();
/**
* Java regex message matching patterns. The {@link java.sql.SQLException} with messages matching any of the patterns
* will be excluded from the default fatal policy. DB depended.
*/
private final List<Pattern> unlessMessageMatches = new ArrayList<>();
}
} }

View File

@@ -1,93 +0,0 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.amqp;
import java.util.concurrent.TimeUnit;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException;
import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException;
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
import org.eclipse.hawkbit.repository.exception.InvalidTargetAddressException;
import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException;
import org.eclipse.hawkbit.repository.exception.TenantNotExistException;
import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler;
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
import org.springframework.amqp.support.converter.MessageConversionException;
import org.springframework.messaging.MessageHandlingException;
/**
* Custom {@link FatalExceptionStrategy} that markes defined hawkBit internal
* exceptions not to be requeued. In addition it throttles in case of a requeue
* by means of blocking the processing thread for a certain amount of time. That
* avoids a back and forth between broker and hawkBit at maximum speed.
*/
@Slf4j
public class DelayedRequeueExceptionStrategy extends ConditionalRejectingErrorHandler.DefaultExceptionStrategy {
private final long delay;
/**
* @param delay in {@link TimeUnit#MILLISECONDS} before requeue.
*/
public DelayedRequeueExceptionStrategy(final long delay) {
this.delay = delay;
}
@Override
protected boolean isUserCauseFatal(final Throwable cause) {
if (invalidMessage(cause)) {
return true;
}
log.error("Found a message that has to be requeued. Processing with delay of {}ms: ", delay, cause);
try {
TimeUnit.MILLISECONDS.sleep(delay);
} catch (final InterruptedException e) {
log.error("Delay interrupted!", e);
Thread.currentThread().interrupt();
}
return false;
}
private static boolean invalidMessage(final Throwable cause) {
return doesNotExist(cause) || quotaHit(cause) || invalidContent(cause) || invalidState(cause);
}
private static boolean invalidState(final Throwable cause) {
return cause instanceof CancelActionNotAllowedException;
}
private static boolean quotaHit(final Throwable cause) {
return cause instanceof AssignmentQuotaExceededException;
}
private static boolean doesNotExist(final Throwable cause) {
return cause instanceof TenantNotExistException || cause instanceof EntityNotFoundException;
}
private static boolean invalidContent(final Throwable cause) {
return isRepositoryException(cause) || isMessageException(cause);
}
private static boolean isRepositoryException(final Throwable cause) {
return cause instanceof ConstraintViolationException || cause instanceof InvalidTargetAttributeException;
}
private static boolean isMessageException(final Throwable cause) {
return cause instanceof InvalidTargetAddressException ||
cause instanceof MessageConversionException ||
cause instanceof MessageHandlingException;
}
}

View File

@@ -1,64 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.amqp;
import java.util.List;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
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
*/
@Slf4j
public class DelegatingConditionalErrorHandler implements ErrorHandler {
private final List<AmqpErrorHandler> handlers;
private final ErrorHandler defaultHandler;
/**
* Constructor
*
* @param handlers {@link List} of error handlers
* @param defaultHandler the default error handler
*/
public DelegatingConditionalErrorHandler(final List<AmqpErrorHandler> 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;
}
}

View File

@@ -1,23 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
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<EntityNotFoundException> {
@Override
public Class<EntityNotFoundException> getExceptionClass() {
return EntityNotFoundException.class;
}
}

View File

@@ -1,23 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
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<InvalidTargetAttributeException> {
@Override
public Class<InvalidTargetAttributeException> getExceptionClass() {
return InvalidTargetAttributeException.class;
}
}

View File

@@ -1,41 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
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<MessageConversionException> {
@Override
public Class<MessageConversionException> 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<String> detailedErrorMessage = getFirstAncestralErrorMessage(throwable.getCause());
return detailedErrorMessage.isPresent() ? (detailedErrorMessage.get() + errorMessage) : errorMessage;
}
private Optional<String> getFirstAncestralErrorMessage(final Throwable throwable) {
if (throwable.getCause() instanceof InvalidFormatException) {
return Optional.of(throwable.getCause().getMessage());
}
return Optional.empty();
}
}

View File

@@ -0,0 +1,125 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.amqp;
import java.util.ArrayList;
import java.util.List;
import jakarta.validation.ConstraintViolationException;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException;
import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException;
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
import org.eclipse.hawkbit.repository.exception.InvalidTargetAddressException;
import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException;
import org.eclipse.hawkbit.repository.exception.TenantNotExistException;
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
import org.springframework.amqp.support.converter.MessageConversionException;
import org.springframework.messaging.MessageHandlingException;
import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException;
import org.springframework.util.ObjectUtils;
/**
* Custom {@link FatalExceptionStrategy} that marks defined hawkBit internal exceptions not to be re-queued.
*/
@ToString
@Slf4j
class RequeueExceptionStrategy implements FatalExceptionStrategy {
private final List<FatalExceptionStrategy> fatalExceptionStrategies = new ArrayList<>();
@SuppressWarnings("unchecked")
RequeueExceptionStrategy(final List<FatalExceptionStrategy> fatalExceptionStrategies, final List<String> fatalExceptionTypes) {
this.fatalExceptionStrategies.add(new TypeBasedFatalExceptionStrategy(
// default, see DefaultExceptionStrategy
MessageConversionException.class,
org.springframework.messaging.converter.MessageConversionException.class,
MethodArgumentResolutionException.class, NoSuchMethodException.class, ClassCastException.class,
// invalid state
CancelActionNotAllowedException.class,
// quota hit
AssignmentQuotaExceededException.class,
// does not exist
TenantNotExistException.class, EntityNotFoundException.class,
// is invalid content, repository exception
ConstraintViolationException.class, InvalidTargetAttributeException.class,
// is invalid content, message exception
InvalidTargetAddressException.class, MessageHandlingException.class
));
if (!ObjectUtils.isEmpty(fatalExceptionTypes)) {
// add explicitly configured fatal exception types
fatalExceptionTypes.forEach(type -> {
try {
final Class<?> clazz = Class.forName(type);
if (Throwable.class.isAssignableFrom(clazz)) {
this.fatalExceptionStrategies.add(new TypeBasedFatalExceptionStrategy((Class<? extends Throwable>) clazz));
} else {
log.warn("Fatal exception type {} is not a Throwable", type);
}
} catch (final ClassNotFoundException e) {
log.warn("Could not find class for fatal exception type {}", type);
}
});
}
this.fatalExceptionStrategies.addAll(fatalExceptionStrategies);
log.info("RequeueExceptionStrategy created: {}", this);
}
@Override
public boolean isFatal(final Throwable t) {
for (Throwable cause = t; cause != null; cause = cause.getCause()) {
// default exception from DefaultExceptionStrategy
if (isCauseFatal(cause)) {
return true;
}
}
if (log.isDebugEnabled()) {
log.warn("Found a message that has to be re-queued", t);
} else {
log.warn("Found a message that has to be re-queued: {}", t.getMessage());
}
return false;
}
protected boolean isCauseFatal(final Throwable cause) {
for (final FatalExceptionStrategy handler : fatalExceptionStrategies) {
if (handler.isFatal(cause)) {
return true;
}
}
return false;
}
@ToString
public static class TypeBasedFatalExceptionStrategy implements FatalExceptionStrategy {
private final Class<? extends Throwable>[] types;
@SafeVarargs
public TypeBasedFatalExceptionStrategy(final Class<? extends Throwable>... types) {
this.types = types;
}
@Override
public boolean isFatal(final Throwable cause) {
for (final Class<? extends Throwable> type : types) {
if (type.isAssignableFrom(cause.getClass())) {
return true;
}
}
return false;
}
}
}

View File

@@ -1,83 +0,0 @@
/**
* Copyright (c) 2021 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.amqp;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.util.List;
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;
@Feature("Unit Tests - Delegating Conditional Error Handler")
@Story("Delegating Conditional Error Handler")
class DelegatingAmqpErrorHandlerTest {
private final DelegatingConditionalErrorHandler delegatingConditionalErrorHandler =
new DelegatingConditionalErrorHandler(
List.of(new IllegalArgumentExceptionHandler(), new IndexOutOfBoundsExceptionHandler()),
new DefaultErrorHandler());
@Test
@Description("Verifies that with a list of conditional error handlers, the error is delegated to specific handler.")
void verifyDelegationHandling() {
final Throwable error = new Throwable(new IllegalArgumentException());
assertThatExceptionOfType(IllegalArgumentException.class)
.as("Expected handled exception to be of type IllegalArgumentException")
.isThrownBy(() -> delegatingConditionalErrorHandler.handleError(error));
}
@Test
@Description("Verifies that default handler is used if no handlers are defined for the specific exception.")
void verifyDefaultDelegationHandling() {
final Throwable error = new Throwable(new NullPointerException());
assertThatExceptionOfType(RuntimeException.class)
.as("Expected handled exception to be of type RuntimeException")
.isThrownBy(() -> delegatingConditionalErrorHandler.handleError(error));
}
// Test class
static 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
static 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
static class DefaultErrorHandler implements ErrorHandler {
@Override
public void handleError(final Throwable t) {
throw new RuntimeException(t);
}
}
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.amqp;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import io.qameta.allure.Description;
import io.qameta.allure.Feature;
import io.qameta.allure.Story;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy;
import org.springframework.amqp.support.converter.MessageConversionException;
@Feature("Unit Tests - Requeue Exception Strategy")
@Story("Requeue Exception Strategy")
class RequestExceptionStrategyTest {
private final FatalExceptionStrategy requeueExceptionStrategy = new RequeueExceptionStrategy(
List.of(new RequeueExceptionStrategy.TypeBasedFatalExceptionStrategy(
IllegalArgumentException.class, IndexOutOfBoundsException.class)), null);
@Test
@Description("Verifies that default handler is used if no handlers are defined for the specific exception.")
void verifyDefaultFatal() {
assertThat(requeueExceptionStrategy.isFatal(new MessageConversionException("t"))).as("Non Fatal error").isTrue();
assertThat(requeueExceptionStrategy.isFatal(new Throwable(new MessageConversionException("t")))).as("Non Fatal error").isTrue();
}
@Test
@Description("Verifies additional fatal exception types are fatal.")
void verifyAdditionalFatal() {
assertThat(requeueExceptionStrategy.isFatal(new IllegalArgumentException())).isTrue();
assertThat(requeueExceptionStrategy.isFatal(new IndexOutOfBoundsException())).isTrue();
}
@Test
@Description("Verifies additional fatal exception types are fatal.")
void verifyAdditionalWrappedFatal() {
assertThat(requeueExceptionStrategy.isFatal(new Throwable(new IllegalArgumentException()))).isTrue();
assertThat(requeueExceptionStrategy.isFatal(new Throwable(new IndexOutOfBoundsException()))).isTrue();
}
@Test
@Description("Verifies that default handler is used if no handlers are defined for the specific exception.")
void verifyNonFatal() {
assertThat(requeueExceptionStrategy.isFatal(new NullPointerException())).isFalse();
assertThat(requeueExceptionStrategy.isFatal(new Throwable(new NullPointerException()))).isFalse();
}
}

View File

@@ -31,6 +31,7 @@ import java.util.concurrent.BlockingDeque;
import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -126,6 +127,8 @@ import org.springframework.validation.annotation.Validated;
@Validated @Validated
public class JpaControllerManagement extends JpaActionManagement implements ControllerManagement { public class JpaControllerManagement extends JpaActionManagement implements ControllerManagement {
private static final Pattern PATTERN = Pattern.compile("[a-zA-Z0-9_\\-!@#$%^&*()+=\\[\\]{}|;:'\",.<>/\\\\?\\s]*");
private final BlockingDeque<TargetPoll> queue; private final BlockingDeque<TargetPoll> queue;
// TODO - make it final // TODO - make it final
@@ -197,7 +200,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
final long occurredAt = newActionStatus.getOccurredAt(); final long occurredAt = newActionStatus.getOccurredAt();
switch (updatedActionStatus) { switch (updatedActionStatus) {
case ERROR: { case ERROR: {
final JpaTarget target = (JpaTarget) action.getTarget(); final JpaTarget target = action.getTarget();
target.setUpdateStatus(TargetUpdateStatus.ERROR); target.setUpdateStatus(TargetUpdateStatus.ERROR);
handleErrorOnAction(action, target); handleErrorOnAction(action, target);
break; break;
@@ -512,7 +515,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
if (action.isActive()) { if (action.isActive()) {
log.debug("action ({}) was still active. Change to {}.", action, Status.CANCELING); log.debug("action ({}) was still active. Change to {}.", action, Status.CANCELING);
final JpaAction jpaAction = (JpaAction)action; final JpaAction jpaAction = (JpaAction) action;
jpaAction.setStatus(Status.CANCELING); jpaAction.setStatus(Status.CANCELING);
// document that the status has been retrieved // document that the status has been retrieved
@@ -533,7 +536,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
targetRepository.getAccessController().ifPresent( targetRepository.getAccessController().ifPresent(
accessController -> accessController.assertOperationAllowed( accessController -> accessController.assertOperationAllowed(
AccessController.Operation.UPDATE, AccessController.Operation.UPDATE,
(JpaTarget) actionRepository actionRepository
.findById(actionId) .findById(actionId)
.orElseThrow(() -> new EntityNotFoundException(Action.class, actionId)) .orElseThrow(() -> new EntityNotFoundException(Action.class, actionId))
.getTarget())); .getTarget()));
@@ -615,11 +618,11 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
} }
private static boolean isAttributeKeyValid(final String key) { private static boolean isAttributeKeyValid(final String key) {
return key != null && key.length() <= CONTROLLER_ATTRIBUTE_KEY_SIZE; return key != null && key.length() <= CONTROLLER_ATTRIBUTE_KEY_SIZE && PATTERN.matcher(key).matches();
} }
private static boolean isAttributeValueValid(final String value) { private static boolean isAttributeValueValid(final String value) {
return value == null || value.length() <= CONTROLLER_ATTRIBUTE_VALUE_SIZE; return value == null || (value.length() <= CONTROLLER_ATTRIBUTE_VALUE_SIZE && PATTERN.matcher(value).matches());
} }
private static void copy(final Map<String, String> src, final Map<String, String> trg) { private static void copy(final Map<String, String> src, final Map<String, String> trg) {
@@ -820,7 +823,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
return Optional.empty(); return Optional.empty();
} }
final JpaTarget target = (JpaTarget) action.getTarget(); final JpaTarget target = action.getTarget();
action.setActive(false); action.setActive(false);
action.setStatus(DOWNLOADED); action.setStatus(DOWNLOADED);
target.setUpdateStatus(TargetUpdateStatus.IN_SYNC); target.setUpdateStatus(TargetUpdateStatus.IN_SYNC);
@@ -854,11 +857,11 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
* @return a present controllerId in case the attributes needs to be requested. * @return a present controllerId in case the attributes needs to be requested.
*/ */
private JpaTarget handleFinishedAndStoreInTargetStatus(final long occurredAt, final JpaAction action) { private JpaTarget handleFinishedAndStoreInTargetStatus(final long occurredAt, final JpaAction action) {
final JpaTarget target = (JpaTarget) action.getTarget(); final JpaTarget target = action.getTarget();
action.setActive(false); action.setActive(false);
action.setStatus(Status.FINISHED); action.setStatus(Status.FINISHED);
if (target.getInstallationDate() == null || target.getInstallationDate() < occurredAt) { if (target.getInstallationDate() == null || target.getInstallationDate() < occurredAt) {
final JpaDistributionSet ds = (JpaDistributionSet) entityManager.merge(action.getDistributionSet()); final JpaDistributionSet ds = entityManager.merge(action.getDistributionSet());
target.setInstalledDistributionSet(ds); target.setInstalledDistributionSet(ds);
target.setInstallationDate(occurredAt); target.setInstallationDate(occurredAt);
@@ -866,7 +869,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont
// Target reported an installation of a DOWNLOAD_ONLY assignment, the assigned DS has to be adapted // Target reported an installation of a DOWNLOAD_ONLY assignment, the assigned DS has to be adapted
// because the currently assigned DS can be unequal to the currently installed DS (the downloadOnly DS) // because the currently assigned DS can be unequal to the currently installed DS (the downloadOnly DS)
if (isDownloadOnly(action)) { if (isDownloadOnly(action)) {
target.setAssignedDistributionSet((JpaDistributionSet) action.getDistributionSet()); target.setAssignedDistributionSet(action.getDistributionSet());
} }
// check if the assigned set is equal to the installed set (not // check if the assigned set is equal to the installed set (not

View File

@@ -47,7 +47,6 @@ class RSQLTargetFieldTest extends AbstractJpaIntegrationTest {
@BeforeEach @BeforeEach
void setupBeforeTest() { void setupBeforeTest() {
final DistributionSet ds = testdataFactory.createDistributionSet("AssignedDs"); final DistributionSet ds = testdataFactory.createDistributionSet("AssignedDs");
final Map<String, String> attributes = new HashMap<>(); final Map<String, String> attributes = new HashMap<>();