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, HARD_RETRY_MAX_DELAY_MS,
56    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
111            .max_delay_ms
112            .min(HARD_RETRY_MAX_DELAY_MS)
113            .max(base_ms);
114        let max_retries = self.max_retries.min(HARD_RETRY_MAX_RETRIES);
115        let enabled = self.enabled && max_retries > 0;
116        Self {
117            max_retries,
118            base_ms,
119            max_delay_ms,
120            enabled,
121        }
122    }
123
124    /// Total attempts including the first try.
125    #[must_use]
126    pub fn max_attempts(self) -> u32 {
127        let p = self.clamped();
128        if !p.enabled {
129            1
130        } else {
131            p.max_retries.saturating_add(1)
132        }
133    }
134
135    /// Whether another attempt is allowed after `attempt` completed tries (1-based).
136    #[must_use]
137    pub fn may_retry(self, attempt: u32) -> bool {
138        let p = self.clamped();
139        p.enabled && attempt < p.max_attempts()
140    }
141
142    /// Full-jitter delay for this attempt number (1-based completed count).
143    #[must_use]
144    pub fn delay_for_attempt(self, attempt: u32) -> Duration {
145        let p = self.clamped();
146        backoff_full_jitter(p.base_ms, attempt, p.max_delay_ms)
147    }
148}
149
150impl Default for RetryConfig {
151    fn default() -> Self {
152        // Least privilege: product code must opt in explicitly.
153        Self::disabled()
154    }
155}
156
157/// Full jitter: `uniform(0..=min(base*2^attempt, max_delay))`.
158///
159/// Entropy from monotonic [`Instant`] + thread id + stack marker (no `rand` dep).
160/// Not cryptographic; enough to desynchronize multi-agent thundering herds.
161#[must_use]
162pub fn backoff_full_jitter(base_ms: u64, attempt: u32, max_delay_ms: u64) -> Duration {
163    let base = base_ms.max(1);
164    let max_delay = max_delay_ms.max(base);
165    let exp = base.saturating_mul(1u64 << attempt.min(16));
166    let cap = exp.min(max_delay);
167    let pick = mix_u64(attempt) % (cap.saturating_add(1));
168    Duration::from_millis(pick)
169}
170
171/// Sysexits mapping: which process exits agents may re-invoke with backoff.
172///
173/// Only [`exit_codes::EX_IOERR`] (74) is network/SSH-IO retryable. Auth (`77`),
174/// usage (`64`), data (`65`), no-input (`66`), remote command (`1`), signals and
175/// pipe are permanent for the same argv.
176#[must_use]
177pub fn exit_code_is_retryable(code: i32) -> bool {
178    code == exit_codes::EX_IOERR
179}
180
181/// Prefer operator-hinted wait when present; otherwise full-jitter formula.
182///
183/// SSH product has no HTTP `Retry-After`; `hint` lets embedding tools pass a
184/// cool-down without forking the formula.
185#[must_use]
186pub fn wait_for_retry(policy: RetryConfig, attempt: u32, hint: Option<Duration>) -> Duration {
187    let p = policy.clamped();
188    if let Some(d) = hint {
189        return d.min(Duration::from_millis(p.max_delay_ms));
190    }
191    p.delay_for_attempt(attempt)
192}
193
194fn mix_u64(attempt: u32) -> u64 {
195    let mut h = DefaultHasher::new();
196    Instant::now().hash(&mut h);
197    attempt.hash(&mut h);
198    std::thread::current().id().hash(&mut h);
199    let marker = &h as *const DefaultHasher as usize;
200    marker.hash(&mut h);
201    h.finish()
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn default_policy_is_disabled() {
210        let p = RetryConfig::default();
211        assert!(!p.enabled);
212        assert_eq!(p.max_attempts(), 1);
213        assert!(!p.may_retry(1));
214    }
215
216    #[test]
217    fn agent_default_allows_two_retries() {
218        let p = RetryConfig::agent_default().clamped();
219        assert!(p.enabled);
220        assert_eq!(p.max_retries, 2);
221        assert_eq!(p.max_attempts(), 3);
222        assert!(p.may_retry(1));
223        assert!(p.may_retry(2));
224        assert!(!p.may_retry(3));
225    }
226
227    #[test]
228    fn kill_switch_zero_retries() {
229        let p = RetryConfig {
230            max_retries: 0,
231            base_ms: 200,
232            max_delay_ms: 5_000,
233            enabled: true,
234        }
235        .clamped();
236        assert!(!p.enabled);
237        assert_eq!(p.max_attempts(), 1);
238    }
239
240    #[test]
241    fn backoff_respects_cap() {
242        let d = backoff_full_jitter(200, 20, 1_000);
243        assert!(d.as_millis() <= 1_000);
244    }
245
246    #[test]
247    fn backoff_never_exceeds_max() {
248        for attempt in 0..20 {
249            let d = backoff_full_jitter(100, attempt, 500);
250            assert!(d.as_millis() <= 500, "attempt {attempt}: {d:?}");
251        }
252    }
253
254    #[test]
255    fn only_ioerr_exit_is_retryable() {
256        assert!(exit_code_is_retryable(exit_codes::EX_IOERR));
257        assert!(!exit_code_is_retryable(exit_codes::EX_OK));
258        assert!(!exit_code_is_retryable(exit_codes::EX_USAGE));
259        assert!(!exit_code_is_retryable(exit_codes::EX_DATAERR));
260        assert!(!exit_code_is_retryable(exit_codes::EX_NOINPUT));
261        assert!(!exit_code_is_retryable(exit_codes::EX_NOPERM));
262        assert!(!exit_code_is_retryable(exit_codes::EX_GENERAL));
263        assert!(!exit_code_is_retryable(exit_codes::EX_PIPE));
264        assert!(!exit_code_is_retryable(exit_codes::EX_SIGINT));
265    }
266
267    #[test]
268    fn wait_hint_caps_to_max_delay() {
269        let p = RetryConfig::agent_default();
270        let d = wait_for_retry(p, 1, Some(Duration::from_secs(3600)));
271        assert_eq!(d, Duration::from_millis(AGENT_RETRY_MAX_DELAY_MS));
272    }
273
274    #[test]
275    fn hard_caps_clamp_pathological_config() {
276        let p = RetryConfig {
277            max_retries: 10_000,
278            base_ms: 1,
279            max_delay_ms: u64::MAX,
280            enabled: true,
281        }
282        .clamped();
283        assert!(p.max_retries <= HARD_RETRY_MAX_RETRIES);
284        assert!(p.max_delay_ms <= HARD_RETRY_MAX_DELAY_MS);
285    }
286}