Error handling across a pipeline of `direct`/`seda` routes in Apache Camel

1 day ago 1
ARTICLE AD BOX

I’m trying to structure my Camel application as a set of reusable routes that follow the Single Responsibility Principle. My goal is to keep concrete routes clean and straightforward—describing the happy path only—while extracting common error handling into an abstract BaseRouteBuilder.

Route pipeline structure

My application is composed of several “types” of routes that call each other:

technical-sender

sender

process

receiver

technical-receiver

A quick description of each type:

technical sender: an entry point, e.g. rest().post("/demo").to("direct:demo-sender") or rest().openApi("demo-v1.json")

sender: unmarshalling, authorization checks, basic validation, calls to("direct:demo-process"), marshals the response

process: maps the request, calls to("direct:demo-receiver"), maps the response

receiver: sets headers, marshals, adds authorization, calls to("direct:demo-technical-receiver"), handles retries

technical receiver: a low-level producer, e.g. from("direct:demo-technical-receiver").to("http:...")

Each type has its own abstract RouteBuilder. Concrete RouteBuilders define only the happy path (no route-specific error handling unless required).

In general there are many process routes (per process/message type), while sender/receiver routes are shared (usually one per external system), and technical routes are shared even more (usually one per protocol like REST/SOAP/FTP).

Problem

The happy path works perfectly, but I’m struggling with error handling.

At any step in any route (validation, mapping, unmarshalling, etc.) an exception may occur. External calls can fail as well.

What I want is a generic error handling mechanism that:

Catches the exception in the route where it happens

Wraps it into a “container” exception (e.g. IntegrationException) using either:

a default wrapper (DefaultExceptionWrapper), or

a route-specific wrapper (ErrorWrapper)

Propagates that wrapped failure backwards through the pipeline

Allows routes to optionally enrich it (e.g. the process route)

Finally, the sender maps it to the external API contract (HTTP status code + response body)

If no special error handling is required, I want everything to stay in the abstract base builders, without cluttering happy paths with doTry() or choice() blocks.

What I tried / why it fails

I spent several days trying different approaches (e.g. onException(Exception.class) and onCompletion().onFailureOnly().modeBeforeConsumer()), but I’m blocked by these issues:

onException is executed only once per Exchange. Even if I set handled(false) and the exchange returns to the calling route (e.g., receiver → process), onException(Exception.class) is not triggered again.

Rethrowing an exception or setting Exchange.setException(...) in an onException block always triggers FatalFallbackErrorHandler and terminates route processing.

If I set errorHandler(noErrorHandler()) to allow propagating the failure to the caller route, it also disables onException in that route, including redelivery.

onCompletion works on a coy of the Exchange and cannot be used to manipulate with the response to the client.

Can anyone help?

Demo project

I created a demo project on GitHub to simulate this structure:
https://github.com/bvv-jmedved/error-hanling-demo

What I would like to achieve: if the simulated HTTP 500 in the technical receiver is returned from the external system, the client should receive 502 Bad Gateway and this body:

{ "errors": [ { "code": "500", "message": "Something wrong occurred in external system" } ] }

Code (for illustration)

=== errorhanlingdemo/ErrorHanlingDemoApplication.java ===

@SpringBootApplication public class ErrorHanlingDemoApplication { public static void main(String[] args) { SpringApplication.run(ErrorHanlingDemoApplication.class, args); } }

=== errorhanlingdemo/builder/common/BaseProcessRouteBuilder.java ===

public abstract class BaseProcessRouteBuilder extends BaseRouteBuilder { }

=== errorhanlingdemo/builder/common/BaseReceiverRouteBuilder.java ===

public abstract class BaseReceiverRouteBuilder extends BaseRouteBuilder { }

=== errorhanlingdemo/builder/common/BaseRouteBuilder.java ===

public abstract class BaseRouteBuilder extends RouteBuilder { @Override public final void configure() { onCompletion().onFailureOnly().modeBeforeConsumer() .log("Handling onCompletion in route: ${routeId} with body: ${body}") .process(getFailureProcessor()); errorHandler(defaultErrorHandler() .logStackTrace(false) .logExhausted(false)); config(); } protected Processor getFailureProcessor() { return exchange -> {}; } protected abstract void config(); }

