use std::collections::HashMap;
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct SubRequestCircuitBreakerConfig {
pub consecutive_failures: u32,
pub recovery_window_secs: u64,
#[serde(default = "default_half_open_timeout_secs")]
pub half_open_timeout_secs: u64,
}
const fn default_half_open_timeout_secs() -> u64 {
30
}
impl SubRequestCircuitBreakerConfig {
pub fn validate(&self) -> Result<(), String> {
if self.consecutive_failures == 0 {
return Err("subrequest_circuit_breaker: consecutive_failures must be > 0".to_owned());
}
if self.recovery_window_secs == 0 {
return Err("subrequest_circuit_breaker: recovery_window_secs must be > 0".to_owned());
}
if self.half_open_timeout_secs == 0 {
return Err("subrequest_circuit_breaker: half_open_timeout_secs must be > 0".to_owned());
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeConfig {
#[serde(default)]
pub global_queue_interval: Option<u32>,
#[serde(default)]
pub log_overrides: HashMap<String, String>,
#[serde(default)]
pub logging: super::logging::LoggingConfig,
#[serde(default)]
pub max_connections: Option<u32>,
#[serde(default)]
pub max_memory_bytes: Option<usize>,
#[serde(default)]
pub subrequest_circuit_breaker: Option<SubRequestCircuitBreakerConfig>,
#[serde(default)]
pub subrequest_max_connections: Option<usize>,
#[serde(default = "default_subrequest_pool_size")]
pub subrequest_pool_size: Option<usize>,
#[serde(default)]
pub threads: usize,
#[serde(default)]
pub upstream_ca_file: Option<String>,
#[serde(default = "default_upstream_keepalive_pool_size")]
pub upstream_keepalive_pool_size: Option<usize>,
#[serde(default = "default_work_stealing")]
pub work_stealing: bool,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
max_connections: None,
max_memory_bytes: None,
subrequest_circuit_breaker: None,
subrequest_max_connections: None,
subrequest_pool_size: default_subrequest_pool_size(),
threads: 0,
work_stealing: default_work_stealing(),
global_queue_interval: None,
log_overrides: HashMap::new(),
logging: super::logging::LoggingConfig::default(),
upstream_ca_file: None,
upstream_keepalive_pool_size: default_upstream_keepalive_pool_size(),
}
}
}
fn default_work_stealing() -> bool {
true
}
pub const DEFAULT_SUBREQUEST_POOL_SIZE: usize = 128;
#[expect(clippy::unnecessary_wraps, reason = "serde default")]
fn default_subrequest_pool_size() -> Option<usize> {
Some(DEFAULT_SUBREQUEST_POOL_SIZE)
}
#[expect(clippy::unnecessary_wraps, reason = "serde default")]
fn default_upstream_keepalive_pool_size() -> Option<usize> {
Some(64)
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use super::*;
#[test]
fn default_has_zero_threads_and_work_stealing_true() {
let cfg = RuntimeConfig::default();
assert_eq!(cfg.threads, 0, "default threads should be 0");
assert!(cfg.work_stealing, "default work_stealing should be true");
}
#[test]
fn deserialise_empty_yaml_gives_defaults() {
let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
assert_eq!(cfg.threads, 0, "empty yaml should give 0 threads");
assert!(cfg.work_stealing, "empty yaml should give work_stealing=true");
}
#[test]
fn deserialise_explicit_threads() {
let cfg: RuntimeConfig = serde_yaml::from_str("threads: 4").unwrap();
assert_eq!(cfg.threads, 4, "explicit threads should be preserved");
assert!(cfg.work_stealing, "unset work_stealing should default to true");
}
#[test]
fn deserialise_work_stealing_disabled() {
let cfg: RuntimeConfig = serde_yaml::from_str("work_stealing: false").unwrap();
assert_eq!(cfg.threads, 0, "unset threads should default to 0");
assert!(!cfg.work_stealing, "explicit work_stealing=false should be preserved");
}
#[test]
fn deserialise_all_fields() {
let yaml = "threads: 8\nwork_stealing: true";
let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.threads, 8, "threads should be 8");
assert!(cfg.work_stealing, "work_stealing should be true");
}
#[test]
fn deserialise_log_overrides() {
let yaml = r#"
log_overrides:
praxis_filter::pipeline: trace
praxis_protocol: debug
"#;
let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.log_overrides.len(), 2, "should have 2 log overrides");
assert_eq!(
cfg.log_overrides["praxis_filter::pipeline"], "trace",
"pipeline override mismatch"
);
assert_eq!(
cfg.log_overrides["praxis_protocol"], "debug",
"protocol override mismatch"
);
}
#[test]
fn default_log_overrides_is_empty() {
let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
assert!(cfg.log_overrides.is_empty(), "log_overrides should default to empty");
}
#[test]
fn global_queue_interval_defaults_to_none() {
let cfg = RuntimeConfig::default();
assert!(
cfg.global_queue_interval.is_none(),
"default interval must be unset so a stock config never warns about the no-op knob"
);
}
#[test]
fn deserialise_global_queue_interval() {
let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: 128").unwrap();
assert_eq!(cfg.global_queue_interval, Some(128), "explicit interval should be 128");
}
#[test]
fn deserialise_global_queue_interval_null() {
let cfg: RuntimeConfig = serde_yaml::from_str("global_queue_interval: null").unwrap();
assert!(cfg.global_queue_interval.is_none(), "null interval should be None");
}
#[test]
fn upstream_keepalive_pool_size_defaults_to_64() {
let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
assert_eq!(
cfg.upstream_keepalive_pool_size,
Some(64),
"default pool size should be 64"
);
}
#[test]
fn deserialise_upstream_keepalive_pool_size() {
let cfg: RuntimeConfig = serde_yaml::from_str("upstream_keepalive_pool_size: 64").unwrap();
assert_eq!(
cfg.upstream_keepalive_pool_size,
Some(64),
"explicit pool size should be 64"
);
}
#[test]
fn upstream_ca_file_defaults_to_none() {
let cfg: RuntimeConfig = serde_yaml::from_str("{}").unwrap();
assert!(
cfg.upstream_ca_file.is_none(),
"upstream_ca_file should default to None"
);
}
#[test]
fn deserialise_upstream_ca_file() {
let cfg: RuntimeConfig = serde_yaml::from_str("upstream_ca_file: /etc/ssl/ca.pem").unwrap();
assert_eq!(
cfg.upstream_ca_file.as_deref(),
Some("/etc/ssl/ca.pem"),
"explicit upstream_ca_file should be preserved"
);
}
#[test]
fn subrequest_circuit_breaker_defaults_to_none() {
let cfg = RuntimeConfig::default();
assert!(cfg.subrequest_circuit_breaker.is_none(), "should default to None");
}
#[test]
fn deserialise_subrequest_circuit_breaker() {
let yaml = r#"
subrequest_circuit_breaker:
consecutive_failures: 5
recovery_window_secs: 30
"#;
let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
let cb = cfg.subrequest_circuit_breaker.unwrap();
assert_eq!(cb.consecutive_failures, 5, "threshold should be 5");
assert_eq!(cb.recovery_window_secs, 30, "recovery should be 30s");
assert_eq!(cb.half_open_timeout_secs, 30, "half_open should default to 30s");
}
#[test]
fn deserialise_subrequest_circuit_breaker_explicit_half_open() {
let yaml = r#"
subrequest_circuit_breaker:
consecutive_failures: 3
recovery_window_secs: 60
half_open_timeout_secs: 15
"#;
let cfg: RuntimeConfig = serde_yaml::from_str(yaml).unwrap();
let cb = cfg.subrequest_circuit_breaker.unwrap();
assert_eq!(cb.half_open_timeout_secs, 15, "explicit half_open should be 15s");
}
#[test]
fn subrequest_circuit_breaker_validate_zero_failures() {
let cb = SubRequestCircuitBreakerConfig {
consecutive_failures: 0,
recovery_window_secs: 30,
half_open_timeout_secs: 30,
};
let err = cb.validate().unwrap_err();
assert!(
err.contains("consecutive_failures must be > 0"),
"should reject zero failures: {err}"
);
}
#[test]
fn subrequest_circuit_breaker_validate_zero_recovery() {
let cb = SubRequestCircuitBreakerConfig {
consecutive_failures: 5,
recovery_window_secs: 0,
half_open_timeout_secs: 30,
};
let err = cb.validate().unwrap_err();
assert!(
err.contains("recovery_window_secs must be > 0"),
"should reject zero recovery: {err}"
);
}
#[test]
fn subrequest_circuit_breaker_validate_zero_half_open() {
let cb = SubRequestCircuitBreakerConfig {
consecutive_failures: 5,
recovery_window_secs: 30,
half_open_timeout_secs: 0,
};
let err = cb.validate().unwrap_err();
assert!(
err.contains("half_open_timeout_secs must be > 0"),
"should reject zero half_open: {err}"
);
}
#[test]
fn subrequest_circuit_breaker_validate_valid() {
let cb = SubRequestCircuitBreakerConfig {
consecutive_failures: 5,
recovery_window_secs: 30,
half_open_timeout_secs: 30,
};
assert!(cb.validate().is_ok(), "valid config should pass");
}
}