Skip to main content

sova_core/app/
mod.rs

1mod bind;
2mod hooks;
3
4pub use bind::{Bind, BoundApp, Http};
5pub use hooks::Server;
6pub(crate) use hooks::{AppInner, ListenParts, ShutdownHook, StartupHook};
7
8use crate::error::{Error, Result};
9use crate::events::EventBus;
10use crate::handler::BoxFuture;
11use crate::plugin::{check_plugin_sdk, InstalledPlugin, Plugin, SdkCompat, PLUGIN_SDK_VERSION};
12use crate::request::Request;
13use crate::response::Response;
14use crate::router::Router;
15use crate::service::{BackgroundService, BoxedService};
16use crate::state::StateMap;
17use bytes::Bytes;
18use http::Method;
19use std::collections::{HashMap, HashSet};
20use std::fs;
21use std::net::{IpAddr, SocketAddr};
22use std::ops::{Deref, DerefMut};
23use std::sync::Arc;
24use std::time::Duration;
25
26pub(crate) type CliCommandFn =
27    Arc<dyn Fn(Arc<StateMap>, Vec<String>) -> BoxFuture<Result<()>> + Send + Sync>;
28
29pub(crate) type CheckFn = Arc<dyn Fn(Arc<StateMap>) -> BoxFuture<Result<()>> + Send + Sync>;
30
31/// Kind of app check — readiness probes vs deploy-time audits.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CheckKind {
34    /// Runtime dependencies (db, redis, storage, …) — used by `GET /ready`.
35    Ready,
36    /// Deploy-time / config audits (openapi, vld, templates, …) — CLI `check` only.
37    Audit,
38}
39
40/// Outcome of one named check from [`App::run_checks`].
41#[derive(Debug, Clone)]
42pub struct CheckResult {
43    pub name: &'static str,
44    pub ok: bool,
45    pub error: Option<String>,
46}
47
48type CheckEntry = (&'static str, CheckKind, CheckFn);
49type CheckList = Arc<std::sync::Mutex<Vec<CheckEntry>>>;
50
51const DEFAULT_MAX_BODY: usize = 2 * 1024 * 1024;
52const DEFAULT_MAX_CONNECTIONS: usize = 1024;
53const DEFAULT_MAX_UPGRADED: usize = 1024;
54const DEFAULT_MAX_CONCURRENT_STREAMS: usize = 200;
55const DEFAULT_MAX_HEADERS: usize = 100;
56/// Default drain budget — leave headroom under k8s' 30s `terminationGracePeriodSeconds`.
57const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(20);
58const DEFAULT_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
59/// Keep-alive idle wait between requests (also Slowloris / first-header wait via hyper).
60const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
61
62/// Thin wrapper over [`Router`] plus server settings and lifecycle hooks.
63pub struct App {
64    pub(crate) router: Router,
65    pub(crate) max_body_size: usize,
66    pub(crate) max_connections: usize,
67    pub(crate) max_upgraded_connections: usize,
68    pub(crate) max_concurrent_streams: usize,
69    pub(crate) max_headers: usize,
70    pub(crate) max_buf_size: Option<usize>,
71    pub(crate) request_timeout: Option<Duration>,
72    pub(crate) header_read_timeout: Duration,
73    pub(crate) idle_timeout: Duration,
74    pub(crate) drain_timeout: Duration,
75    pub(crate) keep_alive: bool,
76    pub(crate) trust_proxy: bool,
77    pub(crate) reuseport: bool,
78    /// Set by CLI listen helpers — BackgroundServices skipped unless `service_in_cli`.
79    pub(crate) cli_mode: bool,
80    pub(crate) service_in_cli: bool,
81    pub(crate) hsts: bool,
82    pub(crate) alt_svc: Option<String>,
83    /// TLS attached by plugins (e.g. Acme) — used by [`BoundApp`] when `.tls()` was not called.
84    #[cfg(feature = "tls")]
85    pub(crate) tls: Option<crate::Tls>,
86    pub(crate) installed_plugins: HashSet<&'static str>,
87    pub(crate) installed_plugin_meta: Vec<InstalledPlugin>,
88    pub(crate) missing_plugin_requires: Vec<(&'static str, &'static str)>,
89    pub(crate) duplicate_plugin_ids: Vec<&'static str>,
90    pub(crate) plugin_sdk_errors: Vec<String>,
91    pub(crate) on_startup: Vec<StartupHook>,
92    pub(crate) on_shutdown: Vec<ShutdownHook>,
93    pub(crate) services: Vec<BoxedService>,
94    pub(crate) cli_commands: HashMap<&'static str, CliCommandFn>,
95    pub(crate) checks: CheckList,
96    pub(crate) probes: bool,
97}
98
99impl App {
100    pub fn new() -> Self {
101        Self {
102            router: Router::new(),
103            max_body_size: DEFAULT_MAX_BODY,
104            max_connections: DEFAULT_MAX_CONNECTIONS,
105            max_upgraded_connections: DEFAULT_MAX_UPGRADED,
106            max_concurrent_streams: DEFAULT_MAX_CONCURRENT_STREAMS,
107            max_headers: DEFAULT_MAX_HEADERS,
108            max_buf_size: None,
109            request_timeout: Some(Duration::from_secs(30)),
110            header_read_timeout: DEFAULT_HEADER_READ_TIMEOUT,
111            idle_timeout: DEFAULT_IDLE_TIMEOUT,
112            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
113            keep_alive: true,
114            trust_proxy: false,
115            reuseport: false,
116            cli_mode: false,
117            service_in_cli: false,
118            hsts: false,
119            alt_svc: None,
120            #[cfg(feature = "tls")]
121            tls: None,
122            installed_plugins: HashSet::new(),
123            installed_plugin_meta: Vec::new(),
124            missing_plugin_requires: Vec::new(),
125            duplicate_plugin_ids: Vec::new(),
126            plugin_sdk_errors: Vec::new(),
127            on_startup: Vec::new(),
128            on_shutdown: Vec::new(),
129            services: Vec::new(),
130            cli_commands: HashMap::new(),
131            checks: Arc::new(std::sync::Mutex::new(Vec::new())),
132            probes: false,
133        }
134    }
135
136    /// Register a plugin CLI command handled by [`Self::run`] (e.g. `"migrate"`).
137    pub fn register_cli<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
138    where
139        F: Fn(Arc<StateMap>, Vec<String>) -> Fut + Send + Sync + 'static,
140        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
141    {
142        self.cli_commands
143            .insert(name, Arc::new(move |state, args| Box::pin(f(state, args))));
144        self
145    }
146
147    /// Register a readiness check (`CheckKind::Ready`) for `GET /ready` and CLI `check`.
148    pub fn register_check<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
149    where
150        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
151        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
152    {
153        self.push_check(name, CheckKind::Ready, f)
154    }
155
156    /// Register a deploy-time audit (`CheckKind::Audit`) — CLI `check` only, not `/ready`.
157    pub fn register_audit<F, Fut>(&mut self, name: &'static str, f: F) -> &mut Self
158    where
159        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
160        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
161    {
162        self.push_check(name, CheckKind::Audit, f)
163    }
164
165    fn push_check<F, Fut>(&mut self, name: &'static str, kind: CheckKind, f: F) -> &mut Self
166    where
167        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
168        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
169    {
170        self.checks.lock().expect("checks lock").push((
171            name,
172            kind,
173            Arc::new(move |state| Box::pin(f(state))),
174        ));
175        self
176    }
177
178    /// Run registered checks filtered by [`CheckKind`].
179    pub async fn run_checks(&self, state: Arc<StateMap>, kinds: &[CheckKind]) -> Vec<CheckResult> {
180        run_check_list(&self.checks, state, kinds).await
181    }
182
183    /// Install k8s-style probes: `GET /healthz` (liveness) and `GET /ready` (Ready checks).
184    ///
185    /// Idempotent. Presets (`App::web` / `App::api`) call this automatically.
186    pub fn with_probes(&mut self) -> &mut Self {
187        if self.probes {
188            return self;
189        }
190        self.probes = true;
191        let checks = Arc::clone(&self.checks);
192
193        self.get("/healthz", || async {
194            Response::json(&serde_json::json!({ "status": "ok" }))
195        });
196
197        self.get("/ready", move |req: Request| {
198            let checks = Arc::clone(&checks);
199            async move {
200                let results = run_check_list(&checks, req.states(), &[CheckKind::Ready]).await;
201                ready_response(&results)
202            }
203        });
204
205        self
206    }
207
208    pub fn max_body_size(&mut self, bytes: usize) -> &mut Self {
209        self.max_body_size = bytes;
210        self.router
211            .defaults
212            .insert(crate::limits::MaxBody::bytes(bytes));
213        self
214    }
215
216    /// Cap concurrent TCP/UDS connections (default 1024).
217    pub fn max_connections(&mut self, n: usize) -> &mut Self {
218        self.max_connections = n.max(1);
219        self
220    }
221
222    /// Cap concurrent HTTP upgrades (WebSocket, …). Excess → **503** + `Retry-After`.
223    pub fn max_upgraded_connections(&mut self, n: usize) -> &mut Self {
224        self.max_upgraded_connections = n.max(1);
225        self
226    }
227
228    /// Cap concurrent HTTP/2 streams per connection (default 200).
229    /// Excess streams → `GOAWAY`/stream-level rejection handled by hyper.
230    pub fn max_concurrent_streams(&mut self, n: usize) -> &mut Self {
231        self.max_concurrent_streams = n.max(1);
232        self
233    }
234
235    /// Max HTTP/1 header count (default 100). Excess → 431 from hyper.
236    pub fn max_headers(&mut self, n: usize) -> &mut Self {
237        self.max_headers = n.max(1);
238        self
239    }
240
241    /// Cap hyper's connection buffer (headers + body framing). Minimum 8192.
242    /// Default ~400 KiB. Use this to bound oversized header blocks.
243    pub fn max_buf_size(&mut self, bytes: usize) -> &mut Self {
244        self.max_buf_size = Some(bytes.max(8192));
245        self
246    }
247
248    /// Per-request timeout around the handler (default 30s). `None` disables.
249    ///
250    /// Measured: timeout ends when the handler returns a [`Response`]. Streaming
251    /// response bodies (SSE) continue afterward and are **not** cut by this timer.
252    /// Idle between stream chunks is governed by TCP/keep-alive, not this setting.
253    pub fn request_timeout(&mut self, timeout: Option<Duration>) -> &mut Self {
254        self.request_timeout = timeout;
255        if let Some(d) = timeout {
256            self.router
257                .defaults
258                .insert(crate::limits::RequestTimeout(d));
259        }
260        self
261    }
262
263    /// Timeout for reading request headers (Slowloris). Also applied while waiting
264    /// for the next keep-alive request (see [`Self::idle_timeout`]). Default 10s.
265    pub fn header_read_timeout(&mut self, timeout: Duration) -> &mut Self {
266        self.header_read_timeout = timeout;
267        self
268    }
269
270    /// Keep-alive idle: how long a quiet connection may wait for the next request.
271    /// Hyper uses one timer for header reads; the effective wait is
272    /// `min(header_read_timeout, idle_timeout)`. Default 60s.
273    pub fn idle_timeout(&mut self, timeout: Duration) -> &mut Self {
274        self.idle_timeout = timeout;
275        self
276    }
277
278    /// How long to wait for in-flight connections after accept stops (default 20s).
279    pub fn drain_timeout(&mut self, timeout: Duration) -> &mut Self {
280        self.drain_timeout = timeout;
281        self
282    }
283
284    /// HTTP/1 keep-alive (default `true`).
285    pub fn keep_alive(&mut self, enabled: bool) -> &mut Self {
286        self.keep_alive = enabled;
287        self
288    }
289
290    /// When true, `ClientAddr` may use `X-Forwarded-For` / `Forwarded` (only behind a trusted proxy).
291    pub fn trust_proxy(&mut self, trust: bool) -> &mut Self {
292        self.trust_proxy = trust;
293        self
294    }
295
296    /// Mark this app as running under the CLI helper (skips BackgroundServices by default).
297    pub fn cli_mode(&mut self, enabled: bool) -> &mut Self {
298        self.cli_mode = enabled;
299        self
300    }
301
302    /// Start BackgroundServices even when [`Self::cli_mode`] is set (default `false`).
303    pub fn service_in_cli(&mut self, enabled: bool) -> &mut Self {
304        self.service_in_cli = enabled;
305        self
306    }
307
308    pub fn install<P: Plugin>(&mut self, plugin: P) -> &mut Self {
309        let plugin_id = plugin.id();
310        if self.installed_plugins.contains(plugin_id) {
311            self.duplicate_plugin_ids.push(plugin_id);
312            return self;
313        }
314        let meta = plugin.meta();
315        for dep in plugin.requires() {
316            if !self.installed_plugins.contains(dep) {
317                self.missing_plugin_requires.push((plugin_id, dep));
318            }
319        }
320        match check_plugin_sdk(meta.sdk, PLUGIN_SDK_VERSION) {
321            SdkCompat::Ok => {}
322            SdkCompat::Warn {
323                core,
324                plugin: declared,
325            } => {
326                tracing::warn!(
327                    plugin = plugin_id,
328                    plugin_sdk = %declared,
329                    core_sdk = %core,
330                    "plugin SDK is older than core; consider rebuilding against the current Plugin SDK"
331                );
332            }
333            SdkCompat::Error(msg) => {
334                self.plugin_sdk_errors
335                    .push(format!("plugin `{plugin_id}`: {msg}"));
336            }
337        }
338        self.installed_plugin_meta.push(InstalledPlugin {
339            id: plugin_id,
340            meta,
341        });
342        plugin.install(self);
343        self.installed_plugins.insert(plugin_id);
344        self
345    }
346
347    /// Whether a plugin with this [`Plugin::id`] was already installed.
348    pub fn has_plugin(&self, id: &str) -> bool {
349        self.installed_plugins.contains(id)
350    }
351
352    /// Metadata for every plugin passed to [`Self::install`] (order preserved).
353    pub fn installed_plugin_meta(&self) -> &[InstalledPlugin] {
354        &self.installed_plugin_meta
355    }
356
357    /// Primary app entrypoint: run as a server process.
358    ///
359    /// CLI mode:
360    /// - `check`
361    /// - `routes`
362    /// - `plugins`
363    /// - `openapi --out <path>`
364    /// - `tasks`
365    /// - `i18n missing`
366    ///
367    /// Non-CLI mode binds via [`Bind::Env`] (`HOST`/`PORT`, default port `3000`).
368    /// Prefer [`App::bind`] + [`BoundApp::run`] when the address is fixed in code.
369    pub async fn run(self) -> Result<()> {
370        self.bind(Bind::Env { default_port: 3000 }).run().await
371    }
372
373    pub(crate) async fn run_cli_command(&self, args: &[String]) -> Result<bool> {
374        let Some(cmd) = args.first().map(String::as_str) else {
375            return Ok(false);
376        };
377
378        let server = self.build()?;
379        let state = server.state();
380        for hook in &server.startups {
381            hook(Arc::clone(&state)).await?;
382        }
383
384        let rest: Vec<String> = args.iter().skip(1).cloned().collect();
385        let handled = if let Some(handler) = self.cli_commands.get(cmd) {
386            handler(Arc::clone(&state), rest).await?;
387            true
388        } else {
389            match cmd {
390                "check" => {
391                    // `build()` already ran — plugin `requires()` are satisfied.
392                    println!("ok plugins");
393                    let results = self
394                        .run_checks(Arc::clone(&state), &[CheckKind::Ready, CheckKind::Audit])
395                        .await;
396                    let mut failed = false;
397                    for r in &results {
398                        if r.ok {
399                            println!("ok {}", r.name);
400                        } else {
401                            println!("fail {} — {}", r.name, r.error.as_deref().unwrap_or(""));
402                            failed = true;
403                        }
404                    }
405                    if failed {
406                        return Err(Error::Internal("one or more checks failed".into()));
407                    }
408                    println!("ok");
409                    true
410                }
411                "routes" => {
412                    println!("{}", self.explain());
413                    true
414                }
415                "plugins" => {
416                    for p in &self.installed_plugin_meta {
417                        let desc = if p.meta.description.is_empty() {
418                            "-"
419                        } else {
420                            p.meta.description
421                        };
422                        println!(
423                            "{:<24} {:<20} sdk={}  {}",
424                            p.id, p.meta.name, p.meta.sdk, desc
425                        );
426                    }
427                    if self.installed_plugin_meta.is_empty() {
428                        println!("(no plugins installed)");
429                    }
430                    true
431                }
432                "openapi" => {
433                    let out_idx = args.iter().position(|a| a == "--out");
434                    let out_path = out_idx
435                        .and_then(|idx| args.get(idx + 1))
436                        .ok_or_else(|| Error::Internal("openapi requires --out <path>".into()))?;
437                    let res = server
438                        .handle_request(Method::GET, "/docs/openapi.json", "")
439                        .await;
440                    if !res.status_code().is_success() {
441                        return Err(Error::Internal(format!(
442                            "openapi endpoint failed with status {}",
443                            res.status_code()
444                        )));
445                    }
446                    let bytes = res
447                        .body_bytes()
448                        .ok_or_else(|| Error::Internal("openapi body is streaming".into()))?;
449                    fs::write(out_path, bytes).map_err(|e| {
450                        Error::Internal(format!("failed writing openapi to {out_path}: {e}"))
451                    })?;
452                    println!("wrote {}", out_path);
453                    true
454                }
455                "tasks" => {
456                    println!(
457                        "tasks CLI requires the Tasks plugin (`app.install(Tasks::…)`).\n\
458                         Then: tasks list | tasks schedule | tasks run NAME"
459                    );
460                    true
461                }
462                "i18n" if args.get(1).map(String::as_str) == Some("missing") => {
463                    let res = server
464                        .handle_request(Method::GET, "/_i18n/_missing.json", "")
465                        .await;
466                    if let Some(body) = res.body_bytes() {
467                        println!("{}", String::from_utf8_lossy(body));
468                    } else {
469                        println!("i18n missing endpoint returned streaming body");
470                    }
471                    true
472                }
473                "i18n" => false,
474                _ => false,
475            }
476        };
477
478        if handled {
479            for hook in &server.shutdowns {
480                hook().await;
481            }
482        }
483        Ok(handled)
484    }
485
486    /// Register a process-local [`BackgroundService`].
487    ///
488    /// Lifecycle: `compile → on_startup → services → accept`;
489    /// stop: `stop accept → drain → stop services → on_shutdown`.
490    pub fn service<S: BackgroundService + 'static>(&mut self, service: S) -> &mut Self {
491        self.services.push(Box::new(service));
492        self
493    }
494
495    /// Run before accepting connections. `Err` prevents the server from starting.
496    pub fn on_startup<F, Fut>(&mut self, f: F) -> &mut Self
497    where
498        F: Fn(Arc<StateMap>) -> Fut + Send + Sync + 'static,
499        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
500    {
501        self.on_startup
502            .push(Arc::new(move |state| Box::pin(f(state))));
503        self
504    }
505
506    /// Run after the accept loop stops, connections drain, and services stop.
507    pub fn on_shutdown<F, Fut>(&mut self, f: F) -> &mut Self
508    where
509        F: Fn() -> Fut + Send + Sync + 'static,
510        Fut: std::future::Future<Output = ()> + Send + 'static,
511    {
512        self.on_shutdown.push(Arc::new(move || Box::pin(f())));
513        self
514    }
515
516    /// Shared [`EventBus`] — inserts a default bus into app state on first use.
517    pub fn events(&mut self) -> EventBus {
518        if let Some(bus) = self.try_state::<EventBus>() {
519            return (*bus).clone();
520        }
521        let bus = EventBus::new();
522        self.state(bus.clone());
523        bus
524    }
525
526    /// Route map for debugging / startup banner.
527    pub fn explain(&self) -> String {
528        self.router.explain()
529    }
530
531    /// Handle one request (compiles the router each call). Prefer [`Self::build`].
532    pub async fn handle(&self, req: Request) -> Response {
533        match self.build() {
534            Ok(server) => server.handle(req).await,
535            Err(err) => err.into_response(),
536        }
537    }
538
539    /// Sugar over [`Request::builder`] + [`Self::handle`] (no custom headers).
540    /// For headers use `Request::builder().header(...).build()` + [`Self::handle`].
541    /// Prefer [`Server::handle_request`] after [`Self::build`].
542    pub async fn handle_request(&self, method: Method, path: &str, body: &str) -> Response {
543        let req = Request::builder()
544            .method(method)
545            .path(path)
546            .body(Bytes::from(body.to_string()))
547            .build();
548        self.handle(req).await
549    }
550
551    /// Run startup hooks via [`Server::run_startup`] (non-destructive).
552    #[cfg(any(test, feature = "testing"))]
553    pub async fn run_startup(&self) -> Result<Arc<StateMap>> {
554        self.build()?.run_startup().await
555    }
556
557    /// Run shutdown hooks via [`Server::run_shutdown`] (non-destructive).
558    #[cfg(any(test, feature = "testing"))]
559    pub async fn run_shutdown(&self) {
560        if let Ok(server) = self.build() {
561            server.run_shutdown().await;
562        }
563    }
564}
565
566pub(crate) fn addr_from_env(default_port: u16) -> Result<SocketAddr> {
567    let port = std::env::var("PORT")
568        .ok()
569        .and_then(|p| p.parse::<u16>().ok())
570        .unwrap_or(default_port);
571
572    match std::env::var("HOST") {
573        Ok(host) if !host.is_empty() => {
574            if let Ok(ip) = host.parse::<IpAddr>() {
575                return Ok(SocketAddr::new(ip, port));
576            }
577            // Allow HOST="127.0.0.1:3000" to override port entirely.
578            if let Ok(addr) = host.parse::<SocketAddr>() {
579                return Ok(addr);
580            }
581            format!("{host}:{port}")
582                .parse()
583                .map_err(|e| Error::Internal(format!("HOST={host:?} invalid: {e}")))
584        }
585        _ => Ok(SocketAddr::from(([0, 0, 0, 0], port))),
586    }
587}
588
589impl Default for App {
590    fn default() -> Self {
591        Self::new()
592    }
593}
594
595impl Deref for App {
596    type Target = Router;
597
598    fn deref(&self) -> &Router {
599        &self.router
600    }
601}
602
603impl DerefMut for App {
604    fn deref_mut(&mut self) -> &mut Router {
605        &mut self.router
606    }
607}
608
609async fn run_check_list(
610    checks: &CheckList,
611    state: Arc<StateMap>,
612    kinds: &[CheckKind],
613) -> Vec<CheckResult> {
614    let entries: Vec<CheckEntry> = checks.lock().expect("checks lock").clone();
615    let mut out = Vec::with_capacity(entries.len());
616    for (name, kind, check) in entries {
617        if !kinds.contains(&kind) {
618            continue;
619        }
620        match check(Arc::clone(&state)).await {
621            Ok(()) => out.push(CheckResult {
622                name,
623                ok: true,
624                error: None,
625            }),
626            Err(e) => out.push(CheckResult {
627                name,
628                ok: false,
629                error: Some(e.to_string()),
630            }),
631        }
632    }
633    out
634}
635
636fn ready_response(results: &[CheckResult]) -> Response {
637    let mut checks = serde_json::Map::new();
638    let mut failed = Vec::new();
639    for r in results {
640        if r.ok {
641            checks.insert(r.name.to_string(), serde_json::json!("ok"));
642        } else {
643            let msg = r.error.clone().unwrap_or_else(|| "failed".into());
644            checks.insert(r.name.to_string(), serde_json::json!(msg));
645            failed.push(r.name);
646        }
647    }
648    let mut res = if failed.is_empty() {
649        Response::json(&serde_json::json!({
650            "status": "ok",
651            "checks": checks,
652        }))
653    } else {
654        Response::json(&serde_json::json!({
655            "status": "not_ready",
656            "failed": failed,
657            "checks": checks,
658        }))
659        .status(503)
660    };
661    // Set by `cargo sovax dev --graceful` so the orchestrator can detect the new process
662    // while the old one still answers on the same REUSEPORT socket.
663    if let Ok(id) = std::env::var("SOVA_INSTANCE_ID") {
664        if !id.is_empty() {
665            res = res.header("x-sova-instance", id);
666        }
667    }
668    res
669}
670
671#[cfg(test)]
672mod env_addr_tests {
673    use super::addr_from_env;
674    use std::sync::Mutex;
675
676    static ENV_LOCK: Mutex<()> = Mutex::new(());
677
678    #[test]
679    fn port_from_env() {
680        let _g = ENV_LOCK.lock().unwrap();
681        std::env::set_var("PORT", "9876");
682        std::env::remove_var("HOST");
683        let addr = addr_from_env(3000).unwrap();
684        assert_eq!(addr.port(), 9876);
685        std::env::remove_var("PORT");
686    }
687
688    #[test]
689    fn host_ip_from_env() {
690        let _g = ENV_LOCK.lock().unwrap();
691        std::env::remove_var("PORT");
692        std::env::set_var("HOST", "127.0.0.1");
693        let addr = addr_from_env(3000).unwrap();
694        assert_eq!(addr, "127.0.0.1:3000".parse().unwrap());
695        std::env::remove_var("HOST");
696    }
697}