Back to Oh My Openagent

axum + sqlx + tracing + tower — HTTP API Stack

packages/omo-codex/plugin/skills/programming/references/rust/axum-stack.md

4.5.1214.6 KB
Original Source

axum + sqlx + tracing + tower — HTTP API Stack

The canonical production HTTP service in Rust 2026.

Cargo.toml dependencies

toml
[dependencies]
axum = { version = "0.8", features = ["macros", "tracing", "ws", "multipart"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = [
    "trace", "compression-gzip", "compression-br",
    "timeout", "cors", "request-id", "sensitive-headers",
    "limit", "set-header",
] }

# Errors / observability
anyhow = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
color-eyre = "0.6"

# Database
sqlx = { version = "0.8", features = [
    "runtime-tokio-rustls", "postgres", "uuid", "macros",
    "migrate", "json",
] }

# Serialization / validation
serde = { version = "1", features = ["derive"] }
serde_json = "1"
validator = { version = "0.18", features = ["derive"] }

# Types
uuid = { version = "1", features = ["v4", "v7", "serde"] }
jiff = { version = "0.1", features = ["serde"] }

# Config
config = { version = "0.14", features = ["toml", "yaml"] }
secrecy = { version = "0.10", features = ["serde"] }

# OpenAPI (optional but recommended)
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-axum = "0.1"
utoipa-swagger-ui = { version = "8", features = ["axum"] }

Project structure

src/
  main.rs            # binary entry
  lib.rs             # re-exports + app builder
  config.rs          # Settings type + loader
  state.rs           # AppState (shared via Arc)
  routes/
    mod.rs           # Router::new() composition
    health.rs
    users.rs
  middleware/
    mod.rs
    auth.rs
    request_id.rs
  models/
    mod.rs
    user.rs
  error.rs           # AppError + IntoResponse impl
  db/
    mod.rs
    migrations/
migrations/          # sqlx migrations

Error type

rust
// src/error.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("not found")]
    NotFound,
    #[error("unauthorized")]
    Unauthorized,
    #[error("validation: {0}")]
    Validation(String),
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("internal")]
    Internal(#[from] anyhow::Error),
    #[error("database")]
    Database(#[from] sqlx::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code, message) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "not_found", self.to_string()),
            AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "unauthorized".into()),
            AppError::Validation(m) => (StatusCode::UNPROCESSABLE_ENTITY, "validation", m.clone()),
            AppError::Conflict(m) => (StatusCode::CONFLICT, "conflict", m.clone()),
            AppError::Database(e) => {
                tracing::error!(error = ?e, "database error");
                (StatusCode::INTERNAL_SERVER_ERROR, "database", "internal".into())
            }
            AppError::Internal(e) => {
                tracing::error!(error = ?e, "internal error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal".into())
            }
        };
        (status, Json(json!({"error": {"code": code, "message": message}}))).into_response()
    }
}

pub type AppResult<T> = std::result::Result<T, AppError>;

Pattern: business errors return AppResult<T>; the IntoResponse impl translates them to HTTP. sqlx::Error and anyhow::Error auto-convert via From. Internal-bucket errors are logged but never leak their Debug representation to clients.

AppState

rust
// src/state.rs
use std::sync::Arc;
use sqlx::PgPool;

#[derive(Clone)]
pub struct AppState {
    pub db: PgPool,
    pub config: Arc<crate::config::Settings>,
    pub http: reqwest::Client,
}

impl AppState {
    pub async fn new(config: crate::config::Settings) -> anyhow::Result<Self> {
        let db = sqlx::postgres::PgPoolOptions::new()
            .max_connections(config.db.max_connections)
            .acquire_timeout(std::time::Duration::from_secs(3))
            .connect(config.db.url.expose_secret())
            .await?;
        sqlx::migrate!("./migrations").run(&db).await?;
        let http = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(15))
            .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
            .build()?;
        Ok(Self { db, config: Arc::new(config), http })
    }
}

Route handler

rust
// src/routes/users.rs
use axum::{
    extract::{Path, State},
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;

use crate::{error::{AppError, AppResult}, state::AppState};

#[derive(Debug, Deserialize, Validate)]
pub struct CreateUser {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 1, max = 100))]
    pub name: String,
}

#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub created_at: jiff::Timestamp,
}

