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