chio_http_serve/hygiene.rs
1use std::time::Duration;
2
3use axum::error_handling::HandleErrorLayer;
4use axum::extract::DefaultBodyLimit;
5use axum::http::StatusCode;
6use axum::Router;
7use tower::limit::GlobalConcurrencyLimitLayer;
8use tower::load_shed::LoadShedLayer;
9use tower::{BoxError, ServiceBuilder};
10use tower_http::timeout::TimeoutLayer;
11
12/// Wall-clock ceiling on the drain: how long to wait for in-flight requests to
13/// finish after the listener stops accepting. Operators must size the unit
14/// `TimeoutStopSec` at least this high plus a flush margin.
15pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(25);
16/// Per-request processing ceiling before the request is denied with 408.
17///
18/// This stays strictly below [`DEFAULT_DRAIN_TIMEOUT`] so a request admitted just
19/// before a stop signal reaches its own 408 and completes cleanly within the
20/// drain window, rather than being severed mid-flight when the forced-drain timer
21/// force-closes the connection. A serve site that lengthens the request timeout
22/// must lengthen the drain to match.
23pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
24/// Maximum concurrent in-flight requests before surplus load sheds with 503.
25pub const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 1024;
26/// Maximum simultaneously accepted TCP connections.
27pub const DEFAULT_MAX_CONNECTIONS: usize = 2048;
28
29/// Bounds and hygiene knobs for one serve site. The [`Default`] is a
30/// conservative, fail-closed posture: a request that exceeds a bound is denied,
31/// never queued.
32#[derive(Debug, Clone)]
33pub struct ServeHygieneConfig {
34 /// How long the drain waits for in-flight requests after the listener stops
35 /// accepting. See [`DEFAULT_DRAIN_TIMEOUT`].
36 pub drain_timeout: Duration,
37 /// Per-request processing timeout. `None` disables it.
38 pub request_timeout: Option<Duration>,
39 /// Maximum concurrent in-flight requests. Surplus load sheds with 503
40 /// instead of queuing. `None` disables the limit.
41 pub max_concurrent_requests: Option<usize>,
42 /// Maximum simultaneously accepted TCP connections, enforced by
43 /// [`MaxConnListener`](crate::MaxConnListener). `None` disables the cap.
44 pub max_connections: Option<usize>,
45 /// Global request body-size cap. `None` preserves each route's own limit,
46 /// which matters for sites that set a large upload limit on one route that a
47 /// global cap would clobber. A site with no route-local limit sets this
48 /// explicitly.
49 pub max_body_bytes: Option<usize>,
50}
51
52impl Default for ServeHygieneConfig {
53 fn default() -> Self {
54 Self {
55 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
56 request_timeout: Some(DEFAULT_REQUEST_TIMEOUT),
57 max_concurrent_requests: Some(DEFAULT_MAX_CONCURRENT_REQUESTS),
58 max_connections: Some(DEFAULT_MAX_CONNECTIONS),
59 max_body_bytes: None,
60 }
61 }
62}
63
64/// Map the error surfaced by the load-shed / concurrency-limit stack to a
65/// status. A shed request is a deliberate overload denial (503); anything else
66/// reaching this handler is an internal fault (500).
67async fn shed_to_status(error: BoxError) -> StatusCode {
68 if error.is::<tower::load_shed::error::Overloaded>() {
69 StatusCode::SERVICE_UNAVAILABLE
70 } else {
71 StatusCode::INTERNAL_SERVER_ERROR
72 }
73}
74
75/// Wrap `router` with the configured request timeout, concurrency limit fronted
76/// by load shedding, and optional body-size cap.
77///
78/// The stack returns a plain [`Router`], so a call site never touches tower
79/// types beyond this call. Load shedding sits outside the concurrency limit so a
80/// request over the limit fails fast with 503 rather than parking: in a
81/// [`ServiceBuilder`] the first layer listed is outermost, giving
82/// `HandleError(LoadShed(ConcurrencyLimit(router)))`. Both `LoadShed` and
83/// `GlobalConcurrencyLimit` are fallible services, so the pair is wrapped in a
84/// [`HandleErrorLayer`] that turns the shed error into a response, which is what
85/// lets the result stay an infallible `Router` layer.
86pub fn apply_server_hygiene(mut router: Router, config: &ServeHygieneConfig) -> Router {
87 if let Some(limit) = config.max_body_bytes {
88 router = router.layer(DefaultBodyLimit::max(limit));
89 }
90 if let Some(timeout) = config.request_timeout {
91 router = router.layer(TimeoutLayer::with_status_code(
92 StatusCode::REQUEST_TIMEOUT,
93 timeout,
94 ));
95 }
96 if let Some(max) = config.max_concurrent_requests {
97 router = router.layer(
98 ServiceBuilder::new()
99 .layer(HandleErrorLayer::new(shed_to_status))
100 .layer(LoadShedLayer::new())
101 .layer(GlobalConcurrencyLimitLayer::new(max)),
102 );
103 }
104 router
105}