Skip to main content

ssh_cli/
retry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Explicit retry policy for ssh-cli (Rules Rust — retry / backoff).
5//!
6//! # Workload and policy scope
7//!
8//! | Concern | Decision |
9//! |---------|----------|
10//! | Product identity | One-shot agent-first SSH CLI (no long-lived client pool) |
11//! | Who retries | **The agent** re-invokes the process; the binary does **not** |
12//! | | auto-retry `exec` / `scp` / `sudo-exec` / `su-exec` (side effects) |
13//! | Classification | [`crate::errors::SshCliError::is_retryable`] + JSON envelope fields |
14//! | Dependency class | SSH TCP + russh only — no HTTP/gRPC product client |
15//! | In-process loops | Opt-in only via [`RetryConfig::enabled`] (default **false**) |
16//!
17//! # Why in-process product retry is off by default
18//!
19//! Remote shell commands and file transfers are **not** assumed idempotent.
20//! Blind retry would violate least privilege and the one-shot contract
21//! (rules: desativar retry em operações não marcadas como idempotentes).
22//! Agents use exit `74` + `retryable: true` and apply this policy externally.
23//!
24//! # Agent contract (documented SLA)
25//!
26//! - Retry **at most** [`crate::constants::AGENT_RETRY_MAX_RETRIES`] times after
27//!   the first failure on transient network/SSH IO
28//!   (exit [`crate::errors::exit_codes::EX_IOERR`]).
29//! - Never blind-retry exits `64`, `65`, `66`, `77`, `1` (remote command),
30//!   signals (`130`/`143`), or pipe (`141`).
31//! - Sleep with full-jitter exponential backoff ([`backoff_full_jitter`]).
32//! - Kill switch for embedding tools: `RetryConfig { enabled: false, .. }` or
33//!   `max_retries: 0`.
34//!
35//! # Delay formula
36//!
37//! ```text
38//! cap(n)  = min(base_ms * 2^min(n, 16), max_delay_ms)
39//! delay   = uniform(0..=cap)   // full jitter
40//! ```
41//!
42//! Monotonic clock for entropy mix: [`std::time::Instant`] (not `SystemTime`).
43//! Async waiters must use `tokio::time::sleep`, never `std::thread::sleep`.
44//!
45//! # Out of scope (identity N/A)
46//!
47//! HTTP `Retry-After`, circuit breaker, retry budget token-bucket, hedged
48//! requests, gRPC, OAuth refresh, outbox/saga, Idempotency-Key headers.
49
50use std::collections::hash_map::DefaultHasher;
51use std::hash::{Hash, Hasher};
52use std::time::{Duration, Instant};
53
54use crate::constants::{
55    AGENT_RETRY_BASE_MS, AGENT_RETRY_MAX_DELAY_MS, AGENT_RETRY_MAX_RETRIES,
56    HARD_RETRY_MAX_DELAY_MS, HARD_RETRY_MAX_RETRIES,
57};
58use crate::errors::exit_codes;
59
60/// Named retry policy (one dependency class: SSH connect / agent re-invoke).
61///
62/// Clone is cheap (`Copy`). Default has [`Self::enabled`] = **false** so product
63/// paths never retry as a side effect (opt-in / least privilege).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct RetryConfig {
66    /// Retries after the first attempt (`0` = single try).
67    pub max_retries: u32,
68    /// Exponential base delay in milliseconds.
69    pub base_ms: u64,
70    /// Maximum single sleep in milliseconds.
71    pub max_delay_ms: u64,
72    /// Kill switch: when false, never retries.
73    pub enabled: bool,
74}
75
76impl RetryConfig {
77    /// Agent-facing defaults (enabled) for **process re-invocation** only.
78    ///
79    /// Justification: matches `docs/AGENTS.md` — at most two retries on exit 74
80    /// with backoff; not used inside product `exec` paths.
81    #[must_use]
82    pub const fn agent_default() -> Self {
83        Self {
84            max_retries: AGENT_RETRY_MAX_RETRIES,
85            base_ms: AGENT_RETRY_BASE_MS,
86            max_delay_ms: AGENT_RETRY_MAX_DELAY_MS,
87            enabled: true,
88        }
89    }
90
91    /// Disabled policy (product default / incident kill switch).
92    #[must_use]
93    pub const fn disabled() -> Self {
94        Self {
95            max_retries: 0,
96            base_ms: AGENT_RETRY_BASE_MS,
97            max_delay_ms: AGENT_RETRY_MAX_DELAY_MS,
98            enabled: false,
99        }
100    }
101
102    /// Clamp fields to hard caps (prevents accidental retry storms).
103    #[must_use]
104    pub fn clamped(self) -> Self {
105        let base_ms = if self.base_ms == 0 {
106            AGENT_RETRY_BASE_MS
107        } else {
108            self.base_ms
109        };
110        let max_delay_ms = self.max_delay_ms.min(HARD_RETRY_MAX_DELAY_MS).max(base_ms);
111        let max_retries = self.max_retries.min(HARD_RETRY_MAX_RETRIES);
112        let enabled = self.enabled && max_retries > 0;
113        Self {
114            max_retries,
115            base_ms,
116            max_delay_ms,
117            enabled,
118        }
119    }
120
121    /// Total attempts including the first try.
122    #[must_use]
123    pub fn max_attempts(self) -> u32 {
124        let p = self.clamped();
125        if !p.enabled {
126            1
127        } else {
128            p.max_retries.saturating_add(1)
129        }
130    }
131
132    /// Whether another attempt is allowed after `attempt` completed tries (1-based).
133    #[must_use]
134    pub fn may_retry(self, attempt: u32) -> bool {
135        let p = self.clamped();
136        p.enabled && attempt < p.max_attempts()
137    }
138
139    /// Full-jitter delay for this attempt number (1-based completed count).
140    #[must_use]
141    pub fn delay_for_attempt(self, attempt: u32) -> Duration {
142        let p = self.clamped();
143        backoff_full_jitter(p.base_ms, attempt, p.max_delay_ms)
144    }
145}
146
147impl Default for RetryConfig {
148    fn default() -> Self {
149        // Least privilege: product code must opt in explicitly.
150        Self::disabled()
151    }
152}
153
154/// Full jitter: `uniform(0..=min(base*2^attempt, max_delay))`.
155///
156/// Entropy from monotonic [`Instant`] + thread id + stack marker (no `rand` dep).
157/// Not cryptographic; enough to desynchronize multi-agent thundering herds.
158#[must_use]
159pub fn backoff_full_jitter(base_ms: u64, attempt: u32, max_delay_ms: u64) -> Duration {
160    let base = base_ms.max(1);
161    let max_delay = max_delay_ms.max(base);
162    let exp = base.saturating_mul(1u64 << attempt.min(16));
163    let cap = exp.min(max_delay);
164    let pick = mix_u64(attempt) % (cap.saturating_add(1));
165    Duration::from_millis(pick)
166}
167
168/// Sysexits mapping: which process exits agents may re-invoke with backoff.
169///
170/// Only [`exit_codes::EX_IOERR`] (74) is network/SSH-IO retryable. Auth (`77`),
171/// usage (`64`), data (`65`), no-input (`66`), remote command (`1`), signals and
172/// pipe are permanent for the same argv.
173#[must_use]
174pub fn exit_code_is_retryable(code: i32) -> bool {
175    code == exit_codes::EX_IOERR
176}
177
178/// Prefer operator-hinted wait when present; otherwise full-jitter formula.
179///
180/// SSH product has no HTTP `Retry-After`; `hint` lets embedding tools pass a
181/// cool-down without forking the formula.
182#[must_use]
183pub fn wait_for_retry(policy: RetryConfig, attempt: u32, hint: Option<Duration>) -> Duration {
184    let p = policy.clamped();
185    if let Some(d) = hint {
186        return d.min(Duration::from_millis(p.max_delay_ms));
187    }
188    p.delay_for_attempt(attempt)
189}
190
191fn mix_u64(attempt: u32) -> u64 {
192    let mut h = DefaultHasher::new();
193    Instant::now().hash(&mut h);
194    attempt.hash(&mut h);
195    std::thread::current().id().hash(&mut h);
196    let marker = &h as *const DefaultHasher as usize;
197    marker.hash(&mut h);
198    h.finish()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn default_policy_is_disabled() {
207        let p = RetryConfig::default();
208        assert!(!p.enabled);
209        assert_eq!(p.max_attempts(), 1);
210        assert!(!p.may_retry(1));
211    }
212
213    #[test]
214    fn agent_default_allows_two_retries() {
215        let p = RetryConfig::agent_default().clamped();
216        assert!(p.enabled);
217        assert_eq!(p.max_retries, 2);
218        assert_eq!(p.max_attempts(), 3);
219        assert!(p.may_retry(1));
220        assert!(p.may_retry(2));
221        assert!(!p.may_retry(3));
222    }
223
224    #[test]
225    fn kill_switch_zero_retries() {
226        let p = RetryConfig {
227            max_retries: 0,
228            base_ms: 200,
229            max_delay_ms: 5_000,
230            enabled: true,
231        }
232        .clamped();
233        assert!(!p.enabled);
234        assert_eq!(p.max_attempts(), 1);
235    }
236
237    #[test]
238    fn backoff_respects_cap() {
239        let d = backoff_full_jitter(200, 20, 1_000);
240        assert!(d.as_millis() <= 1_000);
241    }
242
243    #[test]
244    fn backoff_never_exceeds_max() {
245        for attempt in 0..20 {
246            let d = backoff_full_jitter(100, attempt, 500);
247            assert!(d.as_millis() <= 500, "attempt {attempt}: {d:?}");
248        }
249    }
250
251    #[test]
252    fn only_ioerr_exit_is_retryable() {
253        assert!(exit_code_is_retryable(exit_codes::EX_IOERR));
254        assert!(!exit_code_is_retryable(exit_codes::EX_OK));
255        assert!(!exit_code_is_retryable(exit_codes::EX_USAGE));
256        assert!(!exit_code_is_retryable(exit_codes::EX_DATAERR));
257        assert!(!exit_code_is_retryable(exit_codes::EX_NOINPUT));
258        assert!(!exit_code_is_retryable(exit_codes::EX_NOPERM));
259        assert!(!exit_code_is_retryable(exit_codes::EX_GENERAL));
260        assert!(!exit_code_is_retryable(exit_codes::EX_PIPE));
261        assert!(!exit_code_is_retryable(exit_codes::EX_SIGINT));
262    }
263
264    #[test]
265    fn wait_hint_caps_to_max_delay() {
266        let p = RetryConfig::agent_default();
267        let d = wait_for_retry(p, 1, Some(Duration::from_secs(3600)));
268        assert_eq!(d, Duration::from_millis(AGENT_RETRY_MAX_DELAY_MS));
269    }
270
271    #[test]
272    fn hard_caps_clamp_pathological_config() {
273        let p = RetryConfig {
274            max_retries: 10_000,
275            base_ms: 1,
276            max_delay_ms: u64::MAX,
277            enabled: true,
278        }
279        .clamped();
280        assert!(p.max_retries <= HARD_RETRY_MAX_RETRIES);
281        assert!(p.max_delay_ms <= HARD_RETRY_MAX_DELAY_MS);
282    }
283}