spring-bootaxon

Spring Returns HTTP Status 200 When Exception is Thrown in Axon


I am throwing a org.springframework.web.server.ResponseStatusException from a command handler that was invoked through a HTTP request in Spring. Usually I would expect the response to carry the HTTP status that is defined by the exception.

However, Spring returns status 200 while Axon logs: Command 'com.my.Command' resulted in com.my.CustomResponseStatusException.

It makes no difference whether I call send or sendAndWait on the command gateway. The controller directly returns the resulting CompletableFuture.

Is there a configuration I am missing?

The exception I am throwing is an extended ResponseStatusException that allows me to provide a custom domain error code in conjunction with a HTTP status error code:

public abstract class DomainException extends ResponseStatusException {

    public final String errorCode;

    public DomainException(HttpStatus status, String errorCode, String message) {
        super(status, message);
        this.errorCode = errorCode;
    }

    public DomainException(HttpStatus status, String errorCode, String message, Throwable cause) {
        super(status, message, cause);
        this.errorCode = errorCode;
    }
}

One of these concrete implementations is:

public class MembershipAlreadyExistsException extends DomainException {

    public MembershipAlreadyExistsException(Id<Member> memberId, Id<Club> clubId) {
        super(HttpStatus.BAD_REQUEST, DomainError.MEMBERSHIP_ALREADY_EXISTS.name(), "There is already a membership of member with ID " + memberId + " and club with ID " + clubId);
    }
}

Even though I am giving custom @ControllerAdvice, none of the exception handler methods are invoked as the exception seems to be caught and logged within the command gateway.

@Order(Ordered.HIGHEST_PRECEDENCE)
@ControllerAdvice
public class DefaultExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(DefaultExceptionHandler.class);

    @ExceptionHandler(UndeclaredThrowableException.class)
    protected ResponseEntity<?> handleUndeclaredException(UndeclaredThrowableException exception) {
        logger.error("Undeclared Exception", exception);
        return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(DomainException.class)
    protected ResponseEntity<?> handleDomainException(DomainException exception) {
        logger.error("Domain Exception", exception);
        return new ResponseEntity<>(exception.errorCode, exception.getStatusCode());
    }

    @ExceptionHandler(JSR303ViolationException.class)
    protected ResponseEntity<?> handleJSR303ViolationException(JSR303ViolationException exception) {
        logger.debug("Request was violating one ore more constraints", exception);
        return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
    }
}

Interestingly, if I do this:

@RequestMapping("/invite")
    public CompletableFuture<Void> enrollMember(@RequestBody InviteMemberByEmail inviteMemberByEmail) {
        try {
            logger.trace("Before sending command");
            CompletableFuture<Void> result = commandGateway.sendAndWait(inviteMemberByEmail);
            logger.trace("After sending command");
            return result;

        } catch (Exception e) {
            logger.trace("Exception caught", e);
            throw e;
        }
    }

The output is:

2024-03-23T09:22:29.073+01:00 TRACE 20808 --- [nio-8081-exec-2] a.z.c.i.http.MemberController            : Before sending command
2024-03-23T09:22:29.930+01:00  WARN 20808 --- [nio-8081-exec-2] o.s.c.annotation.AnnotationTypeMapping   : Support for convention-based annotation attribute overrides is deprecated and will be removed in Spring Framework 6.2. Please annotate the following attributes in @org.axonframework.modelling.command.AggregateIdentifier with appropriate @AliasFor declarations: [routingKey]
2024-03-23T09:22:29.949+01:00  WARN 20808 --- [nio-8081-exec-2] o.a.c.gateway.DefaultCommandGateway      : Command '--redacted--.api.InviteMemberToClub' resulted in --redacted--.domain.exceptions.MembershipAlreadyExistsException(400 BAD_REQUEST "There is already a membership of member with ID f6628294-4021-70d1-89cd-0ee5e7e99c8f and club with ID 780f0cc1-8eaf-4968-bcbd-8f92fb678260")
2024-03-23T09:22:29.950+01:00 TRACE 20808 --- [nio-8081-exec-2] a.z.c.i.http.MemberController            : After sending command

I have confirmed that it is in fact an instance of SimpleCommandBus, but noticed some form of FailureLoggingCallback:

enter image description here


Solution

  • I've been checking the implementation of the SimpleCommandBus and DefaultCommandGateway, but the only scenario when the DefaultCommandGateway would not rethrow the exception, is if the CommandResultMessage is not exceptional.

    Note that the CommandResultMessage is the object Axon Framework creates to carry the result of command handling. Hence, if you throw an exception, it will be captured in the CommandResultMessage. Upon doing so, its state is set to be exceptional.

    Hence, at this moment, I can only conclude that the command handler may be doing something off concerning the exception throwing. Or, the aggregate is capturing the exception somewhere else. But, to be able to deduce that, I would need to ask you to either:

    1. Update your question with the Command Handler, and Any interceptors, or,
    2. To provide a sample project so I am able to reproduce the predicament locally. This would allow me to debug the scenario in question.

    Even though I am throwing the exception in the command handler, changing from a tracking to a subscribing event processor fixes the issue (?)

    Switching a TrackingEventProcessor for a SubscribingEventProcessor would result in the same thread to be used for handling your events. So, the only way how this would resolve it, is if the event handler throws an Exception that does cause the CommandResultMessage to become exceptional.