audacity-sdk 0.2.0

Rust SDK for the Audacity Investments AI gateway — Amazon Bedrock Converse-compatible API surface
Documentation
use thiserror::Error;

/// Details carried by every server-derived error variant.
#[derive(Debug, Clone, Default)]
pub struct ErrorDetails {
    pub message: String,
    pub status_code: u16,
    /// Raw code/type string from the error payload.
    pub error_code: Option<String>,
    /// `request_id` captured from shape-B error payloads.
    pub request_id: Option<String>,
    /// `error.details` captured from shape-B error payloads
    /// (e.g. `binding_cap` on budget errors).
    pub details: Option<serde_json::Value>,
    /// Value of the `Retry-After` header, if present.
    pub retry_after_seconds: Option<f64>,
    pub raw_body: String,
}

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

/// The SDK error type — all variants mirror the spec §4 exception taxonomy.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
    /// 400 / invalid_request_error / VALIDATION_ERROR
    #[error("ValidationException: {0}")]
    Validation(Box<ErrorDetails>),

    /// 401/403 / authentication_error / AUTHORIZATION_ERROR / MODEL_NOT_ALLOWED
    #[error("AccessDeniedException: {0}")]
    AccessDenied(Box<ErrorDetails>),

    /// 402 / usage_cap_exceeded / BUDGET_EXCEEDED
    #[error("ServiceQuotaExceededException: {0}")]
    ServiceQuotaExceeded(Box<ErrorDetails>),

    /// 404 / MODEL_NOT_FOUND
    #[error("ResourceNotFoundException: {0}")]
    ResourceNotFound(Box<ErrorDetails>),

    /// 429 (retryable) / rate_limit_exceeded / RATE_LIMIT_EXCEEDED
    #[error("ThrottlingException: {0}")]
    Throttling(Box<ErrorDetails>),

    /// 408 / TIMEOUT_ERROR
    #[error("ModelTimeoutException: {0}")]
    ModelTimeout(Box<ErrorDetails>),

    /// 5xx model-side error
    #[error("ModelErrorException: {0}")]
    ModelError(Box<ErrorDetails>),

    /// Stream-specific error (subtype of ModelError)
    #[error("ModelStreamErrorException: {0}")]
    ModelStreamError(Box<ErrorDetails>),

    /// 503/502/504 / ServiceUnavailable
    #[error("ServiceUnavailableException: {0}")]
    ServiceUnavailable(Box<ErrorDetails>),

    /// 500 / InternalServerException
    #[error("InternalServer: {0}")]
    InternalServer(Box<ErrorDetails>),

    /// No API key could be resolved from config or environment.
    #[error("MissingApiKeyError: no API key provided; set AUDACITY_API_KEY or pass api_key to Config::builder()")]
    MissingApiKey,

    /// Client-side failure. Per spec §4 only **network-level** failures are
    /// retryable; JSON-decode failures and malformed 200 bodies are not.
    #[error("SdkError: {message}")]
    Sdk { message: String, retryable: bool },
}

impl Error {
    /// A retryable network-level client failure (connect/read errors).
    pub(crate) fn sdk_network(message: impl Into<String>) -> Self {
        Error::Sdk {
            message: message.into(),
            retryable: true,
        }
    }

    /// A non-retryable client failure (decode errors, malformed bodies, internal).
    pub(crate) fn sdk(message: impl Into<String>) -> Self {
        Error::Sdk {
            message: message.into(),
            retryable: false,
        }
    }

    /// A client-side input validation failure (no HTTP exchange happened).
    pub(crate) fn client_validation(message: impl Into<String>) -> Self {
        Error::Validation(Box::new(ErrorDetails {
            message: message.into(),
            ..Default::default()
        }))
    }

    /// A stream-level failure with no HTTP error payload (transport drop, etc.).
    pub(crate) fn model_stream_error(message: impl Into<String>) -> Self {
        Error::ModelStreamError(Box::new(ErrorDetails {
            message: message.into(),
            ..Default::default()
        }))
    }

    /// Returns true if this error is retryable per the spec retry policy.
    pub fn is_retryable(&self) -> bool {
        matches!(
            self,
            Error::Throttling(_)
                | Error::ModelTimeout(_)
                | Error::ServiceUnavailable(_)
                | Error::InternalServer(_)
                | Error::Sdk {
                    retryable: true,
                    ..
                }
        )
    }

