1pub const MIN_HANDLER_TIMEOUT_MS: u64 = 100;
23pub const MIN_RETRY_AFTER_SECS: u64 = 1;
27pub const MAX_RETRY_AFTER_SECS: u64 = 30;
28
29pub fn default_max_handlers() -> usize {
32 let cores = std::thread::available_parallelism()
33 .map(|n| n.get())
34 .unwrap_or(1);
35 (2 * cores).clamp(8, 256)
36}
37
38pub fn default_execution_workers() -> usize {
42 std::thread::available_parallelism()
43 .map(|parallelism| parallelism.get())
44 .unwrap_or(1)
45}
46
47pub const DEFAULT_HANDLER_TIMEOUT_MS: u64 = 30_000;
48pub const DEFAULT_RETRY_AFTER_SECS: u64 = 5;
49
50pub const DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL: usize = 64;
58
59fn default_max_inflight_per_principal(max_handlers: usize) -> usize {
60 DEFAULT_MAX_INFLIGHT_PER_PRINCIPAL.min(max_handlers.saturating_sub(1).max(1))
61}
62
63pub fn validate_max_handlers(value: usize) -> Result<usize, String> {
66 if value == 0 {
67 return Err("http max_handlers must be >= 1".to_string());
68 }
69 Ok(value)
70}
71
72pub fn validate_execution_workers(value: usize) -> Result<usize, String> {
73 if value == 0 {
74 return Err("http execution_workers must be >= 1".to_string());
75 }
76 Ok(value)
77}
78
79pub fn validate_max_inflight_per_principal(value: usize) -> Result<usize, String> {
85 Ok(value)
86}
87
88pub fn validate_handler_timeout_ms(value: u64) -> Result<u64, String> {
89 if value < MIN_HANDLER_TIMEOUT_MS {
90 return Err(format!(
91 "http handler_timeout_ms must be >= {MIN_HANDLER_TIMEOUT_MS}"
92 ));
93 }
94 Ok(value)
95}
96
97pub fn validate_retry_after_secs(value: u64) -> Result<u64, String> {
98 if !(MIN_RETRY_AFTER_SECS..=MAX_RETRY_AFTER_SECS).contains(&value) {
99 return Err(format!(
100 "http retry_after_secs must be in [{MIN_RETRY_AFTER_SECS}, {MAX_RETRY_AFTER_SECS}]"
101 ));
102 }
103 Ok(value)
104}
105
106#[derive(Debug, Default, Clone)]
111pub struct HttpLimitsCliInput {
112 pub max_handlers_flag: Option<usize>,
113 pub max_handlers_env: Option<usize>,
114 pub execution_workers_flag: Option<usize>,
115 pub execution_workers_env: Option<usize>,
116 pub handler_timeout_ms_flag: Option<u64>,
117 pub handler_timeout_ms_env: Option<u64>,
118 pub retry_after_secs_flag: Option<u64>,
119 pub retry_after_secs_env: Option<u64>,
120 pub max_inflight_per_principal_flag: Option<usize>,
121 pub max_inflight_per_principal_env: Option<usize>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct HttpLimitsResolved {
128 pub max_handlers: usize,
129 pub execution_workers: usize,
133 pub handler_timeout_ms: u64,
134 pub retry_after_secs: u64,
135 pub max_inflight_per_principal: usize,
137}
138
139impl HttpLimitsResolved {
140 pub fn builtin_defaults() -> Self {
141 let max_handlers = default_max_handlers();
142 Self {
143 max_handlers,
144 execution_workers: default_execution_workers().min(max_handlers),
145 handler_timeout_ms: DEFAULT_HANDLER_TIMEOUT_MS,
146 retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
147 max_inflight_per_principal: default_max_inflight_per_principal(max_handlers),
148 }
149 }
150}
151
152pub fn resolve_http_limits<F>(input: &HttpLimitsCliInput, config_lookup: F) -> HttpLimitsResolved
159where
160 F: Fn(&str) -> Option<String>,
161{
162 let defaults = HttpLimitsResolved::builtin_defaults();
163
164 let max_handlers = input
165 .max_handlers_flag
166 .or_else(|| {
167 config_lookup("red.http.max_handlers")
168 .and_then(|raw| raw.parse::<usize>().ok())
169 .and_then(|v| validate_max_handlers(v).ok())
170 })
171 .or(input.max_handlers_env)
172 .unwrap_or(defaults.max_handlers);
173
174 let execution_workers = input
175 .execution_workers_flag
176 .or_else(|| {
177 config_lookup("red.http.execution_workers")
178 .and_then(|raw| raw.parse::<usize>().ok())
179 .and_then(|value| validate_execution_workers(value).ok())
180 })
181 .or(input.execution_workers_env)
182 .unwrap_or(defaults.execution_workers)
183 .min(max_handlers);
184
185 let handler_timeout_ms = input
186 .handler_timeout_ms_flag
187 .or_else(|| {
188 config_lookup("red.http.handler_timeout_ms")
189 .and_then(|raw| raw.parse::<u64>().ok())
190 .and_then(|v| validate_handler_timeout_ms(v).ok())
191 })
192 .or(input.handler_timeout_ms_env)
193 .unwrap_or(defaults.handler_timeout_ms);
194
195 let retry_after_secs = input
196 .retry_after_secs_flag
197 .or_else(|| {
198 config_lookup("red.http.retry_after_secs")
199 .and_then(|raw| raw.parse::<u64>().ok())
200 .and_then(|v| validate_retry_after_secs(v).ok())
201 })
202 .or(input.retry_after_secs_env)
203 .unwrap_or(defaults.retry_after_secs);
204
205 let max_inflight_per_principal = input
206 .max_inflight_per_principal_flag
207 .or_else(|| {
208 config_lookup("red.http.max_inflight_per_principal")
209 .and_then(|raw| raw.parse::<usize>().ok())
210 .and_then(|v| validate_max_inflight_per_principal(v).ok())
211 })
212 .or(input.max_inflight_per_principal_env)
213 .unwrap_or_else(|| default_max_inflight_per_principal(max_handlers));
214
215 HttpLimitsResolved {
216 max_handlers,
217 execution_workers,
218 handler_timeout_ms,
219 retry_after_secs,
220 max_inflight_per_principal,
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use std::collections::HashMap;
228
229 fn no_config() -> impl Fn(&str) -> Option<String> {
230 |_| None
231 }
232
233 fn map_lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
234 move |key| map.get(key).map(|v| v.to_string())
235 }
236
237 #[test]
238 fn defaults_when_nothing_set() {
239 let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
240 assert_eq!(resolved, HttpLimitsResolved::builtin_defaults());
241 }
242
243 #[test]
244 fn flag_wins_over_env_and_default() {
245 let input = HttpLimitsCliInput {
246 max_handlers_flag: Some(16),
247 max_handlers_env: Some(99),
248 execution_workers_flag: Some(4),
249 execution_workers_env: Some(12),
250 handler_timeout_ms_flag: Some(5_000),
251 handler_timeout_ms_env: Some(7_000),
252 retry_after_secs_flag: Some(3),
253 retry_after_secs_env: Some(7),
254 ..Default::default()
255 };
256 let resolved = resolve_http_limits(&input, no_config());
257 assert_eq!(resolved.max_handlers, 16);
258 assert_eq!(resolved.execution_workers, 4);
259 assert_eq!(resolved.handler_timeout_ms, 5_000);
260 assert_eq!(resolved.retry_after_secs, 3);
261 }
262
263 #[test]
264 fn flag_wins_over_red_config() {
265 let input = HttpLimitsCliInput {
266 max_handlers_flag: Some(16),
267 execution_workers_flag: Some(4),
268 handler_timeout_ms_flag: Some(5_000),
269 retry_after_secs_flag: Some(3),
270 ..Default::default()
271 };
272 let lookup = map_lookup(HashMap::from([
273 ("red.http.max_handlers", "64"),
274 ("red.http.execution_workers", "32"),
275 ("red.http.handler_timeout_ms", "9000"),
276 ("red.http.retry_after_secs", "9"),
277 ]));
278 let resolved = resolve_http_limits(&input, lookup);
279 assert_eq!(resolved.max_handlers, 16);
280 assert_eq!(resolved.execution_workers, 4);
281 assert_eq!(resolved.handler_timeout_ms, 5_000);
282 assert_eq!(resolved.retry_after_secs, 3);
283 }
284
285 #[test]
286 fn red_config_wins_over_env() {
287 let input = HttpLimitsCliInput {
288 max_handlers_env: Some(99),
289 execution_workers_env: Some(12),
290 handler_timeout_ms_env: Some(7_000),
291 retry_after_secs_env: Some(7),
292 ..Default::default()
293 };
294 let lookup = map_lookup(HashMap::from([
295 ("red.http.max_handlers", "64"),
296 ("red.http.execution_workers", "8"),
297 ("red.http.handler_timeout_ms", "9000"),
298 ("red.http.retry_after_secs", "9"),
299 ]));
300 let resolved = resolve_http_limits(&input, lookup);
301 assert_eq!(resolved.max_handlers, 64);
302 assert_eq!(resolved.execution_workers, 8);
303 assert_eq!(resolved.handler_timeout_ms, 9_000);
304 assert_eq!(resolved.retry_after_secs, 9);
305 }
306
307 #[test]
308 fn env_wins_over_default() {
309 let input = HttpLimitsCliInput {
310 max_handlers_env: Some(11),
311 execution_workers_env: Some(3),
312 handler_timeout_ms_env: Some(1_500),
313 retry_after_secs_env: Some(2),
314 ..Default::default()
315 };
316 let resolved = resolve_http_limits(&input, no_config());
317 assert_eq!(resolved.max_handlers, 11);
318 assert_eq!(resolved.execution_workers, 3);
319 assert_eq!(resolved.handler_timeout_ms, 1_500);
320 assert_eq!(resolved.retry_after_secs, 2);
321 }
322
323 #[test]
324 fn invalid_red_config_is_ignored_in_favor_of_lower_layers() {
325 let input = HttpLimitsCliInput {
328 max_handlers_env: Some(11),
329 ..Default::default()
330 };
331 let lookup = map_lookup(HashMap::from([
332 ("red.http.max_handlers", "0"), ("red.http.execution_workers", "0"), ("red.http.handler_timeout_ms", "5"), ("red.http.retry_after_secs", "9999"), ]));
337 let resolved = resolve_http_limits(&input, lookup);
338 assert_eq!(resolved.max_handlers, 11);
340 assert_eq!(
341 resolved.execution_workers,
342 HttpLimitsResolved::builtin_defaults()
343 .execution_workers
344 .min(11)
345 );
346 assert_eq!(resolved.handler_timeout_ms, DEFAULT_HANDLER_TIMEOUT_MS);
348 assert_eq!(resolved.retry_after_secs, DEFAULT_RETRY_AFTER_SECS);
350 }
351
352 #[test]
353 fn validators_reject_zero_equivalent_values() {
354 assert!(validate_max_handlers(0).is_err());
355 assert!(validate_max_handlers(1).is_ok());
356
357 assert!(validate_execution_workers(0).is_err());
358 assert!(validate_execution_workers(1).is_ok());
359
360 assert!(validate_handler_timeout_ms(0).is_err());
361 assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS - 1).is_err());
362 assert!(validate_handler_timeout_ms(MIN_HANDLER_TIMEOUT_MS).is_ok());
363
364 assert!(validate_retry_after_secs(0).is_err());
365 assert!(validate_retry_after_secs(MIN_RETRY_AFTER_SECS).is_ok());
366 assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS).is_ok());
367 assert!(validate_retry_after_secs(MAX_RETRY_AFTER_SECS + 1).is_err());
368 }
369
370 #[test]
371 fn default_max_handlers_in_bounds() {
372 let cap = default_max_handlers();
373 assert!((8..=256).contains(&cap));
374 }
375
376 #[test]
377 fn execution_workers_are_positive_and_never_exceed_handler_envelope() {
378 let defaults = HttpLimitsResolved::builtin_defaults();
379 assert!(defaults.execution_workers >= 1);
380 assert!(defaults.execution_workers <= defaults.max_handlers);
381
382 let input = HttpLimitsCliInput {
383 max_handlers_flag: Some(2),
384 execution_workers_flag: Some(16),
385 ..Default::default()
386 };
387 assert_eq!(
388 resolve_http_limits(&input, no_config()).execution_workers,
389 2
390 );
391 }
392
393 #[test]
394 fn max_inflight_per_principal_follows_precedence_chain() {
395 let resolved = resolve_http_limits(&HttpLimitsCliInput::default(), no_config());
397 assert_eq!(
398 resolved.max_inflight_per_principal,
399 default_max_inflight_per_principal(resolved.max_handlers)
400 );
401
402 let input = HttpLimitsCliInput {
403 max_handlers_flag: Some(2),
404 ..Default::default()
405 };
406 assert_eq!(
407 resolve_http_limits(&input, no_config()).max_inflight_per_principal,
408 1
409 );
410
411 let input = HttpLimitsCliInput {
413 max_inflight_per_principal_env: Some(17),
414 ..Default::default()
415 };
416 assert_eq!(
417 resolve_http_limits(&input, no_config()).max_inflight_per_principal,
418 17
419 );
420
421 let input = HttpLimitsCliInput {
423 max_inflight_per_principal_env: Some(17),
424 ..Default::default()
425 };
426 let lookup = map_lookup(HashMap::from([(
427 "red.http.max_inflight_per_principal",
428 "9",
429 )]));
430 assert_eq!(
431 resolve_http_limits(&input, lookup).max_inflight_per_principal,
432 9
433 );
434
435 let input = HttpLimitsCliInput {
437 max_inflight_per_principal_flag: Some(3),
438 max_inflight_per_principal_env: Some(17),
439 ..Default::default()
440 };
441 let lookup = map_lookup(HashMap::from([(
442 "red.http.max_inflight_per_principal",
443 "9",
444 )]));
445 assert_eq!(
446 resolve_http_limits(&input, lookup).max_inflight_per_principal,
447 3
448 );
449 }
450
451 #[test]
452 fn max_inflight_per_principal_zero_disables_and_is_honored() {
453 let lookup = map_lookup(HashMap::from([(
456 "red.http.max_inflight_per_principal",
457 "0",
458 )]));
459 assert_eq!(
460 resolve_http_limits(&HttpLimitsCliInput::default(), lookup).max_inflight_per_principal,
461 0
462 );
463 assert!(validate_max_inflight_per_principal(0).is_ok());
464 }
465}