This is an automated email from the ASF dual-hosted git repository. fmariani pushed a commit to branch ci-fix/test-infra-container-retry in repository https://gitbox.apache.org/repos/asf/camel.git
commit 9e6a049ba9d2249e7fbff69e61eb96a9dc9b9a5f Author: Croway <[email protected]> AuthorDate: Thu Apr 2 13:49:24 2026 +0200 ci: retry test-infra container initialization on transient failures Add retry with exponential backoff and jitter to TestServiceUtil.tryInitialize() for ContainerFetchException (image pull 404) and ContainerLaunchException (container start timeout). Configurable via system properties camel.test.infra.container.retries (default 3) and camel.test.infra.container.retry.delay.ms (default 5000). --- .../infra/common/services/TestServiceUtil.java | 40 ++++++- .../common/services/TestServiceUtilRetryTest.java | 133 +++++++++++++++++++++ 2 files changed, 168 insertions(+), 5 deletions(-) diff --git a/test-infra/camel-test-infra-common/src/main/java/org/apache/camel/test/infra/common/services/TestServiceUtil.java b/test-infra/camel-test-infra-common/src/main/java/org/apache/camel/test/infra/common/services/TestServiceUtil.java index ea40bb13e337..2460c4083a6f 100644 --- a/test-infra/camel-test-infra-common/src/main/java/org/apache/camel/test/infra/common/services/TestServiceUtil.java +++ b/test-infra/camel-test-infra-common/src/main/java/org/apache/camel/test/infra/common/services/TestServiceUtil.java @@ -17,33 +17,63 @@ package org.apache.camel.test.infra.common.services; +import java.util.concurrent.ThreadLocalRandom; + import org.junit.jupiter.api.extension.ExtensionContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.testcontainers.containers.ContainerFetchException; +import org.testcontainers.containers.ContainerLaunchException; /** * Utility class for the test services */ public final class TestServiceUtil { private static final Logger LOG = LoggerFactory.getLogger(TestServiceUtil.class); + private static final int MAX_RETRIES = Integer.getInteger("camel.test.infra.container.retries", 3); + private static final long BASE_DELAY_MS = Long.getLong("camel.test.infra.container.retry.delay.ms", 5000); private TestServiceUtil() { } /** - * Try to initialize the service, logging failures if they happen + * Try to initialize the service with retry for transient container errors. Retries on + * {@link ContainerFetchException} (image pull failures) and {@link ContainerLaunchException} (container start + * failures) with exponential backoff and jitter. Non-container exceptions fail immediately. Retry count and delay + * are configurable via system properties {@code camel.test.infra.container.retries} (default 3) and + * {@code camel.test.infra.container.retry.delay.ms} (default 5000). * * @param service the service to initialize * @param extensionContext JUnit's extension context * @throws Exception exception thrown while initializing (if any) */ public static void tryInitialize(TestService service, ExtensionContext extensionContext) throws Exception { - try { - service.initialize(); - } catch (Exception e) { - logAndRethrow(service, extensionContext, e); + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + service.initialize(); + return; + } catch (Exception e) { + if (attempt < MAX_RETRIES && isRetryableContainerException(e)) { + long jitter = ThreadLocalRandom.current().nextLong(0, BASE_DELAY_MS / 2); + long delay = BASE_DELAY_MS * attempt + jitter; + LOG.warn("Service {} initialization failed (attempt {}/{}), retrying in {}ms: {}", + service.getClass().getSimpleName(), attempt, MAX_RETRIES, delay, e.getMessage()); + Thread.sleep(delay); + } else { + logAndRethrow(service, extensionContext, e); + } + } + } + } + + private static boolean isRetryableContainerException(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof ContainerFetchException || t instanceof ContainerLaunchException) { + return true; + } } + return false; } /** diff --git a/test-infra/camel-test-infra-common/src/test/java/org/apache/camel/test/infra/common/services/TestServiceUtilRetryTest.java b/test-infra/camel-test-infra-common/src/test/java/org/apache/camel/test/infra/common/services/TestServiceUtilRetryTest.java new file mode 100644 index 000000000000..bffdbad7e6a6 --- /dev/null +++ b/test-infra/camel-test-infra-common/src/test/java/org/apache/camel/test/infra/common/services/TestServiceUtilRetryTest.java @@ -0,0 +1,133 @@ +/* + * 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.test.infra.common.services; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ContainerFetchException; +import org.testcontainers.containers.ContainerLaunchException; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TestServiceUtilRetryTest { + + @Test + void retriesOnContainerFetchException() { + AtomicInteger attempts = new AtomicInteger(); + + TestService service = new StubTestService(() -> { + if (attempts.incrementAndGet() < 3) { + throw new ContainerFetchException("404: image not found"); + } + }); + + assertDoesNotThrow(() -> TestServiceUtil.tryInitialize(service, null)); + assertEquals(3, attempts.get()); + } + + @Test + void retriesOnContainerLaunchException() { + AtomicInteger attempts = new AtomicInteger(); + + TestService service = new StubTestService(() -> { + if (attempts.incrementAndGet() < 2) { + throw new ContainerLaunchException("Container startup failed"); + } + }); + + assertDoesNotThrow(() -> TestServiceUtil.tryInitialize(service, null)); + assertEquals(2, attempts.get()); + } + + @Test + void failsImmediatelyOnNonContainerException() { + AtomicInteger attempts = new AtomicInteger(); + + TestService service = new StubTestService(() -> { + attempts.incrementAndGet(); + throw new IllegalStateException("not a container error"); + }); + + assertThrows(IllegalStateException.class, () -> TestServiceUtil.tryInitialize(service, null)); + assertEquals(1, attempts.get()); + } + + @Test + void failsAfterMaxRetries() { + AtomicInteger attempts = new AtomicInteger(); + + TestService service = new StubTestService(() -> { + attempts.incrementAndGet(); + throw new ContainerFetchException("always fails"); + }); + + assertThrows(ContainerFetchException.class, () -> TestServiceUtil.tryInitialize(service, null)); + assertEquals(3, attempts.get()); + } + + @Test + void retriesOnWrappedContainerException() { + AtomicInteger attempts = new AtomicInteger(); + + TestService service = new StubTestService(() -> { + if (attempts.incrementAndGet() < 2) { + throw new RuntimeException("wrapper", new ContainerLaunchException("nested container error")); + } + }); + + assertDoesNotThrow(() -> TestServiceUtil.tryInitialize(service, null)); + assertEquals(2, attempts.get()); + } + + @Test + void succeedsOnFirstAttempt() { + AtomicInteger attempts = new AtomicInteger(); + + TestService service = new StubTestService(attempts::incrementAndGet); + + assertDoesNotThrow(() -> TestServiceUtil.tryInitialize(service, null)); + assertEquals(1, attempts.get()); + } + + private static class StubTestService implements TestService { + private final Runnable action; + + StubTestService(Runnable action) { + this.action = action; + } + + @Override + public void initialize() { + action.run(); + } + + @Override + public void registerProperties() { + } + + @Override + public void shutdown() { + } + + @Override + public void close() { + } + } +}
