Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
* <p>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.</p>
*/
@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<String, String> health() {
return Map.of("status", "ok");
@GetMapping("/healthz")
public ResponseEntity<Map<String, String>> 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<Map<String, String>> readiness() {
return availabilityResponse(
applicationAvailability.getReadinessState() == ReadinessState.ACCEPTING_TRAFFIC,
"ready",
"not_ready"
);
}

private static ResponseEntity<Map<String, String>> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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();
}
}
Loading