reasoninglayer 0.1.2

Rust client SDK for the Reasoning Layer API
Documentation
//! Error types for the Reasoning Layer SDK.
//!
//! The top-level [`Error`] enum is returned by every fallible SDK operation. It mirrors the TypeScript
//! SDK's error hierarchy: [`Api`](Error::Api) variants correspond to HTTP failures (with the specific
//! backend error kind available via [`ApiError::kind`]), while [`Timeout`](Error::Timeout),
//! [`Network`](Error::Network), and [`Validation`](Error::Validation) cover client-side failures.

use std::time::Duration;

use reqwest::header::HeaderMap;
use reqwest::StatusCode;
use serde_json::Value;

/// Top-level SDK error type. All public fallible APIs return `Result<T, Error>`.
///
/// The [`Api`](Self::Api) variant carries a boxed [`ApiError`] to keep `Error` compact (avoiding
/// a clippy `result_large_err` warning across every public method that can fail).
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// HTTP API returned a 4xx/5xx response.
    #[error(transparent)]
    Api(Box<ApiError>),

    /// Request exceeded its configured timeout.
    #[error("request timed out after {}ms", .timeout.as_millis())]
    Timeout {
        /// The timeout that was exceeded.
        timeout: Duration,
    },

    /// Client-side validation failed before the request was sent.
    #[error("validation error{}: {message}", field_display(.field.as_deref()))]
    Validation {
        /// Optional field that failed validation.
        field: Option<String>,
        /// Human-readable message.
        message: String,
    },

    /// Network failure (DNS resolution, connection refused, TLS error, etc.).
    #[error("network error: {message}")]
    Network {
        /// Human-readable message.
        message: String,
        /// Underlying cause, if available.
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync>>,
    },

    /// Request body serialization failed.
    #[error("serialization error: {0}")]
    Serde(#[from] serde_json::Error),

    /// Could not parse or build a URL.
    #[error("url error: {0}")]
    Url(#[from] url::ParseError),
}

impl Error {
    /// Construct a [`Error::Validation`] for a specific field.
    pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
        Self::Validation {
            field: Some(field.into()),
            message: message.into(),
        }
    }

    /// Construct a [`Error::Validation`] without a specific field.
    pub fn validation_msg(message: impl Into<String>) -> Self {
        Self::Validation {
            field: None,
            message: message.into(),
        }
    }

    /// Returns the HTTP status if this error originated from the backend.
    pub fn status(&self) -> Option<StatusCode> {
        match self {
            Error::Api(e) => Some(e.status),
            _ => None,
        }
    }

    /// Returns the [`ApiError`] if this error originated from the backend.
    pub fn as_api_error(&self) -> Option<&ApiError> {
        match self {
            Error::Api(e) => Some(e.as_ref()),
            _ => None,
        }
    }
}

impl From<ApiError> for Error {
    fn from(e: ApiError) -> Self {
        Self::Api(Box::new(e))
    }
}

fn field_display(field: Option<&str>) -> String {
    match field {
        Some(f) => format!(" ({f})"),
        None => String::new(),
    }
}

/// An HTTP error returned by the backend.
#[derive(Debug, thiserror::Error)]
#[error("{status}: {message}")]
pub struct ApiError {
    /// The kind of error (4xx/5xx category).
    pub kind: ApiErrorKind,
    /// HTTP status code.
    pub status: StatusCode,
    /// Human-readable message extracted from the response body when available.
    pub message: String,
    /// Backend error code (from response body `error` field), if present.
    pub error_code: Option<String>,
    /// Parsed response body, if it was valid JSON.
    pub body: Option<Value>,
    /// Response headers.
    pub headers: HeaderMap,
}

/// Categorises an [`ApiError`] by HTTP status code, matching the TypeScript SDK error hierarchy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorKind {
    /// 400 Bad Request.
    BadRequest,
    /// 401 Unauthorized.
    Authentication,
    /// 403 Forbidden.
    Forbidden,
    /// 404 Not Found.
    NotFound,
    /// 409 Conflict — constraint violation. See [`ApiError::constraint_violation`].
    ConstraintViolation,
    /// 429 Too Many Requests — rate limited. See [`ApiError::rate_limit`].
    RateLimit,
    /// 5xx internal server error.
    InternalServer,
    /// Any other 4xx.
    Other,
}

/// Structured details for a 409 constraint violation.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConstraintViolationDetails {
    /// Term ID that violated the constraint.
    pub term_id: Option<String>,
    /// Feature name that violated the constraint.
    pub feature: Option<String>,
    /// Constraint description.
    pub constraint: Option<String>,
}

/// Rate limit info attached to a 429 response.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RateLimitDetails {
    /// `Retry-After` (seconds), if present.
    pub retry_after: Option<u64>,
    /// `X-RateLimit-Limit`, if present.
    pub limit: Option<u64>,
    /// `X-RateLimit-Remaining`, if present.
    pub remaining: Option<u64>,
}

impl ApiError {
    /// Build an [`ApiError`] from the components of an HTTP response.
    pub(crate) fn from_response(
        status: StatusCode,
        body: Option<Value>,
        headers: HeaderMap,
    ) -> Self {
        let (message, error_code) = extract_error_fields(body.as_ref(), status);
        let kind = match status.as_u16() {
            400 => ApiErrorKind::BadRequest,
            401 => ApiErrorKind::Authentication,
            403 => ApiErrorKind::Forbidden,
            404 => ApiErrorKind::NotFound,
            409 => ApiErrorKind::ConstraintViolation,
            429 => ApiErrorKind::RateLimit,
            s if s >= 500 => ApiErrorKind::InternalServer,
            _ => ApiErrorKind::Other,
        };
        Self {
            kind,
            status,
            message,
            error_code,
            body,
            headers,
        }
    }

    /// If this is a 409 Conflict, returns structured constraint violation details parsed from the body.
    pub fn constraint_violation(&self) -> Option<ConstraintViolationDetails> {
        if self.kind != ApiErrorKind::ConstraintViolation {
            return None;
        }
        let details = self.body.as_ref()?.get("details")?.as_object()?;
        Some(ConstraintViolationDetails {
            term_id: details
                .get("term_id")
                .and_then(|v| v.as_str())
                .map(str::to_owned),
            feature: details
                .get("feature")
                .and_then(|v| v.as_str())
                .map(str::to_owned),
            constraint: details
                .get("constraint")
                .and_then(|v| v.as_str())
                .map(str::to_owned),
        })
    }

    /// If this is a 429 Too Many Requests, returns rate limit headers.
    pub fn rate_limit(&self) -> Option<RateLimitDetails> {
        if self.kind != ApiErrorKind::RateLimit {
            return None;
        }
        Some(RateLimitDetails {
            retry_after: parse_numeric_header(&self.headers, "retry-after"),
            limit: parse_numeric_header(&self.headers, "x-ratelimit-limit"),
            remaining: parse_numeric_header(&self.headers, "x-ratelimit-remaining"),
        })
    }
}

fn extract_error_fields(body: Option<&Value>, status: StatusCode) -> (String, Option<String>) {
    let fallback = format!("HTTP {}", status.as_u16());
    let Some(Value::Object(map)) = body else {
        return (fallback, None);
    };
    let message = map
        .get("message")
        .and_then(|v| v.as_str())
        .map(str::to_owned)
        .unwrap_or(fallback);
    let error_code = map.get("error").and_then(|v| v.as_str()).map(str::to_owned);
    (message, error_code)
}

pub(crate) fn parse_numeric_header(headers: &HeaderMap, name: &str) -> Option<u64> {
    headers.get(name)?.to_str().ok()?.parse::<u64>().ok()
}