Files
spring-boot-demo/docs/05-custom-health-indicators.md
Ankur Mhatre 4b6cefa60a Spring Boot 4 Actuator in production: endpoints, security, custom health indicators
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.
2026-09-04 10:40:04 +05:30

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.

  • ordersDatabasejdbc.setQueryTimeout(2)
  • externalApi — connect and read timeouts of 750 ms on the RestClient request factory
  • kafka — four separate Kafka bounds plus an outer KafkaFuture.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 →