commit 08a48938b151fd7006f53cd46550bc82b958ef04
parent 5635b88327826d48db6844ee888fd035b5965552
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 00:44:09 -0700
http: add health endpoints
Diffstat:
3 files changed, 275 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -252,10 +252,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
+ "form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
+ "hyper",
+ "hyper-util",
"itoa",
"matchit",
"memchr",
@@ -263,10 +266,15 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
"sync_wrapper",
+ "tokio",
"tower",
"tower-layer",
"tower-service",
+ "tracing",
]
[[package]]
@@ -285,6 +293,7 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
+ "tracing",
]
[[package]]
@@ -3238,6 +3247,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3357,6 +3372,29 @@ dependencies = [
]
[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3884,8 +3922,12 @@ dependencies = [
name = "tangle_runtime"
version = "0.1.0"
dependencies = [
+ "axum",
+ "http",
"serde",
"serde_json",
+ "tokio",
+ "tower",
]
[[package]]
@@ -4227,6 +4269,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
+ "log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml
@@ -8,10 +8,14 @@ license.workspace = true
description = "HTTP, WebSocket, and CLI runtime surfaces for tangle"
[dependencies]
+axum = "0.8"
+http = "1"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
+tokio = { version = "1", features = ["macros", "rt"] }
+tower = { version = "0.5", features = ["util"] }
[lints]
workspace = true
diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs
@@ -1,6 +1,13 @@
#![forbid(unsafe_code)]
+use axum::{
+ Json, Router,
+ extract::State,
+ response::{IntoResponse, Response},
+ routing::get,
+};
use core::fmt;
+use http::StatusCode;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -111,6 +118,14 @@ impl fmt::Display for ApiError {
impl std::error::Error for ApiError {}
+impl IntoResponse for ApiError {
+ fn into_response(self) -> Response {
+ let status =
+ StatusCode::from_u16(self.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ (status, Json(self.envelope())).into_response()
+ }
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiErrorEnvelope {
pub error: ApiErrorBody,
@@ -122,9 +137,122 @@ pub struct ApiErrorBody {
pub message: String,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ReadinessCheckStatus {
+ Ready,
+ NotReady,
+}
+
+impl ReadinessCheckStatus {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Ready => "ready",
+ Self::NotReady => "not_ready",
+ }
+ }
+
+ pub fn is_ready(self) -> bool {
+ self == Self::Ready
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ReadinessState {
+ pub database: ReadinessCheckStatus,
+ pub migrations: ReadinessCheckStatus,
+ pub repository: ReadinessCheckStatus,
+}
+
+impl ReadinessState {
+ pub fn new(
+ database: ReadinessCheckStatus,
+ migrations: ReadinessCheckStatus,
+ repository: ReadinessCheckStatus,
+ ) -> Self {
+ Self {
+ database,
+ migrations,
+ repository,
+ }
+ }
+
+ pub fn ready() -> Self {
+ Self::new(
+ ReadinessCheckStatus::Ready,
+ ReadinessCheckStatus::Ready,
+ ReadinessCheckStatus::Ready,
+ )
+ }
+
+ pub fn is_ready(self) -> bool {
+ self.database.is_ready() && self.migrations.is_ready() && self.repository.is_ready()
+ }
+
+ pub fn response(self) -> ReadinessDocument {
+ ReadinessDocument {
+ status: if self.is_ready() {
+ "ready".to_owned()
+ } else {
+ "not_ready".to_owned()
+ },
+ checks: ReadinessChecksDocument {
+ database: self.database.as_str().to_owned(),
+ migrations: self.migrations.as_str().to_owned(),
+ repository: self.repository.as_str().to_owned(),
+ },
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct HealthDocument {
+ pub status: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ReadinessDocument {
+ pub status: String,
+ pub checks: ReadinessChecksDocument,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ReadinessChecksDocument {
+ pub database: String,
+ pub migrations: String,
+ pub repository: String,
+}
+
+pub fn health_router(readiness: ReadinessState) -> Router {
+ Router::new()
+ .route("/healthz", get(healthz))
+ .route("/readyz", get(readyz))
+ .with_state(readiness)
+}
+
+async fn healthz() -> Json<HealthDocument> {
+ Json(HealthDocument {
+ status: "ok".to_owned(),
+ })
+}
+
+async fn readyz(State(readiness): State<ReadinessState>) -> (StatusCode, Json<ReadinessDocument>) {
+ let status = if readiness.is_ready() {
+ StatusCode::OK
+ } else {
+ StatusCode::SERVICE_UNAVAILABLE
+ };
+ (status, Json(readiness.response()))
+}
+
#[cfg(test)]
mod tests {
- use super::{ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope};
+ use super::{
+ ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, ReadinessCheckStatus,
+ ReadinessState, health_router,
+ };
+ use axum::{body::Body, response::IntoResponse};
+ use http::{Request, StatusCode};
+ use tower::ServiceExt;
#[test]
fn api_error_codes_have_stable_labels_and_statuses() {
@@ -191,4 +319,103 @@ mod tests {
errors[0].envelope()
);
}
+
+ #[tokio::test]
+ async fn api_error_into_response_keeps_public_envelope_shape() {
+ let response = ApiError::not_found("listing not found").into_response();
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "error": {
+ "code": "not_found",
+ "message": "listing not found"
+ }
+ })
+ );
+ }
+
+ #[tokio::test]
+ async fn health_endpoint_reports_liveness() {
+ let response = health_router(ReadinessState::ready())
+ .oneshot(
+ Request::builder()
+ .uri("/healthz")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({ "status": "ok" })
+ );
+ }
+
+ #[tokio::test]
+ async fn readiness_endpoint_reports_ready_checks() {
+ let response = health_router(ReadinessState::ready())
+ .oneshot(
+ Request::builder()
+ .uri("/readyz")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "status": "ready",
+ "checks": {
+ "database": "ready",
+ "migrations": "ready",
+ "repository": "ready"
+ }
+ })
+ );
+ }
+
+ #[tokio::test]
+ async fn readiness_endpoint_reports_unavailable_checks() {
+ let response = health_router(ReadinessState::new(
+ ReadinessCheckStatus::NotReady,
+ ReadinessCheckStatus::Ready,
+ ReadinessCheckStatus::NotReady,
+ ))
+ .oneshot(
+ Request::builder()
+ .uri("/readyz")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body");
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json"),
+ serde_json::json!({
+ "status": "not_ready",
+ "checks": {
+ "database": "not_ready",
+ "migrations": "ready",
+ "repository": "not_ready"
+ }
+ })
+ );
+ }
}