Framework Guide

Rust API Monitoring

Set up health check endpoints and uptime monitoring for your Rust API. Covers Actix-web, Axum, serde responses, and database health checks with sqlx and diesel.

Why Monitor Your Rust API?

Rust eliminates entire classes of bugs at compile time, but production failures still happen. Infrastructure goes down, TLS certificates expire, database connections get exhausted, and deployments introduce regressions. External monitoring catches what the compiler cannot.

  • Infrastructure failures -- Your Rust binary is rock-solid, but the VM, load balancer, or DNS can still fail
  • Connection pool exhaustion -- Database pools (sqlx, diesel, deadpool) can run dry under load
  • Dependency outages -- External APIs, caches, and message queues go down independently
  • Configuration errors -- Wrong environment variables, expired secrets, or misconfigured TLS after a deploy

Health Response with Serde

Define a typed health response that serializes consistently. Use an enum for status values so invalid states are unrepresentable.

use serde::Serialize;

#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum HealthStatus {
    Healthy,
    Degraded,
    Unhealthy,
}

#[derive(Serialize)]
struct HealthResponse {
    status: HealthStatus,
    version: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    checks: Option<DependencyChecks>,
}

#[derive(Serialize)]
struct DependencyChecks {
    database: CheckResult,
}

#[derive(Serialize)]
struct CheckResult {
    status: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    response_time_ms: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>,
}

Actix-web Health Endpoint

use actix_web::{web, App, HttpServer, HttpResponse};

