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#[inline]
109fn spawn_upkeep(
110    handle: metrics_exporter_prometheus::PrometheusHandle,
111) -> tokio::task::JoinHandle<()> {
112    tokio::spawn(async move {
113        let mut interval = tokio::time::interval(Duration::from_secs(5));
114        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
115        loop {
116            interval.tick().await;
117            handle.run_upkeep();
118        }
119    })
120}
121
122/// Aborts the upkeep task and awaits its completion.
123#[inline]
124async fn teardown_upkeep(task: tokio::task::JoinHandle<()>) {
125    task.abort();
126    match task.await {
127        Err(error) if error.is_cancelled() => {}
128        Err(error) => tracing::error!(%error, "metrics upkeep task failed during shutdown"),
129        Ok(()) => {}
130    }
131}
132
133/// Initialises the `PostHog` client from environment variables and installs
134/// it — or clears any stale predecessor — in the shared slot so capture
135/// sites can reach it.
136///
137/// Always calls [`crate::telemetry::install_posthog_client`], even when the
138/// factory returns `None`, so that a client left in the slot by a previous
139/// run in the same process cannot linger into this run.
140#[cfg(feature = "posthog")]
141#[inline]
142async fn init_posthog() {
143    let client = crate::telemetry::init_posthog_client_from_env().await;
144    crate::telemetry::install_posthog_client(client);
145}
146
147/// Resolves when SIGINT (Ctrl+C) or SIGTERM is received.
148#[inline]
149async fn shutdown_signal() {
150    use core::future;
151
152    use tokio::signal;
153
154    let ctrl_c = async {
155        if let Err(error) = signal::ctrl_c().await {
156            tracing::error!(%error, "failed to install Ctrl+C handler");
157            future::pending::<()>().await;
158        }
159    };
160
161    #[cfg(unix)]
162    let terminate = async {
163        match signal::unix::signal(signal::unix::SignalKind::terminate()) {
164            Ok(mut stream) => {
165                stream.recv().await;
166            }
167            Err(error) => {
168                tracing::error!(%error, "failed to install SIGTERM handler");
169                future::pending::<()>().await;
170            }
171        }
172    };
173
174    #[cfg(not(unix))]
175    let terminate = future::pending::<()>();
176
177    tokio::select! {
178        () = ctrl_c => {
179            tracing::info!("shutdown signal received, draining");
180        },
181        () = terminate => {
182            tracing::info!("shutdown signal received, draining");
183        },
184    }
185}