Skip to content

Commit b143f5e

Browse files
feat(retry): M18.1 — RetryPolicy + classifier + run loop + CAT_RETRY (FR-81, FR-82, FR-83 lib side) (#28)
Adds the library-side scaffold M18 needs to honour ConnectTimeout + ConnectionAttempts from `~/.ssh/config` (FR-80, M18.2), classify transient vs fatal errors (FR-82), drive an exponential-backoff retry loop with jitter (FR-81), and capture per-attempt history for FR-83's `gitway --test --json` envelope. src/retry.rs (new, ~430 lines + ~190 lines of tests): - pub struct RetryPolicy { attempts, base, factor, cap, max_window, connect_timeout } with builder-style setters. Default values per PRD: 3 attempts, 250 ms base, x2 factor, 8 s cap, 30 s max window, no connect_timeout. - pub enum Disposition { Retry, Fatal } pub fn classify(err: &AnvilError) -> Disposition (FR-82): * AuthenticationFailed / HostKeyMismatch / NoKeyFound / KeyEncrypted -> Fatal * Io kind in {ConnectionRefused, TimedOut, HostUnreachable, NetworkUnreachable, NotFound (DNS NXDOMAIN), AddrNotAvailable} -> Retry * Everything else (russh protocol, signing, signature-invalid, other Io kinds) -> Fatal HTTP 429/503 detection from FR-82's defensive wording is out of scope: Anvil speaks raw SSH; HTTP statuses only surface in ProxyCommand subprocess output that Anvil doesn't parse. - pub struct RetryAttempt { attempt: u32, reason: String, elapsed: Duration } captures per-failure history for FR-83. - pub async fn run<F, Fut, T>(policy, op) -> Result<(T, Vec<RetryAttempt>), AnvilError> drives the loop with jittered exponential backoff: delay_n = min(base * factor^(n-1), cap) + uniform_jitter([0, base/2]) Jitter sourced from rand_core::OsRng (already in deps from M19's prepend_revoked). Bails on max_window before starting another attempt. Emits tracing::warn! at CAT_RETRY per failed attempt with attempt / reason / elapsed_ms / disposition fields. - run is timeout-agnostic: the per-attempt tokio::time::timeout wrap lives at the call site (M18.2's session.rs::connect) so the same loop driver can be reused for non-network operations. - 15 unit tests covering: default-policy values, builder chainability, classifier matrix (auth-fatal / host-key-fatal / no-key-fatal / io-connection-refused-retry / io-timed-out-retry / io-not-found-retry / io-permission-denied-fatal), run loop (success-first-try / bail-on-fatal / retry-and-record-history / exhaust-attempt-count), backoff curve (exponential growth, cap enforcement, 1000-draw jitter window). src/log.rs: - New pub const CAT_RETRY = "anvil_ssh::retry"; appended to CATEGORIES. - categories_slice_matches_individual_constants test updated to include the new constant. src/error.rs: - New pub fn AnvilError::io_kind(&self) -> Option<std::io::ErrorKind> returns the underlying io::Error::kind() for the Io variant. Used by retry::classify; also useful for downstream consumers inspecting failure categories. - New pub fn AnvilError::is_transient(&self) -> bool returning matches!(retry::classify(self), Disposition::Retry). Surfaces the classifier as a single-call predicate for log-aggregation pipelines and CLI error paths. src/lib.rs: - pub mod retry; Public API: pure additive. Version bump to 0.9.0 lands in M18.3. Plan: M18.1 of anvil-gitway-milestone-plan.md. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6f20788 commit b143f5e

4 files changed

Lines changed: 627 additions & 2 deletions

File tree

src/error.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,36 @@ impl AnvilError {
191191
matches!(self.kind, AnvilErrorKind::Io(_))
192192
}
193193

194+
/// Returns the underlying [`std::io::ErrorKind`] when this error
195+
/// is an I/O variant, or `None` otherwise.
196+
///
197+
/// Used by [`crate::retry::classify`] (M18, FR-82) to distinguish
198+
/// transient connection failures (`ConnectionRefused`,
199+
/// `TimedOut`, `HostUnreachable`, …) from fatal ones
200+
/// (`PermissionDenied`, etc.).
201+
#[must_use]
202+
pub fn io_kind(&self) -> Option<std::io::ErrorKind> {
203+
match &self.kind {
204+
AnvilErrorKind::Io(e) => Some(e.kind()),
205+
_ => None,
206+
}
207+
}
208+
209+
/// Returns `true` when this error is classified as transient by
210+
/// [`crate::retry::classify`] — i.e. the [`crate::retry::run`]
211+
/// loop would retry it (M18, FR-82).
212+
///
213+
/// Useful for callers that want to short-circuit before reaching
214+
/// the retry loop, e.g. log-aggregation pipelines deciding
215+
/// whether a single failure should page on-call.
216+
#[must_use]
217+
pub fn is_transient(&self) -> bool {
218+
matches!(
219+
crate::retry::classify(self),
220+
crate::retry::Disposition::Retry,
221+
)
222+
}
223+
194224
/// Returns `true` if the server's host key did not match any pinned fingerprint.
195225
#[must_use]
196226
pub fn is_host_key_mismatch(&self) -> bool {

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub mod keygen;
5555
pub mod log;
5656
pub mod proxy;
5757
pub mod relay;
58+
pub mod retry;
5859
pub mod session;
5960
pub mod sshsig;
6061
pub mod time;

src/log.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,19 @@ pub const CAT_CHANNEL: &str = "anvil_ssh::channel";
6969
/// source `file` and `line` number (FR-66).
7070
pub const CAT_CONFIG: &str = "anvil_ssh::config";
7171

72+
/// `target =` string for the connection-retry / timeout category
73+
/// (M18, FR-83). Events at this target record each retry attempt
74+
/// with `attempt`, `reason`, `elapsed_ms`, and (on terminal failure)
75+
/// a `disposition` field of `fatal` / `exhausted`.
76+
pub const CAT_RETRY: &str = "anvil_ssh::retry";
77+
7278
/// All Anvil-defined categories, in declaration order. Used by
7379
/// downstream CLIs (e.g. Gitway's `--debug-categories` flag) to
7480
/// validate user-supplied category names before building an
7581
/// `EnvFilter`. Does not include `russh` — that's a synthetic
7682
/// passthrough recognized by the consumer's filter, not an Anvil
7783
/// category.
78-
pub const CATEGORIES: &[&str] = &[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG];
84+
pub const CATEGORIES: &[&str] = &[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG, CAT_RETRY];
7985

8086
/// Installs the `log` → `tracing` bridge.
8187
///
@@ -125,7 +131,10 @@ mod tests {
125131

126132
#[test]
127133
fn categories_slice_matches_individual_constants() {
128-
assert_eq!(CATEGORIES, &[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG]);
134+
assert_eq!(
135+
CATEGORIES,
136+
&[CAT_KEX, CAT_AUTH, CAT_CHANNEL, CAT_CONFIG, CAT_RETRY],
137+
);
129138
}
130139

131140
#[test]

0 commit comments

Comments
 (0)