From 68cfcbbfc678d7c9a986e7ab09ef5c619eb80ec8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:07:17 +0900 Subject: [PATCH 1/2] test(operations): add RED liveness-readiness contract --- .../controller/HealthControllerTest.java | 95 +++++++++++++++++-- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/HealthControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/HealthControllerTest.java index 4a8be8f7..f6cc1b7a 100644 --- a/src/test/java/com/clearfolio/viewer/controller/HealthControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/HealthControllerTest.java @@ -1,19 +1,100 @@ package com.clearfolio.viewer.controller; -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; +import org.springframework.boot.availability.ApplicationAvailability; +import org.springframework.boot.availability.LivenessState; +import org.springframework.boot.availability.ReadinessState; +import org.springframework.test.web.reactive.server.WebTestClient; +/** + * Verifies that liveness and readiness expose different operational states. + */ class HealthControllerTest { @Test - void healthControllerReturnsOkPayload() { - final HealthController controller = new HealthController(); + void livenessReturnsOkWhenTheApplicationCanRecover() { + ApplicationAvailability availability = availability( + LivenessState.CORRECT, + ReadinessState.ACCEPTING_TRAFFIC + ); + + client(availability).get() + .uri("/healthz") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Cache-Control", "no-store") + .expectBody(String.class) + .isEqualTo("{\"status\":\"ok\"}"); + } + + @Test + void livenessReturnsServiceUnavailableForAnUnrecoverableApplication() { + ApplicationAvailability availability = availability( + LivenessState.BROKEN, + ReadinessState.ACCEPTING_TRAFFIC + ); - final Map response = controller.health(); + client(availability).get() + .uri("/healthz") + .exchange() + .expectStatus().isEqualTo(503) + .expectHeader().valueEquals("Cache-Control", "no-store") + .expectBody(String.class) + .isEqualTo("{\"status\":\"broken\"}"); + } + + @Test + void readinessReturnsOkOnlyWhileTrafficCanBeAccepted() { + ApplicationAvailability availability = availability( + LivenessState.CORRECT, + ReadinessState.ACCEPTING_TRAFFIC + ); + + client(availability).get() + .uri("/readyz") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Cache-Control", "no-store") + .expectBody(String.class) + .isEqualTo("{\"status\":\"ready\"}"); + } + + @Test + void readinessReturnsServiceUnavailableWhileTrafficIsRefused() { + ApplicationAvailability availability = availability( + LivenessState.CORRECT, + ReadinessState.REFUSING_TRAFFIC + ); + + client(availability).get() + .uri("/readyz") + .exchange() + .expectStatus().isEqualTo(503) + .expectHeader().valueEquals("Cache-Control", "no-store") + .expectBody(String.class) + .isEqualTo("{\"status\":\"not_ready\"}"); + } + + @Test + void controllerRejectsMissingAvailabilityStateProvider() { + assertThrows(NullPointerException.class, () -> new HealthController(null)); + } + + private static ApplicationAvailability availability( + LivenessState livenessState, + ReadinessState readinessState + ) { + ApplicationAvailability availability = mock(ApplicationAvailability.class); + when(availability.getLivenessState()).thenReturn(livenessState); + when(availability.getReadinessState()).thenReturn(readinessState); + return availability; + } - assertThat(response).containsEntry("status", "ok"); + private static WebTestClient client(ApplicationAvailability availability) { + return WebTestClient.bindToController(new HealthController(availability)).build(); } } From 285c56a8f58c489a39cb28ac516759a5dbcae5f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:13:52 +0900 Subject: [PATCH 2/2] feat(operations): separate liveness and readiness probes --- .../viewer/controller/HealthController.java | 73 +++++++++++++++---- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/HealthController.java b/src/main/java/com/clearfolio/viewer/controller/HealthController.java index 9975524c..dcdca47b 100644 --- a/src/main/java/com/clearfolio/viewer/controller/HealthController.java +++ b/src/main/java/com/clearfolio/viewer/controller/HealthController.java @@ -1,36 +1,81 @@ package com.clearfolio.viewer.controller; import java.util.Map; +import java.util.Objects; +import org.springframework.boot.availability.ApplicationAvailability; +import org.springframework.boot.availability.LivenessState; +import org.springframework.boot.availability.ReadinessState; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** - * Lightweight endpoint used for process-liveness checks. + * Exposes separate liveness and readiness probes on the application port. * - *

This endpoint deliberately reports only whether the application process can - * answer requests. Traffic-readiness semantics are introduced separately so an - * orchestrator never confuses restart eligibility with dependency readiness.

+ *

Liveness answers whether this process can continue operating or needs a + * restart. Readiness answers whether the instance should receive traffic. The + * two signals deliberately remain separate so a temporary readiness failure + * does not trigger a restart cascade.

*/ @RestController -@RequestMapping("/healthz") public class HealthController { + private final ApplicationAvailability applicationAvailability; + /** - * Creates the stateless liveness controller. + * Creates the probe controller from Spring Boot's availability state. + * + * @param applicationAvailability current application availability provider */ - public HealthController() { - // No mutable state or external dependency belongs in the liveness path. + public HealthController(ApplicationAvailability applicationAvailability) { + this.applicationAvailability = Objects.requireNonNull( + applicationAvailability, + "applicationAvailability" + ); } /** - * Returns a static health payload when the service is alive. + * Returns the process liveness state. * - * @return health status payload + * @return {@code 200} with {@code status=ok} when the process can recover, + * otherwise {@code 503} with {@code status=broken} */ - @GetMapping - public Map health() { - return Map.of("status", "ok"); + @GetMapping("/healthz") + public ResponseEntity> liveness() { + return availabilityResponse( + applicationAvailability.getLivenessState() == LivenessState.CORRECT, + "ok", + "broken" + ); + } + + /** + * Returns whether this instance is ready to accept traffic. + * + * @return {@code 200} with {@code status=ready} while accepting traffic, + * otherwise {@code 503} with {@code status=not_ready} + */ + @GetMapping("/readyz") + public ResponseEntity> readiness() { + return availabilityResponse( + applicationAvailability.getReadinessState() == ReadinessState.ACCEPTING_TRAFFIC, + "ready", + "not_ready" + ); + } + + private static ResponseEntity> availabilityResponse( + boolean available, + String availableStatus, + String unavailableStatus + ) { + HttpStatus responseStatus = available ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE; + String statusValue = available ? availableStatus : unavailableStatus; + return ResponseEntity.status(responseStatus) + .cacheControl(CacheControl.noStore()) + .body(Map.of("status", statusValue)); } }