async fn health() -> HttpResponse {
    HttpResponse::Ok().json(HealthResponse {
        status: HealthStatus::Healthy,
        version: env!("CARGO_PKG_VERSION"),
        checks: None,
    })
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/health", web::get().to(health))
            // ... your other routes
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

Axum Health Endpoint

use axum::{routing::get, Json, Router};

async fn health() -> Json<HealthResponse> {
    Json(HealthResponse {
        status: HealthStatus::Healthy,
        version: env!("CARGO_PKG_VERSION"),
        checks: None,
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/health", get(health));
        // ... your other routes

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

Shared Application State

Health checks need access to your database pool and other shared resources. Both frameworks provide extractors for this.

Actix-web with web::Data:

use actix_web::web;
use sqlx::PgPool;

struct AppState {
    db: PgPool,
}

async fn health(state: web::Data<AppState>) -> HttpResponse {
    let db_check = check_database(&state.db).await;
    let status = if db_check.status == "ok" {
        HealthStatus::Healthy
    } else {
        HealthStatus::Unhealthy
    };
    let code = match status {
        HealthStatus::Healthy => 200,
        _ => 503,
    };
    HttpResponse::build(
        actix_web::http::StatusCode::from_u16(code).unwrap()
    ).json(HealthResponse {
        status,
        version: env!("CARGO_PKG_VERSION"),
        checks: Some(DependencyChecks { database: db_check }),
    })
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let pool = PgPool::connect("postgres://localhost/mydb")
        .await
        .expect("Failed to connect to database");

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(AppState { db: pool.clone() }))
            .route("/health", web::get().to(health))
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

Axum with State extractor:

use axum::extract::State;
use std::sync::Arc;

struct AppState {
    db: PgPool,
}

async fn health(
    State(state): State<Arc<AppState>>,
) -> (axum::http::StatusCode, Json<HealthResponse>) {
    let db_check = check_database(&state.db).await;
    let status = if db_check.status == "ok" {
        HealthStatus::Healthy
    } else {
        HealthStatus::Unhealthy
    };
    let code = match status {
        HealthStatus::Healthy => axum::http::StatusCode::OK,
        _ => axum::http::StatusCode::SERVICE_UNAVAILABLE,
    };
    (code, Json(HealthResponse {
        status,
        version: env!("CARGO_PKG_VERSION"),
        checks: Some(DependencyChecks { database: db_check }),
    }))
}

#[tokio::main]
async fn main() {
    let pool = PgPool::connect("postgres://localhost/mydb")
        .await
        .expect("Failed to connect to database");

    let state = Arc::new(AppState { db: pool });

    let app = Router::new()
        .route("/health", get(health))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

Database Health Checks

Check your database connection with a timeout to prevent a slow database from hanging your health endpoint.

With sqlx:

use sqlx::PgPool;
use std::time::{Duration, Instant};
use tokio::time::timeout;

async fn check_database(pool: &PgPool) -> CheckResult {
    let start = Instant::now();

    match timeout(
        Duration::from_secs(5),
        sqlx::query("SELECT 1").execute(pool),
    ).await {
        Ok(Ok(_)) => CheckResult {
            status: "ok",
            response_time_ms: Some(start.elapsed().as_millis() as u64),
            error: None,
        },
        Ok(Err(e)) => CheckResult {
            status: "error",
            response_time_ms: Some(start.elapsed().as_millis() as u64),
            error: Some(e.to_string()),
        },
        Err(_) => CheckResult {
            status: "error",
            response_time_ms: Some(5000),
            error: Some("database check timed out".to_string()),
        },
    }
}

With diesel:

use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use std::time::Instant;

type DbPool = Pool<ConnectionManager<PgConnection>>;

fn check_database_diesel(pool: &DbPool) -> CheckResult {
    let start = Instant::now();

    match pool.get() {
        Ok(mut conn) => {
            match diesel::sql_query("SELECT 1").execute(&mut conn) {
                Ok(_) => CheckResult {
                    status: "ok",
                    response_time_ms: Some(start.elapsed().as_millis() as u64),
                    error: None,
                },
                Err(e) => CheckResult {
                    status: "error",
                    response_time_ms: Some(start.elapsed().as_millis() as u64),
                    error: Some(e.to_string()),
                },
            }
        },
        Err(e) => CheckResult {
            status: "error",
            response_time_ms: Some(start.elapsed().as_millis() as u64),
            error: Some(format!("pool error: {}", e)),
        },
    }
}

Set Up Monitoring with UptimeSignal

Once your health endpoint is deployed, add it to UptimeSignal for continuous external monitoring. UptimeSignal pings your endpoint on a schedule and alerts you immediately when it returns a non-2xx status or times out.

  1. Sign up at app.uptimesignal.io -- free, no credit card
  2. Add a monitor with your health endpoint URL (e.g. https://api.example.com/health)
  3. Set the interval -- 5 minutes on free, 1 minute on Pro
  4. Configure alerts -- get notified by email when your API goes down or recovers

Best Practices

  • Keep it fast -- Health checks should respond in under 500ms. Use timeouts on all dependency checks.
  • No auth required -- Monitoring services need unauthenticated access to your health endpoint.
  • Use typed responses -- Derive Serialize on structs instead of building JSON by hand. The compiler catches schema drift.
  • Separate liveness from readiness -- /health for "process is alive", /ready for "dependencies are connected".
  • Include version info -- Use env!("CARGO_PKG_VERSION") to embed the crate version at compile time.

Common Rust API Production Issues

Connection Pool Exhaustion

Under high load, all connections in your sqlx or diesel pool get checked out. New requests queue up and eventually time out. Include a database ping in your health check to detect this before users are affected.

Tokio Runtime Starvation

Blocking operations on the async runtime (e.g. calling std::fs instead of tokio::fs, or CPU-heavy computation without spawn_blocking) can starve other tasks. Your health check will time out even though the process is running.

TLS Certificate Expiry

Your Rust binary runs perfectly, but the TLS termination layer (reverse proxy, cloud load balancer) lets a certificate expire. External monitoring catches this immediately because it connects over HTTPS just like your users.

Silent Panics in Spawned Tasks

A panic inside a tokio::spawn task terminates that task silently without crashing the process. Background work stops but the health endpoint keeps responding. Check downstream side effects (queue depth, staleness) in your readiness probe.

Frequently Asked Questions

What should a Rust health check endpoint return?
A health check should return HTTP 200 with a JSON body serialized via serde containing at minimum a status field. Use an enum for status values (Healthy, Degraded, Unhealthy) and derive Serialize so the response is type-safe. Return HTTP 503 if any critical dependency is unreachable.
Should I use Actix-web or Axum for my Rust API?
Both are production-ready. Axum is the newer choice, built on the tower ecosystem and maintained by the Tokio team, making it a natural fit if you already use hyper or tonic. Actix-web is battle-tested with a larger middleware ecosystem. Either framework supports health check endpoints equally well. UptimeSignal monitors both identically since it only checks HTTP responses.
How do I share database pools across handlers?
In Actix-web, wrap your connection pool in web::Data and register it with app_data(). In Axum, use the State extractor with your pool stored in an Arc<AppState>. Both approaches give each handler a reference-counted handle to the pool without manual Arc management in your route functions.
How do I prevent health checks from cluttering my logs?
In Actix-web, configure the Logger middleware to exclude health routes using .exclude("/health"). In Axum, create a custom tower layer that checks the request path before logging. Alternatively, use tracing-subscriber filter directives to suppress logs for specific routes.
How often should I monitor my Rust API?
For production APIs, check every 1-5 minutes. UptimeSignal's free tier checks every 5 minutes, which catches most outages promptly. The Pro tier supports 1-minute intervals for faster detection. Rust services tend to be highly reliable, but infrastructure failures, TLS certificate expiry, and dependency outages still happen.

Start monitoring your Rust API

Get alerted when your Rust endpoints fail. Setup takes 30 seconds.

25 monitors free. No credit card required.

More Framework Guides