=== errorhanlingdemo/builder/common/BaseSenderRouteBuilder.java ===

public abstract class BaseSenderRouteBuilder extends BaseRouteBuilder { }

=== errorhanlingdemo/builder/common/BaseTechnicalReceiverRouteBuilder.java ===

public abstract class BaseTechnicalReceiverRouteBuilder extends BaseRouteBuilder { }

=== errorhanlingdemo/builder/common/BaseTechnicalSenderRouteBuilder.java ===

public abstract class BaseTechnicalSenderRouteBuilder extends BaseRouteBuilder { }

=== errorhanlingdemo/builder/process/DemoProcessRouteBuilder.java ===

@Component public class DemoProcessRouteBuilder extends BaseProcessRouteBuilder { @Override protected void config() { from("seda:demo-process") .routeId("demo-process") .log("Processor. Processing request: ${body}") .to("seda:demo-receiver?exchangePattern=InOut") .log("Processor. Processing response: ${body}"); } }

=== errorhanlingdemo/builder/receiver/DemoReceiverRouteBuilder.java ===

@Component public class DemoReceiverRouteBuilder extends BaseReceiverRouteBuilder { @Override protected void config() { onException(HttpOperationFailedException.class) .maximumRedeliveries(2).redeliveryDelay(0) .onRedelivery( exchange -> { System.out.println("Receiver. Getting new token"); }) .handled(false); ; from("seda:demo-receiver") .routeId("demo-receiver") .log("Receiver. Preparing call with request: ${body}") .to("direct:technicalreceiver") .log("Receiver. Handling response: ${body}") ; } }

=== errorhanlingdemo/builder/sender/DemoSenderRouteBuilder.java ===

@Component public class DemoSenderRouteBuilder extends BaseSenderRouteBuilder { @Override public void config() { from("seda:demo-sender") .routeId("demo-sender") .log("Sender. Validating authorizationSending message: ${body}") .log("Sender. Unmarshalling request: ${body}") .to("seda:demo-process?exchangePattern=InOut") .log("Sender. Marshalling response: ${body}"); } }

=== errorhanlingdemo/builder/sender/model/DemoError.java ===

public record DemoError( List<Error> errors ) { public record Error( String code, String message ) { } }

=== errorhanlingdemo/builder/technicalreciever/DemoTechnicalReceiverRouteBuilder.java ===

@Component public class DemoTechnicalReceiverRouteBuilder extends BaseTechnicalReceiverRouteBuilder { @Override protected void config() { errorHandler(noErrorHandler()); from("direct:technicalreceiver") .routeId("technicalreceiver") .log("Technical receiver. Calling target system with message: ${body}") .throwException(new HttpOperationFailedException( "http://fake-receiver", 500, "Something wrong occurred in external system", null, null, "{\"Status\":\"Server Failed\"}" )) .log("Technical receiver. Cleaning headers") ; } }

=== errorhanlingdemo/builder/technicalsender/DemoTechnicalSender.java ===

@Component public class DemoTechnicalSender extends BaseTechnicalSenderRouteBuilder { @Override protected void config() { rest() .post("/demo") .to("seda:demo-sender"); } }

=== errorhanlingdemo/exception/IntegrationError.java ===

public record IntegrationError( String code, String message ) { }

=== errorhanlingdemo/exception/IntegrationException.java ===

@Getter public class IntegrationException extends RuntimeException { private final int statusCode; private final List<IntegrationError> errors; public IntegrationException( int statusCode, String errorCode, String message, Exception cause) { super(message, cause); List<IntegrationError> integrationErrors = List.of( new IntegrationError(errorCode, message)); this.statusCode = statusCode; this.errors = integrationErrors; } public IntegrationException( int statusCode, List<IntegrationError> errors, String message, Exception cause) { super(message, cause); this.statusCode = statusCode; this.errors = List.copyOf(errors); } }
Read Entire Article