arcature 2026.2.1

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! The `/up/ready` readiness endpoint (AP2.1-10).
//!
//! Readiness is the gate an upstream load balancer uses to decide whether to
//! send new traffic. It is `200 OK` **only** when the lifecycle state is
//! [`Ready`](crate::application::lifecycle::LifecycleState::Ready) **and**
//! every application-registered readiness check returns `true` (PROGRAM.md
//! AP2.1-10: "Readiness may depend on required startup and migration
//! compatibility"; "Readiness must NOT become true before required app
//! dependencies are ready"). It is `503 Service Unavailable` during
//! `Starting`, during `Draining`, and after `Stopped`.
//!
//! # Why a 503 (not a 200-with-body) while unready
//!
//! A load balancer probes `/up/ready` and routes traffic on a 2xx. A 503 is
//! the unambiguous "do not send traffic" signal across every load balancer
//! (nginx `proxy_next_upstream`, AWS ALB, Kubernetes readiness probe). A
//! 200-with-body-that-says-unready would require the LB to parse the body,
//! which is fragile and non-standard. The body is a public-safe status word
//! for humans; the status code is the contract.
//!
//! # No spoofability
//!
//! Readiness is NOT spoofable: a `Draining` or `Starting` state returns 503
//! regardless of the readiness checks, and the readiness checks are
//! application-supplied closures evaluated against real dependencies. The
//! endpoint does not accept any query parameter or header that could
//! override the decision.

use crate::application::lifecycle::{Lifecycle, LifecycleState};
use crate::axum::Extension;
use crate::axum::http::StatusCode;
use crate::axum::response::IntoResponse;

/// The readiness response: `200 OK` with body `"ready"` only when the
/// lifecycle is `Ready` and all readiness checks pass; otherwise `503
/// Service Unavailable` with a public-safe status word (`"starting"`,
/// `"draining"`, `"stopped"`, or `"unready"` — the last when the state is
/// `Ready` but a readiness check failed).
#[must_use]
pub fn ready_response(lifecycle: &Lifecycle) -> (StatusCode, &'static str) {
    let state = lifecycle.state();
    if lifecycle.ready() {
        return (StatusCode::OK, "ready");
    }
    // 503 with a public-safe status word. The word is the lifecycle state
    // for non-Ready states; "unready" when the state is Ready but a check
    // failed (so an operator can distinguish "still starting" from "started
    // but a dependency is unhealthy"). No dependency detail is leaked.
    let body = match state {
        LifecycleState::Starting => "starting",
        LifecycleState::Draining => "draining",
        LifecycleState::Stopped => "stopped",
        LifecycleState::Ready => "unready",
    };
    (StatusCode::SERVICE_UNAVAILABLE, body)
}

/// The `/up/ready` handler. Reads the [`Lifecycle`] from an Axum
/// [`Extension`], so it composes with any application state type `S`.
pub(crate) async fn ready(Extension(lifecycle): Extension<Lifecycle>) -> impl IntoResponse {
    ready_response(&lifecycle)
}

#[cfg(test)]
mod tests {
    use super::ready_response;
    use crate::application::lifecycle::Lifecycle;
    use crate::axum::http::StatusCode;
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, Ordering};

    #[test]
    fn ready_returns_503_while_starting() {
        let lifecycle = Lifecycle::new();
        let (status, body) = ready_response(&lifecycle);
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body, "starting");
    }

    #[test]
    fn ready_returns_200_when_ready_and_no_checks() {
        let lifecycle = Lifecycle::new();
        lifecycle.mark_ready();
        let (status, body) = ready_response(&lifecycle);
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, "ready");
    }

    #[test]
    fn ready_returns_503_when_ready_but_check_fails() {
        let flag = Arc::new(AtomicBool::new(false));
        let flag_for_check = flag.clone();
        let lifecycle =
            Lifecycle::new().register_readiness(move || flag_for_check.load(Ordering::SeqCst));
        lifecycle.mark_ready();
        let (status, body) = ready_response(&lifecycle);
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body, "unready");
        flag.store(true, Ordering::SeqCst);
        let (status, body) = ready_response(&lifecycle);
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, "ready");
    }

    #[test]
    fn ready_returns_503_while_draining_even_if_checks_pass() {
        let lifecycle = Lifecycle::new();
        lifecycle.mark_ready();
        lifecycle.begin_drain();
        let (status, body) = ready_response(&lifecycle);
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body, "draining");
    }

    #[test]
    fn ready_returns_503_when_stopped() {
        let lifecycle = Lifecycle::new();
        lifecycle.mark_stopped();
        let (status, body) = ready_response(&lifecycle);
        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(body, "stopped");
    }
}