    /// Returns the `Retry-After` value if this error carries one.
    pub fn retry_after_seconds(&self) -> Option<f64> {
        match self {
            Error::Throttling(d)
            | Error::ModelTimeout(d)
            | Error::ServiceUnavailable(d)
            | Error::InternalServer(d)
            | Error::ModelError(d)
            | Error::ModelStreamError(d)
            | Error::Validation(d)
            | Error::AccessDenied(d)
            | Error::ServiceQuotaExceeded(d)
            | Error::ResourceNotFound(d) => d.retry_after_seconds,
            _ => None,
        }
    }
}

// ── error parsing ────────────────────────────────────────────────────────────

/// Parse the raw response body + HTTP status into an [`Error`].
pub(crate) fn parse_error(status: u16, body: &str, retry_after: Option<f64>) -> Error {
    let (message, error_code, request_id, error_details) = extract_error_fields(body);

    let details = Box::new(ErrorDetails {
        message: message.clone(),
        status_code: status,
        error_code: error_code.clone(),
        request_id,
        details: error_details,
        retry_after_seconds: retry_after,
        raw_body: body.to_owned(),
    });

    classify_error(status, error_code.as_deref(), details)
}

/// Pull (message, code, request_id, details) out of shape-A or shape-B bodies.
#[allow(clippy::type_complexity)]
pub(crate) fn extract_error_fields(
    body: &str,
) -> (
    String,
    Option<String>,
    Option<String>,
    Option<serde_json::Value>,
) {
    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
        return (body.to_owned(), None, None, None);
    };

    // Shape B: { "success": false, "error": { "code": …, "message": …,
    //            "request_id": …, "details": … } }
    if v.get("success").and_then(|s| s.as_bool()) == Some(false) {
        if let Some(err) = v.get("error") {
            let message = err
                .get("message")
                .and_then(|m| m.as_str())
                .unwrap_or("unknown error")
                .to_owned();
            let code = err.get("code").and_then(|c| c.as_str()).map(str::to_owned);
            let request_id = err
                .get("request_id")
                .and_then(|r| r.as_str())
                .map(str::to_owned);
            let details = err.get("details").filter(|d| !d.is_null()).cloned();
            return (message, code, request_id, details);
        }
    }

    // Shape A: { "error": { "message": …, "type": …, "code": … } }
    if let Some(err) = v.get("error") {
        let message = err
            .get("message")
            .and_then(|m| m.as_str())
            .unwrap_or("unknown error")
            .to_owned();
        // prefer "code" over "type"
        let code = err
            .get("code")
            .and_then(|c| c.as_str())
            .filter(|s| !s.is_empty())
            .or_else(|| err.get("type").and_then(|t| t.as_str()))
            .map(str::to_owned);
        return (message, code, None, None);
    }

    (body.to_owned(), None, None, None)
}

/// Classify into the right Error variant using the spec mapping table.
pub(crate) fn classify_error(status: u16, code: Option<&str>, details: Box<ErrorDetails>) -> Error {
    let details = match code {
        Some(c) => match classify_by_code(c, status, details) {
            Ok(err) => return err,
            Err(details) => details, // unrecognized code — fall through to HTTP status
        },
        None => details,
    };
    classify_by_status(status, details)
}

/// Map a raw error code (case-insensitive) to a variant per the spec table.
/// Returns the details back if the code is unrecognized.
pub(crate) fn classify_by_code(
    code: &str,
    status: u16,
    details: Box<ErrorDetails>,
) -> Result<Error, Box<ErrorDetails>> {
    Ok(match code.to_uppercase().as_str() {
        "INVALID_API_KEY"
        | "API_KEY_REQUIRED"
        | "AUTHENTICATION_ERROR"
        | "AUTHORIZATION_ERROR"
        | "MODEL_NOT_ALLOWED" => Error::AccessDenied(details),

        "USAGE_CAP_EXCEEDED" | "USAGE_CAP_ERROR" | "BUDGET_EXCEEDED" => {
            Error::ServiceQuotaExceeded(details)
        }

        "RATE_LIMIT_EXCEEDED" | "RATE_LIMIT_ERROR" => Error::Throttling(details),

        "INVALID_REQUEST_ERROR" | "VALIDATION_ERROR" => Error::Validation(details),

        "MODEL_NOT_FOUND" => Error::ResourceNotFound(details),

        "TIMEOUT_ERROR" => Error::ModelTimeout(details),

        "STREAM_ERROR" => Error::ModelStreamError(details),

        "UPSTREAM_ERROR" => {
            if status >= 500 {
                Error::ServiceUnavailable(details)
            } else {
                Error::ModelError(details)
            }
        }

        _ => return Err(details),
    })
}

