use std::time::Duration;
use ironflow_core::retry::is_retryable as is_operation_retryable;
use rand::Rng;
use crate::error::EngineError;
const INITIAL_BACKOFF: Duration = Duration::from_secs(30);
const BACKOFF_MULTIPLIER: f64 = 4.0;
const MAX_BACKOFF: Duration = Duration::from_secs(15 * 60);
const JITTER_RATIO: f64 = 0.2;
pub fn is_run_retryable(error: &EngineError) -> bool {
match error {
EngineError::Operation(op_err) => is_operation_retryable(op_err),
EngineError::InvalidWorkflow(_)
| EngineError::StepConfig(_)
| EngineError::Serialization(_)
| EngineError::Store(_)
| EngineError::RunBudgetExceeded { .. }
| EngineError::MonthlyBudgetExceeded { .. }
| EngineError::ApprovalRequired { .. } => false,
}
}
pub fn backoff_for_retry(retry_index: u32) -> Duration {
let jitter = rand::rng().random_range(1.0 - JITTER_RATIO..=1.0 + JITTER_RATIO);
jittered_backoff(retry_index, jitter)
}
fn jittered_backoff(retry_index: u32, jitter: f64) -> Duration {
let base = INITIAL_BACKOFF.as_secs_f64() * BACKOFF_MULTIPLIER.powi(retry_index as i32);
let capped = base.min(MAX_BACKOFF.as_secs_f64());
Duration::from_secs_f64(capped * jitter)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use ironflow_core::error::{AgentError, OperationError};
use ironflow_store::error::StoreError;
use rust_decimal::Decimal;
use uuid::Uuid;
use super::*;
#[test]
fn invalid_workflow_is_not_retryable() {
let err = EngineError::InvalidWorkflow("no handler registered: deploy".to_string());
assert!(!is_run_retryable(&err));
}
#[test]
fn step_config_is_not_retryable() {
let err = EngineError::StepConfig("bad shell config".to_string());
assert!(!is_run_retryable(&err));
}
#[test]
fn serialization_is_not_retryable() {
let serde_err = serde_json::from_str::<String>("not json").unwrap_err();
assert!(!is_run_retryable(&EngineError::Serialization(serde_err)));
}
#[test]
fn store_error_is_not_retryable() {
let err = EngineError::Store(StoreError::RunNotFound(Uuid::nil()));
assert!(!is_run_retryable(&err));
}
#[test]
fn run_budget_exceeded_is_not_retryable() {
let err = EngineError::RunBudgetExceeded {
run_id: Uuid::nil(),
limit_usd: Decimal::new(100, 2),
spent_usd: Decimal::new(95, 2),
step_budget_usd: Decimal::new(20, 2),
};
assert!(!is_run_retryable(&err));
}
#[test]
fn monthly_budget_exceeded_is_not_retryable() {
let err = EngineError::MonthlyBudgetExceeded {
limit_usd: Decimal::new(1000, 2),
spent_usd: Decimal::new(1000, 2),
};
assert!(!is_run_retryable(&err));
}
#[test]
fn approval_required_is_not_retryable() {
let err = EngineError::ApprovalRequired {
run_id: Uuid::nil(),
step_id: Uuid::nil(),
message: "approve?".to_string(),
};
assert!(!is_run_retryable(&err));
}
#[test]
fn budget_exceeded_is_not_retryable() {
let err = EngineError::Operation(OperationError::Agent(AgentError::BudgetExceeded {
spent_usd: 0.30,
limit_usd: 0.25,
debug_messages: Vec::new(),
partial_usage: Box::default(),
}));
assert!(!is_run_retryable(&err));
}
#[test]
fn shell_failure_is_not_retryable() {
let err = EngineError::Operation(OperationError::Shell {
exit_code: 1,
stderr: "boom".to_string(),
});
assert!(!is_run_retryable(&err));
}
#[test]
fn http_server_error_is_retryable() {
let err = EngineError::Operation(OperationError::Http {
status: Some(503),
message: "service unavailable".to_string(),
});
assert!(is_run_retryable(&err));
}
#[test]
fn http_client_error_is_not_retryable() {
let err = EngineError::Operation(OperationError::Http {
status: Some(422),
message: "unprocessable".to_string(),
});
assert!(!is_run_retryable(&err));
}
#[test]
fn operation_timeout_is_retryable() {
let err = EngineError::Operation(OperationError::Timeout {
step: "build".to_string(),
limit: Duration::from_secs(60),
});
assert!(is_run_retryable(&err));
}
#[test]
fn agent_process_failure_is_retryable() {
let err = EngineError::Operation(OperationError::Agent(AgentError::ProcessFailed {
exit_code: 1,
stderr: "crashed".to_string(),
}));
assert!(is_run_retryable(&err));
}
#[test]
fn backoff_grows_by_the_multiplier() {
assert_eq!(jittered_backoff(0, 1.0), Duration::from_secs(30));
assert_eq!(jittered_backoff(1, 1.0), Duration::from_secs(120));
assert_eq!(jittered_backoff(2, 1.0), Duration::from_secs(480));
}
#[test]
fn backoff_is_capped() {
assert_eq!(jittered_backoff(3, 1.0), Duration::from_secs(15 * 60));
assert_eq!(jittered_backoff(50, 1.0), Duration::from_secs(15 * 60));
}
#[test]
fn jitter_scales_the_delay() {
assert_eq!(jittered_backoff(0, 0.8), Duration::from_secs(24));
assert_eq!(jittered_backoff(0, 1.2), Duration::from_secs(36));
}
#[test]
fn backoff_for_retry_stays_within_jitter_bounds() {
for _ in 0..100 {
let delay = backoff_for_retry(0);
assert!(
delay >= Duration::from_secs(24) && delay <= Duration::from_secs(36),
"delay {delay:?} outside +/- 20% of 30s"
);
}
}
#[test]
fn backoff_for_retry_never_exceeds_the_cap_with_jitter() {
for _ in 0..100 {
let delay = backoff_for_retry(10);
assert!(delay <= Duration::from_secs_f64(15.0 * 60.0 * 1.2));
}
}
}