From c7bcee6fe859e2e6ab6c8e56001105367f3b6fb4 Mon Sep 17 00:00:00 2001 From: Harshit Date: Wed, 8 Apr 2026 23:22:40 +0530 Subject: [PATCH 1/2] Fix: apply configured monitor timeout to StatusUpdater and add tests (#5147) --- .../config/AdminServerAutoConfiguration.java | 21 +++++++++++++--- .../AdminServerAutoConfigurationTest.java | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java index 1739db2fd7a..70c646678b0 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfiguration.java @@ -100,7 +100,22 @@ public InstanceIdGenerator instanceIdGenerator() { @ConditionalOnMissingBean public StatusUpdater statusUpdater(InstanceRepository instanceRepository, InstanceWebClient.Builder instanceWebClientBuilder) { - return new StatusUpdater(instanceRepository, instanceWebClientBuilder.build(), new ApiMediaTypeHandler()); + + StatusUpdater updater = new StatusUpdater(instanceRepository, instanceWebClientBuilder.build(), + new ApiMediaTypeHandler()); + + AdminServerProperties.MonitorProperties monitorProperties = this.adminServerProperties.getMonitor(); + + Duration timeout = monitorProperties.getDefaultTimeout(); + Duration interval = monitorProperties.getStatusInterval(); + + if (timeout.compareTo(interval) > 0) { + timeout = interval; + } + + updater.timeout(timeout); + + return updater; } @Bean(initMethod = "start", destroyMethod = "stop") @@ -117,8 +132,8 @@ public StatusUpdateTrigger statusUpdateTrigger(StatusUpdater statusUpdater, Publ defaultTimeout, statusInterval); } - return new StatusUpdateTrigger(statusUpdater, events, monitorProperties.getStatusInterval(), - monitorProperties.getStatusLifetime(), monitorProperties.getStatusMaxBackoff()); + return new StatusUpdateTrigger(statusUpdater, events, statusInterval, monitorProperties.getStatusLifetime(), + monitorProperties.getStatusMaxBackoff()); } @Bean diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java index 0fb44f63930..c8519e46a27 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java @@ -16,6 +16,8 @@ package de.codecentric.boot.admin.server.config; +import java.time.Duration; + import com.hazelcast.config.Config; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -36,6 +38,7 @@ import de.codecentric.boot.admin.server.notify.MailNotifier; import de.codecentric.boot.admin.server.notify.NotificationTrigger; import de.codecentric.boot.admin.server.notify.Notifier; +import de.codecentric.boot.admin.server.services.StatusUpdater; import static org.assertj.core.api.Assertions.assertThat; @@ -64,6 +67,28 @@ void hazelcastConfig() { }); } + @Test + void shouldApplyConfiguredTimeoutFromProperties() { + this.contextRunner + .withPropertyValues("spring.boot.admin.monitor.default-timeout=5s", + "spring.boot.admin.monitor.status-interval=10s") + .run((context) -> { + StatusUpdater updater = context.getBean(StatusUpdater.class); + assertThat(updater).extracting("timeout").isEqualTo(Duration.ofSeconds(5)); + }); + } + + @Test + void shouldClampTimeoutToInterval() { + this.contextRunner + .withPropertyValues("spring.boot.admin.monitor.default-timeout=20s", + "spring.boot.admin.monitor.status-interval=10s") + .run((context) -> { + StatusUpdater updater = context.getBean(StatusUpdater.class); + assertThat(updater).extracting("timeout").isEqualTo(Duration.ofSeconds(10)); + }); + } + public static class TestHazelcastConfig { @Bean From a6aa9dcb6887e6844efb75e5d6b11b2c291f1d5c Mon Sep 17 00:00:00 2001 From: ulrichschulte Date: Fri, 10 Apr 2026 12:25:13 +0200 Subject: [PATCH 2/2] #5147: Integrationtest for monitor timeout added --- .../server/MonitorTimeoutIntegrationTest.java | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/MonitorTimeoutIntegrationTest.java diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/MonitorTimeoutIntegrationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/MonitorTimeoutIntegrationTest.java new file mode 100644 index 00000000000..c9d266f6b06 --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/MonitorTimeoutIntegrationTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2014-2024 the original author or authors. + * + * Licensed 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 + * + * https://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 de.codecentric.boot.admin.server; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; + +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.ReactiveHealthIndicator; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.config.EnableAdminServer; +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.services.InstanceRegistry; +import de.codecentric.boot.admin.server.services.StatusUpdater; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests verifying that the monitor timeout configured via + * {@code spring.boot.admin.monitor.default-timeout} is actually applied by + * {@link StatusUpdater} when polling the health endpoint (see issue #5147). + * + *

+ * The test starts a minimal admin server on a random port. It registers an instance whose + * health endpoint is backed by a {@link SlowHealthIndicator} and directly invokes + * {@link StatusUpdater#updateStatus} to avoid waiting for the polling interval. + * + *

+ * + *

+ * The configured {@code default-timeout} is 3 s. {@code StatusUpdater} subtracts a 1 s + * margin before passing it to the WebClient, so the effective read timeout is 2 s. + */ +@SpringBootTest(classes = MonitorTimeoutIntegrationTest.TestAdminApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { "spring.main.web-application-type=reactive", "spring.boot.admin.monitor.default-timeout=3s", + "spring.boot.admin.monitor.status-interval=60s", "spring.boot.admin.monitor.status-lifetime=60s", + "management.endpoints.web.exposure.include=health" }) +class MonitorTimeoutIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private SlowHealthIndicator slowHealthIndicator; + + @Autowired + private StatusUpdater statusUpdater; + + @Autowired + private InstanceRegistry instanceRegistry; + + @Autowired + private InstanceRepository instanceRepository; + + private InstanceId instanceId; + + @BeforeEach + void setUp() { + this.slowHealthIndicator.setDelaySeconds(0); + String healthUrl = "http://localhost:" + this.port + "/actuator/health"; + this.instanceId = this.instanceRegistry.register(Registration.create("timeout-test", healthUrl).build()) + .block(); + } + + @AfterEach + void tearDown() { + this.slowHealthIndicator.setDelaySeconds(0); + if (this.instanceId != null) { + this.instanceRegistry.deregister(this.instanceId).block(); + } + } + + @Test + void instanceIsUpWhenHealthRespondsWithinConfiguredTimeout() { + triggerStatusUpdate(); + + assertThat(findInstance().getStatusInfo().getStatus()).isEqualTo("UP"); + } + + @Test + void instanceGoesOfflineWhenHealthExceedsConfiguredTimeout() { + // 5 s delay exceeds the 2 s effective WebClient timeout (3 s configured − 1 s + // margin). + this.slowHealthIndicator.setDelaySeconds(5); + triggerStatusUpdate(); + + assertThat(findInstance().getStatusInfo().getStatus()).isEqualTo("OFFLINE"); + } + + @Test + void instanceRecoversWhenHealthBecomesResponsiveAgain() { + // Step 1: trigger a timeout → OFFLINE. + this.slowHealthIndicator.setDelaySeconds(5); + triggerStatusUpdate(); + assertThat(findInstance().getStatusInfo().getStatus()).isEqualTo("OFFLINE"); + + // Step 2: remove the delay → health responds fast → UP again. + this.slowHealthIndicator.setDelaySeconds(0); + triggerStatusUpdate(); + assertThat(findInstance().getStatusInfo().getStatus()).isEqualTo("UP"); + } + + private void triggerStatusUpdate() { + this.statusUpdater.updateStatus(this.instanceId).block(Duration.ofSeconds(15)); + } + + private Instance findInstance() { + return this.instanceRepository.find(this.instanceId).block(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EnableAdminServer + static class TestAdminApplication { + + @Bean + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + return http.authorizeExchange((authorizeExchange) -> authorizeExchange.anyExchange().permitAll()) + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .build(); + } + + @Bean + SlowHealthIndicator slowHealthIndicator() { + return new SlowHealthIndicator(); + } + + } + + /** + * A reactive health indicator that introduces a configurable delay before responding. + * Used to simulate a slow downstream health endpoint for timeout verification. + */ + static class SlowHealthIndicator implements ReactiveHealthIndicator { + + private final AtomicLong delaySeconds = new AtomicLong(0); + + @Override + public @NonNull Mono health() { + long delay = this.delaySeconds.get(); + if (delay <= 0) { + return Mono.just(Health.up().build()); + } + return Mono.delay(Duration.ofSeconds(delay)).map((tick) -> Health.up().build()); + } + + void setDelaySeconds(long seconds) { + this.delaySeconds.set(Math.max(0, seconds)); + } + + } + +}