nobreak 0.1.0

nobreak is a minimal circuit breaker for your functions
Documentation
use std::time::Duration;

#[tokio::test]
async fn test_can_call() -> anyhow::Result<()> {
    let cb = nobreak::breaker();
    let res: String = cb
        .call_async(|| async { Ok::<_, anyhow::Error>("output".to_string()) })
        .await
        .map_err(|e| e.into_inner().unwrap())?;

    assert_eq!("output", res.as_str());

    Ok(())
}

#[tokio::test]
async fn test_opens_after_failures() {
    let cb = nobreak::builder()
        .with_failure_threshold(3)
        .with_open_duration(Duration::from_secs(60))
        .build();

    for _ in 0..3 {
        let _ = cb
            .call_async(|| async { Err::<String, _>(anyhow::anyhow!("fail")) })
            .await;
    }

    assert_eq!(nobreak::CircuitState::Open, cb.state());

    let res: Result<String, _> = cb
        .call_async(|| async { Ok::<_, anyhow::Error>("should not run".to_string()) })
        .await;

    assert!(matches!(res, Err(nobreak::CircuitError::Open)));
}

#[tokio::test]
async fn test_half_open_recovery() {
    let cb = nobreak::builder()
        .with_failure_threshold(2)
        .with_success_threshold(2)
        .with_open_duration(Duration::from_millis(10))
        .build();

    for _ in 0..2 {
        let _ = cb
            .call_async(|| async { Err::<String, _>(anyhow::anyhow!("fail")) })
            .await;
    }

    assert_eq!(nobreak::CircuitState::Open, cb.state());

    tokio::time::sleep(Duration::from_millis(20)).await;

    let res = cb
        .call_async(|| async { Ok::<String, anyhow::Error>("ok".to_string()) })
        .await;
    assert!(res.is_ok());

    let res = cb
        .call_async(|| async { Ok::<String, anyhow::Error>("ok".to_string()) })
        .await;
    assert!(res.is_ok());

    assert_eq!(nobreak::CircuitState::Closed, cb.state());
}

#[tokio::test]
async fn test_half_open_failure() {
    let cb = nobreak::builder()
        .with_failure_threshold(2)
        .with_success_threshold(2)
        .with_open_duration(Duration::from_millis(10))
        .build();

    for _ in 0..2 {
        let _ = cb
            .call_async(|| async { Err::<String, _>(anyhow::anyhow!("fail")) })
            .await;
    }

    tokio::time::sleep(Duration::from_millis(20)).await;

    let _ = cb
        .call_async(|| async { Err::<String, _>(anyhow::anyhow!("still failing")) })
        .await;

    assert_eq!(nobreak::CircuitState::Open, cb.state());
}

#[tokio::test]
async fn test_success_resets_failure_count() {
    let cb = nobreak::builder()
        .with_failure_threshold(3)
        .with_open_duration(Duration::from_secs(60))
        .build();

    for _ in 0..2 {
        let _ = cb
            .call_async(|| async { Err::<String, _>(anyhow::anyhow!("fail")) })
            .await;
    }

    let _ = cb
        .call_async(|| async { Ok::<String, anyhow::Error>("ok".to_string()) })
        .await;

    // Another 2 failures should not open the circuit (count was reset)
    for _ in 0..2 {
        let _ = cb
            .call_async(|| async { Err::<String, _>(anyhow::anyhow!("fail")) })
            .await;
    }

    assert_eq!(nobreak::CircuitState::Closed, cb.state());
}

#[tokio::test]
async fn test_failed_error() {
    let cb = nobreak::breaker();
    let res = cb
        .call_async(|| async { Err::<String, _>(anyhow::anyhow!("boom")) })
        .await;

    if let Err(nobreak::CircuitError::Failed { error }) = res {
        assert_eq!("boom", error.to_string());
    } else {
        panic!("expected failed error");
    }
}

#[test]
fn test_sync_call() -> anyhow::Result<()> {
    let cb = nobreak::breaker();
    let res = cb
        .call(|| Ok::<_, anyhow::Error>("hello".to_string()))
        .map_err(|e| e.into_inner().unwrap())?;

    assert_eq!("hello", res.as_str());

    Ok(())
}

#[test]
fn test_sync_opens_after_failures() {
    let cb = nobreak::builder()
        .with_failure_threshold(2)
        .with_open_duration(Duration::from_secs(60))
        .build();

    for _ in 0..2 {
        let _ = cb.call(|| Err::<String, _>(anyhow::anyhow!("fail")));
    }

    let res = cb.call(|| Ok::<_, anyhow::Error>("should not run".to_string()));

    assert!(matches!(res, Err(nobreak::CircuitError::Open)));
}

#[test]
fn test_sync_half_open_recovery() {
    let cb = nobreak::builder()
        .with_failure_threshold(2)
        .with_success_threshold(2)
        .with_open_duration(Duration::from_millis(10))
        .build();

    for _ in 0..2 {
        let _ = cb.call(|| Err::<String, _>(anyhow::anyhow!("fail")));
    }

    std::thread::sleep(Duration::from_millis(20));

    let res = cb.call(|| Ok::<_, anyhow::Error>("ok".to_string()));
    assert!(res.is_ok());

    let res = cb.call(|| Ok::<_, anyhow::Error>("ok".to_string()));
    assert!(res.is_ok());

    assert_eq!(nobreak::CircuitState::Closed, cb.state());
}