#[tracing::instrument(skip(state, body))]
pub async fn create_user(
    State(state): State<AppState>,
    Json(body): Json<CreateUser>,
) -> AppResult<(axum::http::StatusCode, Json<User>)> {
    body.validate().map_err(|e| AppError::Validation(e.to_string()))?;

    let id = Uuid::now_v7();
    let user = sqlx::query_as!(
        User,
        r#"INSERT INTO users (id, email, name, created_at)
           VALUES ($1, $2, $3, NOW())
           RETURNING id, email, name, created_at as "created_at: jiff::Timestamp""#,
        id, body.email, body.name
    )
    .fetch_one(&state.db)
    .await
    .map_err(|e| match &e {
        sqlx::Error::Database(db) if db.code().as_deref() == Some("23505") =>
            AppError::Conflict("email already exists".into()),
        _ => AppError::Database(e),
    })?;

    Ok((axum::http::StatusCode::CREATED, Json(user)))
}

#[tracing::instrument(skip(state))]
pub async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> AppResult<Json<User>> {
    sqlx::query_as!(
        User,
        r#"SELECT id, email, name, created_at as "created_at: jiff::Timestamp"
           FROM users WHERE id = $1"#,
        id
    )
    .fetch_optional(&state.db)
    .await?
    .map(Json)
    .ok_or(AppError::NotFound)
}

Router assembly

rust
// src/routes/mod.rs
use axum::{routing::{get, post}, Router};
use tower_http::{
    compression::CompressionLayer,
    cors::CorsLayer,
    request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
    sensitive_headers::SetSensitiveHeadersLayer,
    timeout::TimeoutLayer,
    trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer},
};
use std::time::Duration;
use crate::state::AppState;

mod health;
mod users;

pub fn router(state: AppState) -> Router {
    let api = Router::new()
        .route("/health", get(health::handler))
        .route("/users", post(users::create_user))
        .route("/users/:id", get(users::get_user))
        .with_state(state);

    Router::new()
        .nest("/api/v1", api)
        .layer(
            tower::ServiceBuilder::new()
                .layer(SetSensitiveHeadersLayer::new([
                    axum::http::header::AUTHORIZATION,
                    axum::http::header::COOKIE,
                ]))
                .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
                .layer(
                    TraceLayer::new_for_http()
                        .make_span_with(DefaultMakeSpan::new().include_headers(false))
                        .on_response(DefaultOnResponse::new().latency_unit(tower_http::LatencyUnit::Millis)),
                )
                .layer(PropagateRequestIdLayer::x_request_id())
                .layer(TimeoutLayer::new(Duration::from_secs(30)))
                .layer(CompressionLayer::new())
                .layer(CorsLayer::permissive()), // tighten in production
        )
}

Order matters: outermost layer wraps the request first. Trace before timeout so timeouts get logged. Compression after trace so trace sees the original body size.

Main + graceful shutdown

rust
// src/main.rs
use my_app::{config::Settings, routes, state::AppState};

#[tokio::main]
async fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;
    init_tracing();

    let config = Settings::load()?;
    let state = AppState::new(config.clone()).await?;
    let app = routes::router(state);

    let listener = tokio::net::TcpListener::bind(&config.bind).await?;
    tracing::info!(addr = %config.bind, "listening");

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;
    Ok(())
}

fn init_tracing() {
    use tracing_subscriber::{fmt, EnvFilter};
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,hyper=warn,tower_http=info"));
    fmt().with_env_filter(filter).with_target(false).json().init();
}

async fn shutdown_signal() {
    let ctrl_c = async { tokio::signal::ctrl_c().await.expect("ctrl_c handler"); };
    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("signal handler").recv().await;
    };
    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
    tracing::info!("shutting down");
}

Middleware: bearer auth example

rust
// src/middleware/auth.rs
use axum::{
    extract::{Request, State},
    http::header::AUTHORIZATION,
    middleware::Next,
    response::Response,
};
use crate::{error::AppError, state::AppState};

#[derive(Clone, Debug)]
pub struct AuthUser { pub id: uuid::Uuid }

