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)]
10#[non_exhaustive]
11pub enum ServerError {
12    /// A TCP bind or Axum serve I/O failure.
13    #[error(transparent)]
14    Listen(#[from] std::io::Error),
15
16    /// Failed to install the Prometheus metrics recorder.
17    #[error("failed to initialize Prometheus metrics recorder: {0}")]
18    MetricsInit(#[source] BuildError),
19
20    /// Failed to build the tokio runtime.
21    #[error("failed to build tokio runtime: {0}")]
22    RuntimeBuild(#[source] std::io::Error),
23}
24
25/// Configuration for the HTTP server.
26pub struct ServerConfig {
27    /// Network address to bind. Accepts IPv4 literals (`127.0.0.1`), IPv6
28    /// literals (`::1`), and hostnames (`localhost`). Default: `127.0.0.1`.
29    pub host: String,
30    /// Port to listen on. Use `0` for OS-assigned (for testing).
31    pub port: u16,
32}
33
34impl ServerConfig {
35    /// Create a new server configuration.
36    #[inline]
37    #[must_use]
38    pub fn new<Host>(host: Host, port: u16) -> Self
39    where
40        Host: Into<String>,
41    {
42        Self {
43            host: host.into(),
44            port,
45        }
46    }
47}
48
49/// Bind a TCP listener for the given configuration without starting the
50/// Axum server.
51///
52/// The `host` field accepts IPv4 literals, IPv6 literals (no brackets needed),
53/// and hostnames (resolved via DNS).
54///
55/// Useful for tests that need to assert binding behavior deterministically
56/// without spawning the full server task.
57///
58/// # Errors
59///
60/// Returns [`Err`] if the host cannot be resolved, the port is already in
61/// use, or listening fails.
62#[inline]
63pub async fn bind(config: &ServerConfig) -> std::io::Result<TcpListener> {
64    TcpListener::bind((config.host.as_str(), config.port)).await
65}
66
67/// Bind and start the HTTP server, shutting down gracefully on SIGINT/SIGTERM.
68///
69/// Delegates binding to [`bind`], installs the Prometheus metrics recorder
70/// globally, then spawns an upkeep task that is aborted when the server future
71/// completes. Logs the actual bound address using [`TcpListener::local_addr`]
72/// rather than the configured port, so that port `0` (OS-assigned) shows the
73/// real port.
74///
75/// # Errors
76///
77/// Returns [`Err`] if the Prometheus recorder cannot be installed, the host
78/// cannot be resolved, the port is already in use, or listening fails.
79#[inline]
80pub async fn serve(config: ServerConfig) -> Result<(), ServerError> {
81    let listener = bind(&config).await?;
82    let bound_addr = listener.local_addr()?;
83
84    let handle = crate::metrics::install_global().map_err(ServerError::MetricsInit)?;
85
86    #[cfg(feature = "posthog")]
87    init_posthog().await;
88
89    let upkeep_task = spawn_upkeep(handle.clone());
90
91    tracing::info!(addr = %bound_addr, "docspec-http listening");
92
93    let server_result = axum::serve(listener, crate::router::router_with_metrics(handle))
94        .with_graceful_shutdown(shutdown_signal())
95        .await;
96
97    crate::telemetry::shutdown().await;
98    #[cfg(feature = "posthog")]
99    tracing::info!("posthog flush complete");
100
101    teardown_upkeep(upkeep_task).await;
102    server_result?;
103
104    Ok(())
105}
106
107/// Spawns the Prometheus upkeep task.
108#[allow(clippy::single_call_fn)]
109#[inline]
110fn spawn_upkeep(
111    handle: metrics_exporter_prometheus::PrometheusHandle,
112) -> tokio::task::JoinHandle<()> {
113    tokio::spawn(async move {
114        let mut interval = tokio::time::interval(Duration::from_secs(5));
115        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
116        loop {
117            interval.tick().await;
118            handle.run_upkeep();
119        }
120    })
121}
122
123/// Aborts the upkeep task and awaits its completion.
124#[allow(clippy::single_call_fn)]
125#[inline]
126async fn teardown_upkeep(task: tokio::task::JoinHandle<()>) {
127    task.abort();
128    match task.await {
129        Err(error) if error.is_cancelled() => {}
130        Err(error) => tracing::error!(%error, "metrics upkeep task failed during shutdown"),
131        Ok(()) => {}
132    }
133}
134
135/// Initialises the `PostHog` client from environment variables and installs
136/// it — or clears any stale predecessor — in the shared slot so capture
137/// sites can reach it.
138///
139/// Always calls [`crate::telemetry::install_posthog_client`], even when the
140/// factory returns `None`, so that a client left in the slot by a previous
141/// run in the same process cannot linger into this run.
142#[cfg(feature = "posthog")]
143#[allow(clippy::single_call_fn)]
144#[inline]
145async fn init_posthog() {
146    let client = crate::telemetry::init_posthog_client_from_env().await;
147    crate::telemetry::install_posthog_client(client);
148}
149
150/// Resolves when SIGINT (Ctrl+C) or SIGTERM is received.
151// Reason: graceful shutdown is intentionally factored out to match Axum's
152// documented server lifecycle pattern and keep `serve` focused on binding.
153#[allow(clippy::single_call_fn)]
154#[inline]
155async fn shutdown_signal() {
156    use core::future;
157
158    use tokio::signal;
159
160    let ctrl_c = async {
161        if let Err(error) = signal::ctrl_c().await {
162            tracing::error!(%error, "failed to install Ctrl+C handler");
163            future::pending::<()>().await;
164        }
165    };
166
167    #[cfg(unix)]
168    let terminate = async {
169        match signal::unix::signal(signal::unix::SignalKind::terminate()) {
170            Ok(mut stream) => {
171                stream.recv().await;
172            }
173            Err(error) => {
174                tracing::error!(%error, "failed to install SIGTERM handler");
175                future::pending::<()>().await;
176            }
177        }
178    };
179
180    #[cfg(not(unix))]
181    let terminate = future::pending::<()>();
182
183    tokio::select! {
184        () = ctrl_c => {
185            tracing::info!("shutdown signal received, draining");
186        },
187        () = terminate => {
188            tracing::info!("shutdown signal received, draining");
189        },
190    }
191}