rust 120 lines · 3 tabs

Aggregating Subsystem Health Checks Behind an Axum /healthz Endpoint

Shared by codesnips Aug 2026
3 tabs
use async_trait::async_trait;
use serde::Serialize;

#[derive(Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
    Up,
    Degraded,
    Down,
}

#[derive(Serialize)]
pub struct Component {
    pub name: &'static str,
    pub status: Status,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

#[async_trait]
pub trait HealthCheck: Send + Sync {
    fn name(&self) -> &'static str;
    async fn check(&self) -> (Status, Option<String>);
}

pub struct PgCheck {
    pub pool: sqlx::PgPool,
}

#[async_trait]
impl HealthCheck for PgCheck {
    fn name(&self) -> &'static str {
        "postgres"
    }

    async fn check(&self) -> (Status, Option<String>) {
        match sqlx::query("SELECT 1").execute(&self.pool).await {
            Ok(_) => (Status::Up, None),
            Err(e) => (Status::Down, Some(e.to_string())),
        }
    }
}
3 files · rust Explain with highlit

This snippet shows how to build a /healthz endpoint that reports the health of several independent subsystems by probing each one concurrently and merging the results into a single response. The pattern is common in microservices where an orchestrator (Kubernetes, a load balancer) needs one endpoint that answers "is this instance actually serving?" while the service itself may depend on a database, a cache, and downstream HTTP APIs.

The HealthCheck trait defines the contract every probe implements: a stable name for reporting and an async check that returns a Status. Modeling checks as trait objects (Arc<dyn HealthCheck>) keeps the aggregator agnostic to what each subsystem actually does — a Postgres ping and an HTTP HEAD request look identical to the caller. The Status enum distinguishes Up, Degraded, and Down so partial failures don't get flattened into a boolean.

PgCheck is a concrete probe. Notice it doesn't manage its own timeout; instead each probe is expected to be simple and let the aggregator enforce time bounds. This avoids every author reinventing timeout logic and keeps a slow dependency from stalling the whole endpoint.

In HealthRegistry, check_all is the core of the design. It wraps every probe in tokio::time::timeout and drives them with join_all, so all subsystems are queried in parallel and the total latency is bounded by the slowest survivor rather than the sum. A probe that exceeds the deadline is coerced into Status::Down with a timeout reason via unwrap_or, which means a hung dependency degrades the report instead of hanging the request. The overall status is derived by folding component statuses: any Down makes the aggregate Down, any Degraded makes it Degraded, otherwise Up.

health_handler in routes.rs maps that aggregate onto an HTTP status code — 200 for healthy or degraded, 503 when down — so a load balancer can act on the code alone while richer detail rides along in the JSON body. Returning Degraded as 200 is a deliberate trade-off: the instance is imperfect but still worth routing traffic to. This structure scales cleanly — adding a subsystem is just pushing another Arc<dyn HealthCheck> into the registry.


Related snips

Share this code

Here's the card — post it anywhere.

Aggregating Subsystem Health Checks Behind an Axum /healthz Endpoint — share card
Link copied