codewhale-tui 0.9.8

Terminal UI for open-source and open-weight coding models
Documentation
use super::*;

#[test]
fn retryability_distinguishes_transient_failures_from_durable_failures() {
    for error in [
        LlmError::RateLimited {
            message: "too many requests".into(),
            retry_after: None,
        },
        LlmError::ServerError {
            status: 500,
            message: "internal error".into(),
        },
        LlmError::NetworkError("connection refused".into()),
        LlmError::Timeout(Duration::from_secs(30)),
    ] {
        assert!(error.is_retryable(), "expected transient error: {error}");
    }
    for error in [
        LlmError::authentication_error("invalid key"),
        LlmError::AuthorizationError("blocked".into()),
        LlmError::InvalidRequest {
            status: 400,
            message: "bad json".into(),
        },
        LlmError::ContentPolicyError("unsafe content".into()),
        LlmError::ContextLengthError("too long".into()),
    ] {
        assert!(!error.is_retryable(), "expected durable error: {error}");
    }
}

#[test]
fn http_response_boundary_classifies_status_contract() {
    assert!(matches!(
        LlmError::from_http_response(429, "rate limit exceeded"),
        LlmError::RateLimited { .. }
    ));
    assert!(matches!(
        LlmError::from_http_response(401, "invalid api key"),
        LlmError::AuthenticationError(_)
    ));
    assert!(matches!(
        LlmError::from_http_response(403, "forbidden"),
        LlmError::AuthorizationError(_)
    ));
    assert!(matches!(
        LlmError::from_http_response(403, "invalid api key"),
        LlmError::AuthenticationError(_)
    ));
    let cancelled = LlmError::from_http_response(499, "upstream request cancelled");
    assert!(matches!(
        &cancelled,
        LlmError::ServerError { status: 499, .. }
    ));
    assert!(cancelled.is_retryable());
    assert!(matches!(
        LlmError::from_http_response(500, "internal server error"),
        LlmError::ServerError { status: 500, .. }
    ));
    assert!(matches!(
        LlmError::from_http_response(503, "service unavailable"),
        LlmError::ServerError { status: 503, .. }
    ));
    assert!(matches!(
        LlmError::from_http_response(400, "context_length_exceeded"),
        LlmError::ContextLengthError(_)
    ));
    assert!(matches!(
        LlmError::from_http_response(400, "content_policy_violation"),
        LlmError::ContentPolicyError(_)
    ));
    assert!(matches!(
        LlmError::from_http_response(400, "invalid json"),
        LlmError::InvalidRequest { status: 400, .. }
    ));
}

#[test]
fn explicit_400_402_and_429_quota_responses_are_typed_and_non_retryable() {
    for (status, body) in [
        (
            400,
            r#"{"error":{"code":"insufficient_quota","message":"You exceeded your current quota"}}"#,
        ),
        (
            429,
            r#"{"error":{"type":"insufficient_quota","message":"Billing limit reached"}}"#,
        ),
        (
            402,
            r#"{"error":{"code":"billing_hard_limit_reached","message":"Payment required"}}"#,
        ),
        (
            429,
            "You exceeded your current quota. Please check your plan and billing details.",
        ),
        (429, "Account quota exhausted"),
    ] {
        let error = LlmError::from_http_response(status, body);
        assert!(matches!(error, LlmError::QuotaExhausted(_)));
        assert!(!error.is_retryable());
    }

    let raw = r#"{"error":{"code":"billing_hard_limit_reached","message":"Account unavailable"}}"#;
    let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
    assert!(matches!(
        LlmError::from_http_response(429, &safe),
        LlmError::QuotaExhausted(_)
    ));
}

#[test]
fn generic_429_stays_rate_limited_and_retryable() {
    for body in [
        "Too Many Requests",
        "Rate limit on your API quota exceeded",
        "Requests per minute quota exceeded",
        "Quota rate limit exceeded; retry after 10 seconds",
    ] {
        let error = LlmError::from_http_response(429, body);
        assert!(
            matches!(error, LlmError::RateLimited { .. }),
            "expected transient rate limit for {body:?}, got {error:?}"
        );
        assert!(error.is_retryable());
    }

    let raw = r#"{"error":{"code":"RESOURCE_EXHAUSTED","message":"Rate limit on your API quota exceeded"}}"#;
    let safe = sanitize_http_error_body(Some("fixture"), 429, raw);
    let error = LlmError::from_http_response(429, &safe);
    assert!(matches!(error, LlmError::RateLimited { .. }));
    assert!(error.is_retryable());
}

#[tokio::test]
async fn retry_loop_stops_after_one_typed_quota_failure() {
    let mut calls = 0;
    let result: RetryResult<i32> = with_retry(
        &RetryConfig::default(),
        || {
            calls += 1;
            async {
                Err(LlmError::from_http_response(
                    429,
                    r#"{"error":{"code":"insufficient_quota"}}"#,
                ))
            }
        },
        None,
    )
    .await;
    assert_eq!(result.unwrap_err().attempts, 1);
    assert_eq!(calls, 1);
}

#[tokio::test]
async fn retry_loop_stops_after_one_authentication_failure() {
    let mut calls = 0;
    let result: RetryResult<i32> = with_retry(
        &RetryConfig::default(),
        || {
            calls += 1;
            async { Err(LlmError::authentication_error("bad key")) }
        },
        None,
    )
    .await;
    assert!(result.is_err());
    assert_eq!(calls, 1);
}