Companion repository for the ankurm.com article. Every transcript in docs/output/ was produced by running this project; scripts/run-all.sh regenerates all of them. Verified against Spring Boot 4.1.1 / Framework 7.0.9 / Security 7.1.1 / Micrometer 1.17.1 / kafka-clients 4.2.1 on Temurin JDK 25.0.4.1+1.
4.5 KiB
← 04 Securing Actuator · 05 · Custom health indicators · 06 Failure modes →
05 — Custom health indicators
Three indicators, three different jobs.
| Bean | Class | Checks |
|---|---|---|
ordersDatabase |
OrdersDatabaseHealthIndicator |
the query the application depends on |
kafka |
KafkaHealthIndicator |
AdminClient.describeCluster |
externalApi |
ExternalApiHealthIndicator |
an HTTP dependency, with a budget |
The shape
import org.springframework.boot.health.contributor.AbstractHealthIndicator;
import org.springframework.boot.health.contributor.Health;
@Component("ordersDatabase")
public class OrdersDatabaseHealthIndicator extends AbstractHealthIndicator {
@Override
protected void doHealthCheck(Health.Builder builder) throws Exception {
// throw, or call builder.up() / down() / status("DEGRADED")
}
}
Note the package — org.springframework.boot.health.contributor, new in Boot 4. See
chapter 02.
AbstractHealthIndicator over the bare interface, because it catches your exceptions and turns
them into DOWN with the error recorded, rather than letting one throwing indicator take down
the whole /actuator/health response.
The bean name is the JSON key
@Component("ordersDatabase") produces "ordersDatabase" in the response. Boot strips a
trailing HealthIndicator from the bean name, so ordersDatabaseHealthIndicator gives the same
key. Rename the bean and every dashboard, alert and health-group include: that referenced the
old key silently stops matching — which is why
HealthGroupTests asserts on the
names.
Check what the application needs, not what the connection can do
Boot's built-in db indicator runs a validation query and answers "can I reach the database".
That is rarely the question. ordersDatabase runs the query the service actually depends on and
adds a latency judgement:
{"details":{"orders":3,"queryMs":0,"slowThresholdMs":250},"status":"UP"}
It returns a custom DEGRADED status above the threshold. Custom statuses are legal;
management.endpoint.health.status.order and .http-mapping control how they aggregate and
what HTTP code they produce. An unmapped custom status aggregates as UNKNOWN and maps to 200,
which is usually not what you meant.
Every indicator needs a budget
All three carry an explicit timeout, and the reason is chapter 06.
ordersDatabase—jdbc.setQueryTimeout(2)externalApi— connect and read timeouts of 750 ms on theRestClientrequest factorykafka— four separate Kafka bounds plus an outerKafkaFuture.get(timeout)
Credentials
externalApi sends HTTP Basic. The first captured run of this repository did not, and reported:
{"error":"HttpClientErrorException$Unauthorized: 401 : [no body]","status":"DOWN"}
which looks exactly like an upstream outage and was a missing header. If your indicator authenticates, make the failure message distinguish "they are down" from "we are unauthorised" — future you will be reading it at 3am.
Reuse the client
KafkaHealthIndicator creates one AdminClient in its constructor and closes it in
close(). Creating one per probe opens a fresh set of broker connections on every probe
interval; across a fleet of any size that is a denial-of-service against your own brokers.
What it all looks like
output/07-custom-health-indicators.txt has the full
nine-component response. Flipping the stub upstream to DOWN and changing nothing else:
{
"status": "DOWN",
"externalApi": {
"details": {
"error": "HttpServerErrorException$ServiceUnavailable: 503 : \"upstream unavailable\"",
"url": "http://localhost:8080/stub/upstream/ping",
"latencyMs": 101,
"timeoutMs": 750
},
"status": "DOWN"
}
}
/actuator/health now answers 503. If that URL is your readiness probe, every pod in the
deployment just left the load balancer because a third party had a bad minute.
Chapter 07 fixes that.
← 04 · 05 · 06 Failure modes →