hoosh 1.3.0

AI inference gateway — multi-provider LLM routing, local model serving, speech-to-text, and token budget management
Documentation
//! Error types for hoosh.

use thiserror::Error;

/// Top-level error type.
///
/// # Example
///
/// ```
/// use hoosh::HooshError;
///
/// let err = HooshError::ModelNotFound("gpt-99".into());
/// assert_eq!(err.http_status_code(), 404);
/// assert_eq!(err.error_code(), "model_not_found");
/// assert!(!err.is_retryable());
/// ```
#[derive(Debug, Error)]
pub enum HooshError {
    #[error("provider error: {0}")]
    Provider(String),

    #[error("model not found: {0}")]
    ModelNotFound(String),

    #[error("rate limited: retry after {retry_after_ms}ms")]
    RateLimited { retry_after_ms: u64 },

    #[error("token budget exceeded: pool '{pool}' has {remaining} tokens remaining")]
    BudgetExceeded { pool: String, remaining: u64 },

    #[error("no provider available for model '{0}'")]
    NoProvider(String),

    #[error("inference timeout after {0}ms")]
    Timeout(u64),

    #[error("cache error: {0}")]
    Cache(String),

    #[error("content blocked by DLP policy: {reason}")]
    DlpBlocked { reason: String },

    #[error(transparent)]
    Http(#[from] reqwest::Error),

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

impl HooshError {
    /// Map to an OpenAI-compatible HTTP status code.
    pub fn http_status_code(&self) -> u16 {
        match self {
            Self::ModelNotFound(_) | Self::NoProvider(_) => 404,
            Self::RateLimited { .. } | Self::BudgetExceeded { .. } => 429,
            Self::Timeout(_) => 408,
            Self::Cache(_) | Self::Provider(_) => 500,
            Self::DlpBlocked { .. } => 403,
            Self::Http(e) => e.status().map(|s| s.as_u16()).unwrap_or(502),
            Self::Other(_) => 500,
        }
    }

    /// Whether this error is transient and the request should be retried.
    ///
    /// Retryable: rate limits, timeouts, provider 5xx, connection errors.
    /// Permanent: model not found, budget exceeded, 4xx client errors.
    #[must_use]
    #[inline]
    pub fn is_retryable(&self) -> bool {
        match self {
            Self::RateLimited { .. } | Self::Timeout(_) => true,
            Self::Provider(_) | Self::Cache(_) => true,
            Self::Http(e) => {
                // Retry on 5xx or connection errors
                e.status().map(|s| s.is_server_error()).unwrap_or(true) // connection error = retryable
            }
            Self::ModelNotFound(_)
            | Self::NoProvider(_)
            | Self::BudgetExceeded { .. }
            | Self::DlpBlocked { .. }
            | Self::Other(_) => false,
        }
    }

    /// Map to an OpenAI-compatible error code string.
    pub fn error_code(&self) -> &'static str {
        match self {
            Self::ModelNotFound(_) => "model_not_found",
            Self::NoProvider(_) => "no_provider",
            Self::RateLimited { .. } => "rate_limit_exceeded",
            Self::BudgetExceeded { .. } => "budget_exceeded",
            Self::Timeout(_) => "timeout",
            Self::Cache(_) => "cache_error",
            Self::Provider(_) => "provider_error",
            Self::DlpBlocked { .. } => "content_blocked",
            Self::Http(_) => "upstream_error",
            Self::Other(_) => "internal_error",
        }
    }
}

pub type Result<T> = std::result::Result<T, HooshError>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn http_status_codes() {
        assert_eq!(
            HooshError::ModelNotFound("x".into()).http_status_code(),
            404
        );
        assert_eq!(HooshError::NoProvider("x".into()).http_status_code(), 404);
        assert_eq!(
            HooshError::RateLimited {
                retry_after_ms: 1000
            }
            .http_status_code(),
            429
        );
        assert_eq!(
            HooshError::BudgetExceeded {
                pool: "default".into(),
                remaining: 0,
            }
            .http_status_code(),
            429
        );
        assert_eq!(HooshError::Timeout(5000).http_status_code(), 408);
        assert_eq!(HooshError::Provider("err".into()).http_status_code(), 500);
        assert_eq!(HooshError::Cache("err".into()).http_status_code(), 500);
        assert_eq!(
            HooshError::Other(anyhow::anyhow!("err")).http_status_code(),
            500
        );
    }

    #[test]
    fn error_codes() {
        assert_eq!(
            HooshError::ModelNotFound("x".into()).error_code(),
            "model_not_found"
        );
        assert_eq!(
            HooshError::NoProvider("x".into()).error_code(),
            "no_provider"
        );
        assert_eq!(
            HooshError::RateLimited { retry_after_ms: 0 }.error_code(),
            "rate_limit_exceeded"
        );
        assert_eq!(
            HooshError::BudgetExceeded {
                pool: "p".into(),
                remaining: 0,
            }
            .error_code(),
            "budget_exceeded"
        );
        assert_eq!(HooshError::Timeout(0).error_code(), "timeout");
        assert_eq!(HooshError::Cache("c".into()).error_code(), "cache_error");
        assert_eq!(
            HooshError::Provider("p".into()).error_code(),
            "provider_error"
        );
        assert_eq!(
            HooshError::Other(anyhow::anyhow!("o")).error_code(),
            "internal_error"
        );
    }

    #[test]
    fn error_display() {
        let e = HooshError::ModelNotFound("llama99".into());
        assert_eq!(e.to_string(), "model not found: llama99");

        let e = HooshError::RateLimited {
            retry_after_ms: 5000,
        };
        assert!(e.to_string().contains("5000"));

        let e = HooshError::BudgetExceeded {
            pool: "default".into(),
            remaining: 42,
        };
        assert!(e.to_string().contains("default"));
        assert!(e.to_string().contains("42"));
    }
}