pub async fn require_auth(
    State(state): State<AppState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AppError> {
    let token = request.headers()
        .get(AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.strip_prefix("Bearer "))
        .ok_or(AppError::Unauthorized)?;

    let claims = verify_jwt(token, &state.config.jwt_secret)?;
    request.extensions_mut().insert(AuthUser { id: claims.sub });
    Ok(next.run(request).await)
}

Apply with .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) on the subroutes that need it.

Testing handlers

rust
// tests/users.rs
#[tokio::test]
async fn creates_user() {
    let pool = test_db().await;          // helper that spins up a transactional DB
    let state = AppState::new_test(pool).await.unwrap();
    let app = my_app::routes::router(state);

    let request = axum::http::Request::builder()
        .uri("/api/v1/users")
        .method("POST")
        .header("content-type", "application/json")
        .body(axum::body::Body::from(
            serde_json::to_vec(&serde_json::json!({"email": "[email protected]", "name": "A"})).unwrap()
        )).unwrap();

    let response = tower::ServiceExt::oneshot(app, request).await.unwrap();
    assert_eq!(response.status(), 201);
    let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.unwrap();
    let user: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(user["email"], "[email protected]");
}

tower::ServiceExt::oneshot calls the router directly without binding a socket. Tests run in parallel without port collisions.

Config

rust
// src/config.rs
use secrecy::{Secret, ExposeSecret};

#[derive(Debug, Clone, serde::Deserialize)]
pub struct Settings {
    pub bind: String,
    pub db: Database,
    pub jwt_secret: Secret<String>,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct Database {
    pub url: Secret<String>,
    pub max_connections: u32,
}

impl Settings {
    pub fn load() -> anyhow::Result<Self> {
        let cfg = config::Config::builder()
            .add_source(config::File::with_name("config/default").required(false))
            .add_source(config::File::with_name(&format!(
                "config/{}", std::env::var("APP_ENV").unwrap_or_else(|_| "dev".into())
            )).required(false))
            .add_source(config::Environment::with_prefix("APP").separator("__"))
            .build()?;
        Ok(cfg.try_deserialize()?)
    }
}

Secret<T> from the secrecy crate hides the value in Debug/Display to prevent accidental log leakage. Access via .expose_secret() only where needed.

OpenAPI (optional)

Add utoipa derive macros on your DTOs and handlers, mount Swagger UI at /swagger-ui:

rust
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use utoipa_swagger_ui::SwaggerUi;

#[derive(OpenApi)]
#[openapi(
    paths(routes::users::create_user, routes::users::get_user),
    components(schemas(routes::users::User, routes::users::CreateUser))
)]
struct ApiDoc;

let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
    .routes(utoipa_axum::routes!(routes::users::create_user, routes::users::get_user))
    .split_for_parts();

let app = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api));

Production checklist

  • Bind to 0.0.0.0 in containers, 127.0.0.1 for local-only services.
  • Set RUST_LOG=info,sqlx=warn (or use EnvFilter defaults as shown).
  • Send logs to stdout in JSON. Ingest via Vector / Fluent Bit / Loki.
  • Run migrations on startup (sqlx::migrate! block). Fail fast on schema mismatch.
  • Health endpoint must hit the DB (so load balancers know if the pool is dead).
  • Add tower::limit::RateLimitLayer or token-bucket middleware for public endpoints.
  • Set tower_http::limit::RequestBodyLimitLayer to bound request size.
  • Compress with brotli + gzip via CompressionLayer.
  • Tighten CORS, do not ship CorsLayer::permissive() to production.
  • Strip sensitive headers from traces via SetSensitiveHeadersLayer.
  • Set up SIGTERM-driven with_graceful_shutdown so deploys roll without dropping requests.
  • Containerize with cargo chef for incremental Docker builds.

Common mistakes

  1. Forgetting error_for_status()? on outbound reqwest — 4xx silently succeeds.
  2. Returning Result<T, sqlx::Error> from handlers — leak DB details to clients. Always go through AppError.
  3. Json<T> extractor before validation — invalid JSON returns axum's default 422 with no body shape. Wrap in a ValidatedJson<T> extractor that runs validator and returns AppError.
  4. Holding DB connections across .await on slow external calls — exhausts the pool. Acquire late, release early.
  5. Skipping tracing::instrument on handlers — losing per-request span correlation.
  6. No RequestBodyLimitLayer — DoS surface. Default axum has no limit.