Skip to main content

docspec_http/
server.rs

1//! HTTP server implementation.
2
3use core::time::Duration;
4
5use metrics_exporter_prometheus::BuildError;
6use tokio::net::TcpListener;
7
8/// Errors that can occur while starting or running the HTTP server.
9#[derive(Debug, thiserror::Error)]
10pub enum ServerError {
11    /// A TCP bind or Axum serve I/O failure.
12    #[error(transparent)]
13    Listen(#[from] std::io::Error),
14
15    /// Failed to install the Prometheus metrics recorder.
16    #[error("failed to initialize Prometheus metrics recorder: {0}")]
17    MetricsInit(#[source] BuildError),
18}
19
20/// Configuration for the HTTP server.
21pub struct ServerConfig {
22    /// Network address to bind. Accepts IPv4 literals (`127.0.0.1`), IPv6
23    /// literals (`::1`), and hostnames (`localhost`). Default: `127.0.0.1`.
24    pub host: String,
25    /// Port to listen on. Use `0` for OS-assigned (for testing).
26    pub port: u16,
27}
28
29impl ServerConfig {
30    /// Create a new server configuration.
31    #[inline]
32    #[must_use]
33    pub fn new<Host>(host: Host, port: u16) -> Self
34    where
35        Host: Into<String>,
36    {
37        Self {
38            host: host.into(),
39            port,
40        }
41    }
42}
43
44/// Bind a TCP listener for the given configuration without starting the
45/// Axum server.
46///
47/// The `host` field accepts IPv4 literals, IPv6 literals (no brackets needed),
48/// and hostnames (resolved via DNS).
49///
50/// Useful for tests that need to assert binding behavior deterministically
51/// without spawning the full server task.
52///
53/// # Errors
54///
55/// Returns [`Err`] if the host cannot be resolved, the port is already in
56/// use, or listening fails.
57#[inline]
58pub async fn bind(config: &ServerConfig) -> std::io::Result<TcpListener> {
59    TcpListener::bind((config.host.as_str(), config.port)).await
60}
61
62/// Bind and start the HTTP server, shutting down gracefully on SIGINT/SIGTERM.
63///
64/// Delegates binding to [`bind`], installs the Prometheus metrics recorder
65/// globally, then spawns an upkeep task that is aborted when the server future
66/// completes. Logs the actual bound address using [`TcpListener::local_addr`]
67/// rather than the configured port, so that port `0` (OS-assigned) shows the
68/// real port.
69///
70/// # Errors
71///
72/// Returns [`Err`] if the Prometheus recorder cannot be installed, the host
73/// cannot be resolved, the port is already in use, or listening fails.
74#[inline]
75pub async fn serve(config: ServerConfig) -> Result<(), ServerError> {
76    let listener = bind(&config).await?;
77    let bound_addr = listener.local_addr()?;
78
79    let handle = crate::metrics::install_global().map_err(ServerError::MetricsInit)?;
80    let upkeep_handle = handle.clone();
81    let upkeep_task = tokio::spawn(async move {
82        let mut interval = tokio::time::interval(Duration::from_secs(5));
83        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
84        loop {
85            interval.tick().await;
86            upkeep_handle.run_upkeep();
87        }
88    });
89
90    tracing::info!(addr = %bound_addr, "docspec-http listening");
91
92    let server_result = axum::serve(listener, crate::router::router_with_metrics(handle))
93        .with_graceful_shutdown(shutdown_signal())
94        .await;
95
96    upkeep_task.abort();
97    match upkeep_task.await {
98        Err(error) if error.is_cancelled() => {}
99        Err(error) => tracing::error!(%error, "metrics upkeep task failed during shutdown"),
100        Ok(()) => {}
101    }
102    server_result?;
103
104    Ok(())
105}
106
107/// Resolves when SIGINT (Ctrl+C) or SIGTERM is received.
108// Reason: graceful shutdown is intentionally factored out to match Axum's
109// documented server lifecycle pattern and keep `serve` focused on binding.
110#[allow(clippy::single_call_fn)]
111#[inline]
112async fn shutdown_signal() {
113    use core::future;
114
115    use tokio::signal;
116
117    let ctrl_c = async {
118        if let Err(error) = signal::ctrl_c().await {
119            tracing::error!(%error, "failed to install Ctrl+C handler");
120            future::pending::<()>().await;
121        }
122    };
123
124    #[cfg(unix)]
125    let terminate = async {
126        match signal::unix::signal(signal::unix::SignalKind::terminate()) {
127            Ok(mut stream) => {
128                stream.recv().await;
129            }
130            Err(error) => {
131                tracing::error!(%error, "failed to install SIGTERM handler");
132                future::pending::<()>().await;
133            }
134        }
135    };
136
137    #[cfg(not(unix))]
138    let terminate = future::pending::<()>();
139
140    tokio::select! {
141        () = ctrl_c => {
142            tracing::info!("shutdown signal received, draining");
143        },
144        () = terminate => {
145            tracing::info!("shutdown signal received, draining");
146        },
147    }
148}