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;