jev-sdk 0.1.0

Unofficial Rust client for the TypeSafe AI System One API (Jev): typed decisions with calibrated probabilities
Documentation
//! Error types for the SDK.

use reqwest::StatusCode;
use serde_json::Value;
use std::fmt;

/// Errors returned by this SDK.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The client is misconfigured (e.g. a missing API key or invalid URL).
    #[error("configuration error: {0}")]
    Config(String),

    /// The request failed client-side validation before anything was sent.
    #[error("invalid request: {0}")]
    InvalidRequest(String),

    /// The API returned an unsuccessful HTTP response (after any retries).
    #[error(transparent)]
    Api(ApiError),

    /// The request could not be sent, or the connection failed (after any
    /// retries). Includes request timeouts.
    #[error("connection error: {0}")]
    Connection(String),

    /// A successful response body could not be decoded.
    #[error("failed to decode response body: {0}")]
    Decode(String),
}

impl Error {
    /// The HTTP status code, if this is an API error.
    pub fn status(&self) -> Option<StatusCode> {
        match self {
            Error::Api(e) => Some(e.status),
            _ => None,
        }
    }

    /// Whether the request failed authentication (HTTP 401).
    pub fn is_authentication(&self) -> bool {
        self.status() == Some(StatusCode::UNAUTHORIZED)
    }

    /// Whether the request was rate-limited or the service was overloaded
    /// (HTTP 429 or 529).
    pub fn is_rate_limited(&self) -> bool {
        matches!(self.status().map(|s| s.as_u16()), Some(429) | Some(529))
    }

    /// Whether the request body failed server-side validation
    /// (HTTP 400 or 422).
    pub fn is_validation(&self) -> bool {
        matches!(self.status().map(|s| s.as_u16()), Some(400) | Some(422))
    }
}

/// An unsuccessful HTTP response from the TypeSafe API.
#[derive(Debug)]
pub struct ApiError {
    /// The HTTP status code.
    pub status: StatusCode,
    /// A human-readable error message extracted from the response body.
    pub message: String,
    /// The parsed response body, if it was JSON.
    pub body: Option<Value>,
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "API error (status {}): {}", self.status, self.message)
    }
}

impl std::error::Error for ApiError {}

impl ApiError {
    /// Build an `ApiError` from a status code and a raw response body,
    /// extracting a message from the API's `{"detail": ...}` error shapes:
    /// a plain string, `{"error_type": ..., "message": ...}`, or a list of
    /// validation errors.
    pub(crate) fn from_response(status: StatusCode, raw: &str) -> Self {
        let body: Option<Value> = serde_json::from_str(raw).ok();
        let message = body
            .as_ref()
            .and_then(|b| b.get("detail"))
            .map(extract_detail_message)
            .filter(|m| !m.is_empty())
            .unwrap_or_else(|| {
                if raw.is_empty() {
                    status
                        .canonical_reason()
                        .unwrap_or("unknown error")
                        .to_owned()
                } else {
                    raw.chars().take(512).collect()
                }
            });
        ApiError {
            status,
            message,
            body,
        }
    }
}

fn extract_detail_message(detail: &Value) -> String {
    match detail {
        Value::String(s) => s.clone(),
        Value::Object(map) => map
            .get("message")
            .and_then(Value::as_str)
            .map(str::to_owned)
            .unwrap_or_else(|| detail.to_string()),
        Value::Array(items) => items
            .iter()
            .map(|item| {
                let loc = item
                    .get("loc")
                    .and_then(Value::as_array)
                    .map(|parts| {
                        parts
                            .iter()
                            .filter_map(|p| {
                                p.as_str()
                                    .map(str::to_owned)
                                    .or_else(|| p.as_number().map(|n| n.to_string()))
                            })
                            .collect::<Vec<_>>()
                            .join(".")
                    })
                    .unwrap_or_default();
                let msg = item.get("msg").and_then(Value::as_str).unwrap_or("invalid");
                if loc.is_empty() {
                    msg.to_owned()
                } else {
                    format!("{loc}: {msg}")
                }
            })
            .collect::<Vec<_>>()
            .join("; "),
        other => other.to_string(),
    }
}

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

    #[test]
    fn parses_object_detail() {
        let err = ApiError::from_response(
            StatusCode::UNAUTHORIZED,
            r#"{"detail":{"error_type":"authentication_error","message":"Cannot authenticate with the server. Please check your API key and try again."}}"#,
        );
        assert_eq!(
            err.message,
            "Cannot authenticate with the server. Please check your API key and try again."
        );
        assert_eq!(err.status, StatusCode::UNAUTHORIZED);
    }

    #[test]
    fn parses_string_detail() {
        let err = ApiError::from_response(
            StatusCode::BAD_REQUEST,
            r#"{"detail":"Noul question must have criteria or instructions: q"}"#,
        );
        assert_eq!(
            err.message,
            "Noul question must have criteria or instructions: q"
        );
    }

    #[test]
    fn parses_fastapi_validation_list() {
        let err = ApiError::from_response(
            StatusCode::UNPROCESSABLE_ENTITY,
            r#"{"detail":[{"loc":["body","questions"],"msg":"field required","type":"value_error.missing"}]}"#,
        );
        assert_eq!(err.message, "body.questions: field required");
    }

    #[test]
    fn falls_back_to_raw_body() {
        let err = ApiError::from_response(StatusCode::INTERNAL_SERVER_ERROR, "oops");
        assert_eq!(err.message, "oops");
        assert!(err.body.is_none());
    }
}