diff --git a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/RouteService.java b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/RouteService.java index 246034c73e36b..58ce5ff5076e2 100644 --- a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/RouteService.java +++ b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/RouteService.java @@ -127,7 +127,7 @@ public void warmUp() throws FailedToStartRouteException { try { doWarmUp(); } catch (Exception e) { - throw new FailedToStartRouteException(getId(), getLocation(), e.getLocalizedMessage(), e); + throw new FailedToStartRouteException(getId(), getLocation(), extractUsefulMessage(e), e); } } @@ -136,11 +136,32 @@ public void setUp() throws FailedToStartRouteException { try { doSetup(); } catch (Exception e) { - throw new FailedToStartRouteException(getId(), getLocation(), e.getLocalizedMessage(), e); + throw new FailedToStartRouteException(getId(), getLocation(), extractUsefulMessage(e), e); } } } + /** + * Extracts a non-null, non-empty error message from the exception or its cause chain. + *

+ * {@link Throwable#getLocalizedMessage()} can return {@code null} for exceptions such as + * {@link NullPointerException} that carry no message, which would cause {@link FailedToStartRouteException} to + * throw {@link NullPointerException} from its own constructor (via {@code Objects.requireNonNull}) instead of + * wrapping the original failure. This helper walks the cause chain to find the first meaningful message and falls + * back to the simple class name so the caller always receives a non-null string. + */ + private static String extractUsefulMessage(Throwable e) { + Throwable current = e; + while (current != null) { + String msg = current.getLocalizedMessage(); + if (msg != null && !msg.isBlank()) { + return msg; + } + current = current.getCause(); + } + return e.getClass().getSimpleName(); + } + public boolean isAutoStartup() { if (!getCamelContext().isAutoStartup()) { return false; diff --git a/core/camel-core/src/test/java/org/apache/camel/impl/engine/RouteServiceWarmUpNullMessageTest.java b/core/camel-core/src/test/java/org/apache/camel/impl/engine/RouteServiceWarmUpNullMessageTest.java new file mode 100644 index 0000000000000..9ec78dccd3068 --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/impl/engine/RouteServiceWarmUpNullMessageTest.java @@ -0,0 +1,264 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * http://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 org.apache.camel.impl.engine; + +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.Consumer; +import org.apache.camel.Endpoint; +import org.apache.camel.Exchange; +import org.apache.camel.FailedToStartRouteException; +import org.apache.camel.Processor; +import org.apache.camel.Producer; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.support.DefaultComponent; +import org.apache.camel.support.DefaultConsumer; +import org.apache.camel.support.DefaultEndpoint; +import org.apache.camel.support.DefaultProducer; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies that {@link RouteService#warmUp()} and {@link RouteService#setUp()} wrap startup failures in a + * {@link FailedToStartRouteException} whose message is always meaningful — even when the root cause exception carries a + * {@code null} message (e.g. a bare {@link NullPointerException}). + * + *

+ * Before the fix, {@code RouteService} passed {@code e.getLocalizedMessage()} directly to the + * {@link FailedToStartRouteException} constructor, which calls {@code Objects.requireNonNull} on that argument. A + * message-less exception therefore caused a secondary {@link NullPointerException} to be thrown from inside the + * exception constructor rather than a proper {@link FailedToStartRouteException}. + * + *

+ * The tests trigger the failure during endpoint initialisation (inside {@code doSetup()}), which is the code path + * covered by the {@code RouteService} fix. + */ +class RouteServiceWarmUpNullMessageTest { + + /** + * When the endpoint throws a message-less {@link NullPointerException} during route setup, the result must be a + * {@link FailedToStartRouteException}, not a raw NPE. + */ + @Test + void testSetUpNullMessageExceptionProducesFailedToStartRouteException() { + CamelContext context = new DefaultCamelContext(); + context.addComponent("fail", new NullMessageFailComponent()); + + assertThatThrownBy(() -> { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("fail:trigger").routeId("test-route").to("direct:out"); + } + }); + context.start(); + }).isInstanceOf(FailedToStartRouteException.class); + } + + /** + * The {@link FailedToStartRouteException} message must contain the route id and must not use the literal string + * "null" as the failure description. + */ + @Test + void testFailedToStartMessageIsNonNullAndMeaningful() { + CamelContext context = new DefaultCamelContext(); + context.addComponent("fail", new NullMessageFailComponent()); + + FailedToStartRouteException caught = null; + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("fail:trigger").routeId("meaningful-route").to("direct:out"); + } + }); + context.start(); + } catch (FailedToStartRouteException e) { + caught = e; + } catch (Exception e) { + Throwable t = e; + while (t != null) { + if (t instanceof FailedToStartRouteException ftsre) { + caught = ftsre; + break; + } + t = t.getCause(); + } + } finally { + try { + context.stop(); + } catch (Exception ignored) { + } + } + + assertThat(caught).as("Expected FailedToStartRouteException").isNotNull(); + assertThat(caught.getMessage()) + .as("FailedToStartRouteException message must not be null") + .isNotNull() + .as("Message must contain the route id") + .contains("meaningful-route") + .as("Message must not contain 'because: null'") + .doesNotContain("because: null"); + } + + /** + * When the endpoint throws a message-less outer exception wrapping an inner exception that has a message, the inner + * message must be surfaced in the {@link FailedToStartRouteException}. + */ + @Test + void testSetUpWalksCauseChainForMessage() { + CamelContext context = new DefaultCamelContext(); + String expectedFragment = "real cause message from chain"; + context.addComponent("fail", new ChainedNullMessageFailComponent(expectedFragment)); + + FailedToStartRouteException caught = null; + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("fail:trigger").routeId("chain-route").to("direct:out"); + } + }); + context.start(); + } catch (FailedToStartRouteException e) { + caught = e; + } catch (Exception e) { + Throwable t = e; + while (t != null) { + if (t instanceof FailedToStartRouteException ftsre) { + caught = ftsre; + break; + } + t = t.getCause(); + } + } finally { + try { + context.stop(); + } catch (Exception ignored) { + } + } + + assertThat(caught).as("Expected FailedToStartRouteException").isNotNull(); + assertThat(caught.getMessage()) + .as("Message should surface cause chain message") + .contains(expectedFragment); + } + + // ---- helpers ---- + + /** + * A component whose endpoint throws a message-less {@link NullPointerException} during its own {@code doStart()} — + * which is invoked by {@code RouteService.doSetup()} via {@code ServiceHelper.initService(endpoint)}, exercising + * the {@code setUp()} fix. + */ + private static class NullMessageFailComponent extends DefaultComponent { + @Override + protected Endpoint createEndpoint(String uri, String remaining, Map parameters) { + return new NullMessageFailEndpoint(uri, this); + } + } + + private static class NullMessageFailEndpoint extends DefaultEndpoint { + NullMessageFailEndpoint(String uri, NullMessageFailComponent component) { + super(uri, component); + } + + @Override + protected void doStart() { + throw new NullPointerException(); + } + + @Override + public Consumer createConsumer(Processor processor) { + return new DefaultConsumer(this, processor) { + }; + } + + @Override + public Producer createProducer() { + return new DefaultProducer(this) { + @Override + public void process(Exchange exchange) { + } + }; + } + + @Override + public boolean isSingleton() { + return true; + } + } + + /** + * A component whose endpoint throws a message-less outer exception wrapping an inner exception that does have a + * message — used to test cause-chain walking in {@code extractUsefulMessage}. + */ + private static class ChainedNullMessageFailComponent extends DefaultComponent { + private final String causeMessage; + + ChainedNullMessageFailComponent(String causeMessage) { + this.causeMessage = causeMessage; + } + + @Override + protected Endpoint createEndpoint(String uri, String remaining, Map parameters) { + return new ChainedNullMessageFailEndpoint(uri, this, causeMessage); + } + } + + private static class ChainedNullMessageFailEndpoint extends DefaultEndpoint { + private final String causeMessage; + + ChainedNullMessageFailEndpoint(String uri, ChainedNullMessageFailComponent component, String causeMessage) { + super(uri, component); + this.causeMessage = causeMessage; + } + + @Override + protected void doStart() { + // Outer NPE has no message; initCause sets the cause without supplying a message to + // the outer exception — forces extractUsefulMessage to walk the chain to find causeMessage. + NullPointerException outer = new NullPointerException(); + outer.initCause(new IllegalStateException(causeMessage)); + throw outer; + } + + @Override + public Consumer createConsumer(Processor processor) { + return new DefaultConsumer(this, processor) { + }; + } + + @Override + public Producer createProducer() { + return new DefaultProducer(this) { + @Override + public void process(Exchange exchange) { + } + }; + } + + @Override + public boolean isSingleton() { + return true; + } + } +}