/// HTTP status fallback (when the code is absent/unrecognized).
fn classify_by_status(status: u16, details: Box<ErrorDetails>) -> Error {
    match status {
        400 => Error::Validation(details),
        401 | 403 => Error::AccessDenied(details),
        402 => Error::ServiceQuotaExceeded(details),
        404 => Error::ResourceNotFound(details),
        408 => Error::ModelTimeout(details),
        429 => Error::Throttling(details),
        500 => Error::InternalServer(details),
        502..=504 => Error::ServiceUnavailable(details),
        s if (400..500).contains(&s) => Error::Validation(details),
        s if s >= 500 => Error::InternalServer(details),
        // Non-200 2xx/3xx — unexpected from this API; not retryable.
        s => Error::sdk(format!(
            "unexpected HTTP status {s}: {}",
            details.message
        )),
    }
}

/// Parse `Retry-After` header value (seconds as integer or float).
pub(crate) fn parse_retry_after(value: &str) -> Option<f64> {
    value.trim().parse::<f64>().ok()
}

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

    #[test]
    fn shape_a_authentication_error() {
        let body = r#"{"error":{"message":"Invalid API key","type":"authentication_error","param":null,"code":"invalid_api_key"}}"#;
        let err = parse_error(401, body, None);
        assert!(matches!(err, Error::AccessDenied(_)));
        if let Error::AccessDenied(d) = err {
            assert_eq!(d.message, "Invalid API key");
            assert_eq!(d.error_code.as_deref(), Some("invalid_api_key"));
            assert!(d.request_id.is_none());
        }
    }

    #[test]
    fn shape_b_model_not_allowed() {
        let body = r#"{"success":false,"error":{"code":"MODEL_NOT_ALLOWED","message":"Model not allowed","request_id":"req-123","details":{"binding_cap":"team-alpha"}}}"#;
        let err = parse_error(403, body, None);
        assert!(matches!(err, Error::AccessDenied(_)));
        if let Error::AccessDenied(d) = err {
            assert_eq!(d.request_id.as_deref(), Some("req-123"));
            // spec §4: shape-B error.details must be carried on the exception
            let details = d.details.expect("details must be captured from shape B");
            assert_eq!(details["binding_cap"], "team-alpha");
        }
    }

    #[test]
    fn shape_a_has_no_details() {
        let body = r#"{"error":{"message":"bad request","type":"invalid_request_error"}}"#;
        let err = parse_error(400, body, None);
        if let Error::Validation(d) = err {
            assert!(d.details.is_none());
        } else {
            panic!("expected Validation");
        }
    }

    #[test]
    fn shape_b_budget_exceeded() {
        let body = r#"{"success":false,"error":{"code":"BUDGET_EXCEEDED","message":"over budget","request_id":null}}"#;
        let err = parse_error(429, body, None);
        assert!(matches!(err, Error::ServiceQuotaExceeded(_)));
        // Must NOT be retryable
        assert!(!err.is_retryable());
    }

    #[test]
    fn rate_limit_is_retryable() {
        let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error","code":"rate_limit_exceeded"}}"#;
        let err = parse_error(429, body, Some(2.0));
        assert!(matches!(err, Error::Throttling(_)));
        assert!(err.is_retryable());
        assert_eq!(err.retry_after_seconds(), Some(2.0));
    }

    #[test]
    fn http_status_fallback_503() {
        let err = parse_error(503, "Service Unavailable", None);
        assert!(matches!(err, Error::ServiceUnavailable(_)));
        assert!(err.is_retryable());
    }

    #[test]
    fn upstream_error_5xx_is_service_unavailable() {
        let body = r#"{"error":{"message":"upstream","code":"UPSTREAM_ERROR"}}"#;
        let err = parse_error(502, body, None);
        assert!(matches!(err, Error::ServiceUnavailable(_)));
    }

    #[test]
    fn upstream_error_4xx_is_model_error() {
        let body = r#"{"error":{"message":"upstream","code":"UPSTREAM_ERROR"}}"#;
        let err = parse_error(400, body, None);
        assert!(matches!(err, Error::ModelError(_)));
    }

    #[test]
    fn parse_retry_after_value() {
        assert_eq!(parse_retry_after("5"), Some(5.0));
        assert_eq!(parse_retry_after("2.5"), Some(2.5));
        assert_eq!(parse_retry_after("bad"), None);
    }
}