Skip to main content

gglib_core/retry/
env.rs

1//! Operator overrides for the retry budget.
2//!
3//! Follows the workspace's `GGLIB_*` escape-hatch convention: the defaults are
4//! what almost everyone should run, and these exist for the operator who has a
5//! reason to differ. Deliberately no settings-table entry — a resilience budget
6//! is not a user preference, and a migration for it would be unearned.
7
8use std::sync::OnceLock;
9use std::time::Duration;
10
11use super::policy::RetryPolicy;
12
13/// Overrides how many attempts a retry sequence may make, including the first.
14pub const MAX_ATTEMPTS_ENV_VAR: &str = "GGLIB_LLM_RETRY_MAX_ATTEMPTS";
15
16/// Overrides the wall-clock ceiling on a whole retry sequence, in seconds.
17pub const DEADLINE_ENV_VAR: &str = "GGLIB_LLM_RETRY_DEADLINE_SECS";
18
19impl RetryPolicy {
20    /// [`RetryPolicy::default`] with any environment overrides applied.
21    ///
22    /// Resolved once per process: these are operator settings, and re-reading
23    /// them per request would cost a syscall on every completion for a value
24    /// that cannot meaningfully change mid-run.
25    ///
26    /// An unset or unparseable variable leaves that field at its default —
27    /// a typo degrades to standard behaviour rather than disabling retry.
28    #[must_use]
29    pub fn from_env() -> Self {
30        static RESOLVED: OnceLock<RetryPolicy> = OnceLock::new();
31        *RESOLVED.get_or_init(|| Self::default().with_env_overrides(&std::env::var))
32    }
33
34    /// A policy that makes one attempt and never retries.
35    ///
36    /// What the CLI's `--no-retry` resolves to, and what a caller wanting
37    /// strictly one-shot behaviour should ask for by name rather than by
38    /// knowing that `max_attempts: 1` means "off".
39    #[must_use]
40    pub fn disabled() -> Self {
41        Self {
42            max_attempts: 1,
43            ..Self::default()
44        }
45    }
46
47    /// Apply overrides read through `lookup`, which is the process environment
48    /// in production and a fixture in tests.
49    fn with_env_overrides<F, E>(mut self, lookup: &F) -> Self
50    where
51        F: Fn(&'static str) -> Result<String, E>,
52    {
53        if let Some(attempts) = parse::<u32, _, _>(lookup, MAX_ATTEMPTS_ENV_VAR) {
54            // Zero attempts would mean never issuing the request at all, which
55            // is nobody's intent; one is the "off" the operator meant.
56            self.max_attempts = attempts.max(1);
57        }
58        if let Some(secs) = parse::<u64, _, _>(lookup, DEADLINE_ENV_VAR) {
59            self.total_deadline = Duration::from_secs(secs);
60        }
61
62        // A single delay must never be able to consume the whole budget, or the
63        // first backoff would overrun the deadline and silently disable
64        // retrying. Halving guarantees at least one retry still fits inside a
65        // shortened window.
66        let ceiling = self.total_deadline / 2;
67        if self.max_backoff > ceiling {
68            self.max_backoff = ceiling;
69        }
70
71        self
72    }
73}
74
75/// Read and parse one variable, warning on a value that cannot be used.
76fn parse<T, F, E>(lookup: &F, name: &'static str) -> Option<T>
77where
78    T: std::str::FromStr,
79    F: Fn(&'static str) -> Result<String, E>,
80{
81    let raw = lookup(name).ok()?;
82    let parsed = raw.trim().parse::<T>().ok();
83    if parsed.is_none() {
84        tracing::warn!(value = %raw, "{name} is not a valid whole number — ignoring it");
85    }
86    parsed
87}
88
89#[cfg(test)]
90#[path = "env_tests.rs"]
91mod env_tests;