Skip to main content

audacity_sdk/
error.rs

1use thiserror::Error;
2
3/// Details carried by every server-derived error variant.
4#[derive(Debug, Clone)]
5pub struct ErrorDetails {
6    pub message: String,
7    pub status_code: u16,
8    /// Raw code/type string from the error payload.
9    pub error_code: Option<String>,
10    /// `request_id` captured from shape-B error payloads.
11    pub request_id: Option<String>,
12    /// Value of the `Retry-After` header, if present.
13    pub retry_after_seconds: Option<f64>,
14    pub raw_body: String,
15}
16
17impl std::fmt::Display for ErrorDetails {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        write!(f, "{}", self.message)
20    }
21}
22
23/// The SDK error type — all variants mirror the spec §4 exception taxonomy.
24#[derive(Debug, Error)]
25pub enum Error {
26    /// 400 / invalid_request_error / VALIDATION_ERROR
27    #[error("ValidationException: {0}")]
28    Validation(ErrorDetails),
29
30    /// 401/403 / authentication_error / AUTHORIZATION_ERROR / MODEL_NOT_ALLOWED
31    #[error("AccessDeniedException: {0}")]
32    AccessDenied(ErrorDetails),
33
34    /// 402 / usage_cap_exceeded / BUDGET_EXCEEDED
35    #[error("ServiceQuotaExceededException: {0}")]
36    ServiceQuotaExceeded(ErrorDetails),
37
38    /// 404 / MODEL_NOT_FOUND
39    #[error("ResourceNotFoundException: {0}")]
40    ResourceNotFound(ErrorDetails),
41
42    /// 429 (retryable) / rate_limit_exceeded / RATE_LIMIT_EXCEEDED
43    #[error("ThrottlingException: {0}")]
44    Throttling(ErrorDetails),
45
46    /// 408 / TIMEOUT_ERROR
47    #[error("ModelTimeoutException: {0}")]
48    ModelTimeout(ErrorDetails),
49
50    /// 5xx model-side error
51    #[error("ModelErrorException: {0}")]
52    ModelError(ErrorDetails),
53
54    /// Stream-specific error (subtype of ModelError)
55    #[error("ModelStreamErrorException: {0}")]
56    ModelStreamError(ErrorDetails),
57
58    /// 503/502/504 / ServiceUnavailable
59    #[error("ServiceUnavailableException: {0}")]
60    ServiceUnavailable(ErrorDetails),
61
62    /// 500 / InternalServerException
63    #[error("InternalServer: {0}")]
64    InternalServer(ErrorDetails),
65
66    /// No API key could be resolved from config or environment.
67    #[error("MissingApiKeyError: no API key provided; set AUDACITY_API_KEY or pass api_key to Config::builder()")]
68    MissingApiKey,
69
70    /// Network / decode failures (retryable on network errors).
71    #[error("SdkError: {0}")]
72    Sdk(String),
73}
74
75impl Error {
76    /// Returns true if this error is retryable per the spec retry policy.
77    pub fn is_retryable(&self) -> bool {
78        matches!(
79            self,
80            Error::Throttling(_)
81                | Error::ModelTimeout(_)
82                | Error::ServiceUnavailable(_)
83                | Error::InternalServer(_)
84                | Error::Sdk(_)
85        )
86    }
87
88    /// Returns the `Retry-After` value if this error carries one.
89    pub fn retry_after_seconds(&self) -> Option<f64> {
90        match self {
91            Error::Throttling(d)
92            | Error::ModelTimeout(d)
93            | Error::ServiceUnavailable(d)
94            | Error::InternalServer(d)
95            | Error::ModelError(d)
96            | Error::ModelStreamError(d)
97            | Error::Validation(d)
98            | Error::AccessDenied(d)
99            | Error::ServiceQuotaExceeded(d)
100            | Error::ResourceNotFound(d) => d.retry_after_seconds,
101            _ => None,
102        }
103    }
104}
105
106// ── error parsing ────────────────────────────────────────────────────────────
107
108/// Parse the raw response body + HTTP status into an [`Error`].
109pub(crate) fn parse_error(status: u16, body: &str, retry_after: Option<f64>) -> Error {
110    let (message, error_code, request_id) = extract_error_fields(body);
111
112    let details = ErrorDetails {
113        message: message.clone(),
114        status_code: status,
115        error_code: error_code.clone(),
116        request_id,
117        retry_after_seconds: retry_after,
118        raw_body: body.to_owned(),
119    };
120
121    classify_error(status, error_code.as_deref(), details)
122}
123
124/// Pull (message, code, request_id) out of shape-A or shape-B bodies.
125pub(crate) fn extract_error_fields(body: &str) -> (String, Option<String>, Option<String>) {
126    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
127        return (body.to_owned(), None, None);
128    };
129
130    // Shape B: { "success": false, "error": { "code": …, "message": …, "request_id": … } }
131    if v.get("success").and_then(|s| s.as_bool()) == Some(false) {
132        if let Some(err) = v.get("error") {
133            let message = err
134                .get("message")
135                .and_then(|m| m.as_str())
136                .unwrap_or("unknown error")
137                .to_owned();
138            let code = err.get("code").and_then(|c| c.as_str()).map(str::to_owned);
139            let request_id = err
140                .get("request_id")
141                .and_then(|r| r.as_str())
142                .map(str::to_owned);
143            return (message, code, request_id);
144        }
145    }
146
147    // Shape A: { "error": { "message": …, "type": …, "code": … } }
148    if let Some(err) = v.get("error") {
149        let message = err
150            .get("message")
151            .and_then(|m| m.as_str())
152            .unwrap_or("unknown error")
153            .to_owned();
154        // prefer "code" over "type"
155        let code = err
156            .get("code")
157            .and_then(|c| c.as_str())
158            .filter(|s| !s.is_empty())
159            .or_else(|| err.get("type").and_then(|t| t.as_str()))
160            .map(str::to_owned);
161        return (message, code, None);
162    }
163
164    (body.to_owned(), None, None)
165}
166
167/// Classify into the right Error variant using the spec mapping table.
168pub(crate) fn classify_error(status: u16, code: Option<&str>, details: ErrorDetails) -> Error {
169    let details = match code {
170        Some(c) => match classify_by_code(c, status, details) {
171            Ok(err) => return err,
172            Err(details) => details, // unrecognized code — fall through to HTTP status
173        },
174        None => details,
175    };
176    classify_by_status(status, details)
177}
178
179/// Map a raw error code (case-insensitive) to a variant per the spec table.
180/// Returns the details back if the code is unrecognized.
181pub(crate) fn classify_by_code(
182    code: &str,
183    status: u16,
184    details: ErrorDetails,
185) -> Result<Error, ErrorDetails> {
186    Ok(match code.to_uppercase().as_str() {
187        "INVALID_API_KEY"
188        | "API_KEY_REQUIRED"
189        | "AUTHENTICATION_ERROR"
190        | "AUTHORIZATION_ERROR"
191        | "MODEL_NOT_ALLOWED" => Error::AccessDenied(details),
192
193        "USAGE_CAP_EXCEEDED" | "USAGE_CAP_ERROR" | "BUDGET_EXCEEDED" => {
194            Error::ServiceQuotaExceeded(details)
195        }
196
197        "RATE_LIMIT_EXCEEDED" | "RATE_LIMIT_ERROR" => Error::Throttling(details),
198
199        "INVALID_REQUEST_ERROR" | "VALIDATION_ERROR" => Error::Validation(details),
200
201        "MODEL_NOT_FOUND" => Error::ResourceNotFound(details),
202
203        "TIMEOUT_ERROR" => Error::ModelTimeout(details),
204
205        "STREAM_ERROR" => Error::ModelStreamError(details),
206
207        "UPSTREAM_ERROR" => {
208            if status >= 500 {
209                Error::ServiceUnavailable(details)
210            } else {
211                Error::ModelError(details)
212            }
213        }
214
215        _ => return Err(details),
216    })
217}
218
219/// HTTP status fallback (when the code is absent/unrecognized).
220fn classify_by_status(status: u16, details: ErrorDetails) -> Error {
221    match status {
222        400 => Error::Validation(details),
223        401 | 403 => Error::AccessDenied(details),
224        402 => Error::ServiceQuotaExceeded(details),
225        404 => Error::ResourceNotFound(details),
226        408 => Error::ModelTimeout(details),
227        429 => Error::Throttling(details),
228        500 => Error::InternalServer(details),
229        502..=504 => Error::ServiceUnavailable(details),
230        s if (400..500).contains(&s) => Error::Validation(details),
231        s if s >= 500 => Error::InternalServer(details),
232        _ => Error::Sdk(details.message),
233    }
234}
235
236/// Parse `Retry-After` header value (seconds as integer or float).
237pub(crate) fn parse_retry_after(value: &str) -> Option<f64> {
238    value.trim().parse::<f64>().ok()
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn shape_a_authentication_error() {
247        let body = r#"{"error":{"message":"Invalid API key","type":"authentication_error","param":null,"code":"invalid_api_key"}}"#;
248        let err = parse_error(401, body, None);
249        assert!(matches!(err, Error::AccessDenied(_)));
250        if let Error::AccessDenied(d) = err {
251            assert_eq!(d.message, "Invalid API key");
252            assert_eq!(d.error_code.as_deref(), Some("invalid_api_key"));
253            assert!(d.request_id.is_none());
254        }
255    }
256
257    #[test]
258    fn shape_b_model_not_allowed() {
259        let body = r#"{"success":false,"error":{"code":"MODEL_NOT_ALLOWED","message":"Model not allowed","request_id":"req-123","details":{}}}"#;
260        let err = parse_error(403, body, None);
261        assert!(matches!(err, Error::AccessDenied(_)));
262        if let Error::AccessDenied(d) = err {
263            assert_eq!(d.request_id.as_deref(), Some("req-123"));
264        }
265    }
266
267    #[test]
268    fn shape_b_budget_exceeded() {
269        let body = r#"{"success":false,"error":{"code":"BUDGET_EXCEEDED","message":"over budget","request_id":null}}"#;
270        let err = parse_error(429, body, None);
271        assert!(matches!(err, Error::ServiceQuotaExceeded(_)));
272        // Must NOT be retryable
273        assert!(!err.is_retryable());
274    }
275
276    #[test]
277    fn rate_limit_is_retryable() {
278        let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error","code":"rate_limit_exceeded"}}"#;
279        let err = parse_error(429, body, Some(2.0));
280        assert!(matches!(err, Error::Throttling(_)));
281        assert!(err.is_retryable());
282        assert_eq!(err.retry_after_seconds(), Some(2.0));
283    }
284
285    #[test]
286    fn http_status_fallback_503() {
287        let err = parse_error(503, "Service Unavailable", None);
288        assert!(matches!(err, Error::ServiceUnavailable(_)));
289        assert!(err.is_retryable());
290    }
291
292    #[test]
293    fn upstream_error_5xx_is_service_unavailable() {
294        let body = r#"{"error":{"message":"upstream","code":"UPSTREAM_ERROR"}}"#;
295        let err = parse_error(502, body, None);
296        assert!(matches!(err, Error::ServiceUnavailable(_)));
297    }
298
299    #[test]
300    fn upstream_error_4xx_is_model_error() {
301        let body = r#"{"error":{"message":"upstream","code":"UPSTREAM_ERROR"}}"#;
302        let err = parse_error(400, body, None);
303        assert!(matches!(err, Error::ModelError(_)));
304    }
305
306    #[test]
307    fn parse_retry_after_value() {
308        assert_eq!(parse_retry_after("5"), Some(5.0));
309        assert_eq!(parse_retry_after("2.5"), Some(2.5));
310        assert_eq!(parse_retry_after("bad"), None);
311    }
312}