Skip to main content

reddb_server/server/
http_limits.rs

1//! Resolution of the three HTTP handler-pool knobs (issue #574 slice 5).
2//!
3//! The values are configurable through the standard precedence chain
4//! used elsewhere in the boot path:
5//!
6//!   flag > red_config > env > built-in default
7//!
8//! Built-in defaults reproduce the hard-coded values from slices 1+2:
9//!   - max_handlers      = (2 * num_cpus).clamp(8, 256)
10//!   - handler_timeout   = 30_000 ms
11//!   - retry_after_secs  = 5
12//!   - max_inflight_per_principal = 64   (issue #934; 0 disables)
13//!
14//! Each knob is validated at parse time and at resolution time so a
15//! stale red_config value cannot corrupt the running server.
16
17/// Lower bound for `handler_timeout_ms`. Anything below this is so
18/// short the deadline trips on healthy requests; we reject the value.
19pub const MIN_HANDLER_TIMEOUT_MS: u64 = 100;
20/// Inclusive bounds for `retry_after_secs`. Below 1s means clients
21/// hammer the server; above 30s means a transient overload looks like
22/// a permanent outage to load balancers.
23pub const MIN_RETRY_AFTER_SECS: u64 = 1;
24pub const MAX_RETRY_AFTER_SECS: u64 = 30;
25
26/// Built-in default for `max_handlers`. Matches
27/// `HttpConnectionLimiter::with_default_cap`.
28pub fn default_max_handlers() -> usize {
29    let cores = std::thread::available_parallelism()
30        .map(|n| n.get())
31        .unwrap_or(1);
32    (2 * cores).clamp(8, 256)
33}
34
35pub const DEFAULT_HANDLER_TIMEOUT_MS: u64 = 30_000;
36pub const DEFAULT_RETRY_AFTER_SECS: u64 = 5;
37
38/// Built-in default for `max_inflight_per_principal` (issue #934). Bounds
39/// any single principal's concurrent in-flight requests at the async edge so
40/// one caller can't drain the whole global handler cap and starve the rest.
41/// `0` disables the per-principal cap entirely; a single-tenant deployment
42/// can set it there to pay nothing. Chosen below the typical multi-core
43/// global cap (256) so it provides real fairness headroom, while sitting
44/// above the global cap on tiny boxes (where there is no abuse pressure) so
45/// it never trips spuriously.
46pub const DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL: usize = 64;
47
48/// Validate a `max_handlers` candidate from any source. Returns the
49/// value unchanged on success.
50pub fn validate_max_handlers(value: usize) -> Result<usize, String> {
51    if value == 0 {
52        return Err("http max_handlers must be >= 1".to_string());
53    }
54    Ok(value)
55}
56
57/// Validate a `max_inflight_per_principal` candidate (issue #934). Every
58/// `usize` is acceptable: a positive value caps each principal's concurrent
59/// in-flight requests, and `0` disables the per-principal cap. Present for
60/// symmetry with the other knobs so the CLI parser can run all four through
61/// the same validated-parse helper.
62pub fn validate_max_inflight_per_principal(value: usize) -> Result<usize, String> {
63    Ok(value)
64}
65
66pub fn validate_handler_timeout_ms(value: u64) -> Result<u64, String> {
67    if value < MIN_HANDLER_TIMEOUT_MS {
68        return Err(format!(
69            "http handler_timeout_ms must be >= {MIN_HANDLER_TIMEOUT_MS}"
70        ));
71    }
72    Ok(value)
73}
74
75pub fn validate_retry_after_secs(value: u64) -> Result<u64, String> {
76    if !(MIN_RETRY_AFTER_SECS..=MAX_RETRY_AFTER_SECS).contains(&value) {
77        return Err(format!(
78            "http retry_after_secs must be in [{MIN_RETRY_AFTER_SECS}, {MAX_RETRY_AFTER_SECS}]"
79        ));
80    }
81    Ok(value)
82}
83
84/// CLI-layer input. Each pair holds the already-validated value coming
85/// from a flag and from an env var, respectively. The resolver applies
86/// the `flag > red_config > env > default` precedence using these
87/// inputs plus a config-store lookup.
88#[derive(Debug, Default, Clone)]
89pub struct HttpLimitsCliInput {
90    pub max_handlers_flag: Option<usize>,
91    pub max_handlers_env: Option<usize>,
92    pub handler_timeout_ms_flag: Option<u64>,
93    pub handler_timeout_ms_env: Option<u64>,
94    pub retry_after_secs_flag: Option<u64>,
95    pub retry_after_secs_env: Option<u64>,
96    pub max_inflight_per_principal_flag: Option<usize>,
97    pub max_inflight_per_principal_env: Option<usize>,
98}
99
100/// Resolved values after applying the full precedence chain. Stamped
101/// into both the `RedDBServer` and the startup log line.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct HttpLimitsResolved {
104    pub max_handlers: usize,
105    pub handler_timeout_ms: u64,
106    pub retry_after_secs: u64,
107    /// Per-principal concurrent in-flight cap (issue #934). `0` disables.
108    pub max_inflight_per_principal: usize,
109}
110
111impl HttpLimitsResolved {
112    pub fn builtin_defaults() -> Self {
113        Self {
114            max_handlers: default_max_handlers(),
115            handler_timeout_ms: DEFAULT_HANDLER_TIMEOUT_MS,
116            retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
117            max_inflight_per_principal: DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL,
118        }
119    }
120}
121
122/// Apply the `flag > red_config > env > default` chain.
123///
124/// `config_lookup` is a closure so this function is independent of the
125/// runtime/config-store type — keeps the resolver pure and testable.
126/// Each lookup returns the raw text value stored under the given key,
127/// matching how `set_config_tree` persists scalars.
128pub fn resolve_http_limits<F>(input: &HttpLimitsCliInput, config_lookup: F) -> HttpLimitsResolved
129where
130    F: Fn(&str) -> Option<String>,
131{
132    let defaults = HttpLimitsResolved::builtin_defaults();
133
134    let max_handlers = input
135        .max_handlers_flag
136        .or_else(|| {
137            config_lookup("red.http.max_handlers")
138                .and_then(|raw| raw.parse::<usize>().ok())
139                .and_then(|v| validate_max_handlers(v).ok())
140        })
141        .or(input.max_handlers_env)
142        .unwrap_or(defaults.max_handlers);
143
144    let handler_timeout_ms = input
145        .handler_timeout_ms_flag
146        .or_else(|| {
147            config_lookup("red.http.handler_timeout_ms")
148                .and_then(|raw| raw.parse::<u64>().ok())
149                .and_then(|v| validate_handler_timeout_ms(v).ok())
150        })
151        .or(input.handler_timeout_ms_env)
152        .unwrap_or(defaults.handler_timeout_ms);
153
154    let retry_after_secs = input
155        .retry_after_secs_flag
156        .or_else(|| {
157            config_lookup("red.http.retry_after_secs")
158                .and_then(|raw| raw.parse::<u64>().ok())
159                .and_then(|v| validate_retry_after_secs(v).ok())
160        })
161        .or(input.retry_after_secs_env)
162        .unwrap_or(defaults.retry_after_secs);
163
164    let max_inflight_per_principal = input
165        .max_inflight_per_principal_flag
166        .or_else(|| {
167            config_lookup("red.http.max_inflight_per_principal")
168                .and_then(|raw| raw.parse::<usize>().ok())
169                .and_then(|v| validate_max_inflight_per_principal(v).ok())
170        })
171        .or(input.max_inflight_per_principal_env)
172        .unwrap_or(defaults.max_inflight_per_principal);
173
174    HttpLimitsResolved {
175        max_handlers,
176        handler_timeout_ms,
177        retry_after_secs,
178        max_inflight_per_principal,
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::collections::HashMap;
186
187    fn no_config() -> impl Fn(&str) -> Option<String> {
188        |_| None
189    }
190
191    fn map_lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
192        move |key| map.get(key).map(|v| v.to_string())
193    }
194
195    #[test]
196    fn defaults_when_nothing_set() {
197        let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
198        assert_eq!(resolved, HttpLimitsResolved::builtin_defaults());
199    }
200
201    #[test]
202    fn flag_wins_over_env_and_default() {
203        let input = HttpLimitsCliInput {
204            max_handlers_flag: Some(16),
205            max_handlers_env: Some(99),
206            handler_timeout_ms_flag: Some(5_000),
207            handler_timeout_ms_env: Some(7_000),
208            retry_after_secs_flag: Some(3),
209            retry_after_secs_env: Some(7),
210            ..Default::default()
211        };
212        let resolved = resolve_http_limits(&input, no_config());
213        assert_eq!(resolved.max_handlers, 16);
214        assert_eq!(resolved.handler_timeout_ms, 5_000);
215        assert_eq!(resolved.retry_after_secs, 3);
216    }
217
218    #[test]
219    fn flag_wins_over_red_config() {
220        let input = HttpLimitsCliInput {
221            max_handlers_flag: Some(16),
222            handler_timeout_ms_flag: Some(5_000),
223            retry_after_secs_flag: Some(3),
224            ..Default::default()
225        };
226        let lookup = map_lookup(HashMap::from([
227            ("red.http.max_handlers", "64"),
228            ("red.http.handler_timeout_ms", "9000"),
229            ("red.http.retry_after_secs", "9"),
230        ]));
231        let resolved = resolve_http_limits(&input, lookup);
232        assert_eq!(resolved.max_handlers, 16);
233        assert_eq!(resolved.handler_timeout_ms, 5_000);
234        assert_eq!(resolved.retry_after_secs, 3);
235    }
236
237    #[test]
238    fn red_config_wins_over_env() {
239        let input = HttpLimitsCliInput {
240            max_handlers_env: Some(99),
241            handler_timeout_ms_env: Some(7_000),
242            retry_after_secs_env: Some(7),
243            ..Default::default()
244        };
245        let lookup = map_lookup(HashMap::from([
246            ("red.http.max_handlers", "64"),
247            ("red.http.handler_timeout_ms", "9000"),
248            ("red.http.retry_after_secs", "9"),
249        ]));
250        let resolved = resolve_http_limits(&input, lookup);
251        assert_eq!(resolved.max_handlers, 64);
252        assert_eq!(resolved.handler_timeout_ms, 9_000);
253        assert_eq!(resolved.retry_after_secs, 9);
254    }
255
256    #[test]
257    fn env_wins_over_default() {
258        let input = HttpLimitsCliInput {
259            max_handlers_env: Some(11),
260            handler_timeout_ms_env: Some(1_500),
261            retry_after_secs_env: Some(2),
262            ..Default::default()
263        };
264        let resolved = resolve_http_limits(&input, no_config());
265        assert_eq!(resolved.max_handlers, 11);
266        assert_eq!(resolved.handler_timeout_ms, 1_500);
267        assert_eq!(resolved.retry_after_secs, 2);
268    }
269
270    #[test]
271    fn invalid_red_config_is_ignored_in_favor_of_lower_layers() {
272        // Garbage in red_config — must not break boot. Env value wins;
273        // if env is absent, default wins.
274        let input = HttpLimitsCliInput {
275            max_handlers_env: Some(11),
276            ..Default::default()
277        };
278        let lookup = map_lookup(HashMap::from([
279            ("red.http.max_handlers", "0"),        // rejected by validate
280            ("red.http.handler_timeout_ms", "5"),  // rejected by validate
281            ("red.http.retry_after_secs", "9999"), // rejected by validate
282        ]));
283        let resolved = resolve_http_limits(&input, lookup);
284        // max_handlers: red_config invalid -> env (11)
285        assert_eq!(resolved.max_handlers, 11);
286        // handler_timeout_ms: red_config invalid, no env -> default
287        assert_eq!(resolved.handler_timeout_ms, DEFAULT_HANDLER_TIMEOUT_MS);
288        // retry_after_secs: red_config invalid, no env -> default
289        assert_eq!(resolved.retry_after_secs, DEFAULT_RETRY_AFTER_SECS);
290    }
291
292    #[test]
293    fn validators_reject_zero_equivalent_values() {
294        assert!(validate_max_handlers(0).is_err());
295        assert!(validate_max_handlers(1).is_ok());
296
297        assert!(validate_handler_timeout_ms(0).is_err());
298        assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS - 1).is_err());
299        assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS).is_ok());
300
301        assert!(validate_retry_after_secs(0).is_err());
302        assert!(validate_retry_after_secs(MIN_RETRY_AFTER_SECS).is_ok());
303        assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS).is_ok());
304        assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS + 1).is_err());
305    }
306
307    #[test]
308    fn default_max_handlers_in_bounds() {
309        let cap = default_max_handlers();
310        assert!((8..=256).contains(&cap));
311    }
312
313    #[test]
314    fn max_inflight_per_principal_follows_precedence_chain() {
315        // default
316        let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
317        assert_eq!(
318            resolved.max_inflight_per_principal,
319            DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL
320        );
321
322        // env over default
323        let input = HttpLimitsCliInput {
324            max_inflight_per_principal_env: Some(17),
325            ..Default::default()
326        };
327        assert_eq!(
328            resolve_http_limits(&input, no_config()).max_inflight_per_principal,
329            17
330        );
331
332        // red_config over env
333        let input = HttpLimitsCliInput {
334            max_inflight_per_principal_env: Some(17),
335            ..Default::default()
336        };
337        let lookup = map_lookup(HashMap::from([(
338            "red.http.max_inflight_per_principal",
339            "9",
340        )]));
341        assert_eq!(
342            resolve_http_limits(&input, lookup).max_inflight_per_principal,
343            9
344        );
345
346        // flag over everything
347        let input = HttpLimitsCliInput {
348            max_inflight_per_principal_flag: Some(3),
349            max_inflight_per_principal_env: Some(17),
350            ..Default::default()
351        };
352        let lookup = map_lookup(HashMap::from([(
353            "red.http.max_inflight_per_principal",
354            "9",
355        )]));
356        assert_eq!(
357            resolve_http_limits(&input, lookup).max_inflight_per_principal,
358            3
359        );
360    }
361
362    #[test]
363    fn max_inflight_per_principal_zero_disables_and_is_honored() {
364        // 0 is a legal value (disables the per-principal cap) and must
365        // survive the resolve chain rather than being treated as unset.
366        let lookup = map_lookup(HashMap::from([(
367            "red.http.max_inflight_per_principal",
368            "0",
369        )]));
370        assert_eq!(
371            resolve_http_limits(&HttpLimitsCliInput::default(), lookup).max_inflight_per_principal,
372            0
373        );
374        assert!(validate_max_inflight_per_principal(0).is_ok());
375    }
376}