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, Default)]
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    /// `error.details` captured from shape-B error payloads
13    /// (e.g. `binding_cap` on budget errors).
14    pub details: Option<serde_json::Value>,
15    /// Value of the `Retry-After` header, if present.
16    pub retry_after_seconds: Option<f64>,
17    pub raw_body: String,
18}
19
20impl std::fmt::Display for ErrorDetails {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "{}", self.message)
23    }
24}
25
26/// The SDK error type — all variants mirror the spec §4 exception taxonomy.
27#[derive(Debug, Error)]
28#[non_exhaustive]
29pub enum Error {
30    /// 400 / invalid_request_error / VALIDATION_ERROR
31    #[error("ValidationException: {0}")]
32    Validation(Box<ErrorDetails>),
33
34    /// 401/403 / authentication_error / AUTHORIZATION_ERROR / MODEL_NOT_ALLOWED
35    #[error("AccessDeniedException: {0}")]
36    AccessDenied(Box<ErrorDetails>),
37
38    /// 402 / usage_cap_exceeded / BUDGET_EXCEEDED
39    #[error("ServiceQuotaExceededException: {0}")]
40    ServiceQuotaExceeded(Box<ErrorDetails>),
41
42    /// 404 / MODEL_NOT_FOUND
43    #[error("ResourceNotFoundException: {0}")]
44    ResourceNotFound(Box<ErrorDetails>),
45
46    /// 429 (retryable) / rate_limit_exceeded / RATE_LIMIT_EXCEEDED
47    #[error("ThrottlingException: {0}")]
48    Throttling(Box<ErrorDetails>),
49
50    /// 408 / TIMEOUT_ERROR
51    #[error("ModelTimeoutException: {0}")]
52    ModelTimeout(Box<ErrorDetails>),
53
54    /// 5xx model-side error
55    #[error("ModelErrorException: {0}")]
56    ModelError(Box<ErrorDetails>),
57
58    /// Stream-specific error (subtype of ModelError)
59    #[error("ModelStreamErrorException: {0}")]
60    ModelStreamError(Box<ErrorDetails>),
61
62    /// 503/502/504 / ServiceUnavailable
63    #[error("ServiceUnavailableException: {0}")]
64    ServiceUnavailable(Box<ErrorDetails>),
65
66    /// 500 / InternalServerException
67    #[error("InternalServer: {0}")]
68    InternalServer(Box<ErrorDetails>),
69
70    /// No API key could be resolved from config or environment.
71    #[error("MissingApiKeyError: no API key provided; set AUDACITY_API_KEY or pass api_key to Config::builder()")]
72    MissingApiKey,
73
74    /// Client-side failure. Per spec §4 only **network-level** failures are
75    /// retryable; JSON-decode failures and malformed 200 bodies are not.
76    #[error("SdkError: {message}")]
77    Sdk { message: String, retryable: bool },
78}
79
80impl Error {
81    /// A retryable network-level client failure (connect/read errors).
82    pub(crate) fn sdk_network(message: impl Into<String>) -> Self {
83        Error::Sdk {
84            message: message.into(),
85            retryable: true,
86        }
87    }
88
89    /// A non-retryable client failure (decode errors, malformed bodies, internal).
90    pub(crate) fn sdk(message: impl Into<String>) -> Self {
91        Error::Sdk {
92            message: message.into(),
93            retryable: false,
94        }
95    }
96
97    /// A client-side input validation failure (no HTTP exchange happened).
98    pub(crate) fn client_validation(message: impl Into<String>) -> Self {
99        Error::Validation(Box::new(ErrorDetails {
100            message: message.into(),
101            ..Default::default()
102        }))
103    }
104
105    /// A stream-level failure with no HTTP error payload (transport drop, etc.).
106    pub(crate) fn model_stream_error(message: impl Into<String>) -> Self {
107        Error::ModelStreamError(Box::new(ErrorDetails {
108            message: message.into(),
109            ..Default::default()
110        }))
111    }
112
113    /// Returns true if this error is retryable per the spec retry policy.
114    pub fn is_retryable(&self) -> bool {
115        matches!(
116            self,
117            Error::Throttling(_)
118                | Error::ModelTimeout(_)
119                | Error::ServiceUnavailable(_)
120                | Error::InternalServer(_)
121                | Error::Sdk {
122                    retryable: true,
123                    ..
124                }
125        )
126    }
127
128    /// Returns the `Retry-After` value if this error carries one.
129    pub fn retry_after_seconds(&self) -> Option<f64> {
130        match self {
131            Error::Throttling(d)
132            | Error::ModelTimeout(d)
133            | Error::ServiceUnavailable(d)
134            | Error::InternalServer(d)
135            | Error::ModelError(d)
136            | Error::ModelStreamError(d)
137            | Error::Validation(d)
138            | Error::AccessDenied(d)
139            | Error::ServiceQuotaExceeded(d)
140            | Error::ResourceNotFound(d) => d.retry_after_seconds,
141            _ => None,
142        }
143    }
144}
145
146// ── error parsing ────────────────────────────────────────────────────────────
147
148/// Parse the raw response body + HTTP status into an [`Error`].
149pub(crate) fn parse_error(status: u16, body: &str, retry_after: Option<f64>) -> Error {
150    let (message, error_code, request_id, error_details) = extract_error_fields(body);
151
152    let details = Box::new(ErrorDetails {
153        message: message.clone(),
154        status_code: status,
155        error_code: error_code.clone(),
156        request_id,
157        details: error_details,
158        retry_after_seconds: retry_after,
159        raw_body: body.to_owned(),
160    });
161
162    classify_error(status, error_code.as_deref(), details)
163}
164
165/// Map an in-stream error payload (no HTTP status) through the §4 code table
166/// — the Anthropic-shaped envelope's `error.type` hits the same table via the
167/// shape-A extractor — falling back to ModelStreamError for codeless or
168/// unrecognized errors.
169pub(crate) fn parse_stream_error(raw: &str) -> Error {
170    let (message, code, request_id, error_details) = extract_error_fields(raw);
171    let details = Box::new(ErrorDetails {
172        message,
173        status_code: 0,
174        error_code: code.clone(),
175        request_id,
176        details: error_details,
177        retry_after_seconds: None,
178        raw_body: raw.to_owned(),
179    });
180    match code.as_deref() {
181        Some(c) => classify_by_code(c, 0, details).unwrap_or_else(Error::ModelStreamError),
182        None => Error::ModelStreamError(details),
183    }
184}
185
186/// Pull (message, code, request_id, details) out of shape-A or shape-B bodies.
187#[allow(clippy::type_complexity)]
188pub(crate) fn extract_error_fields(
189    body: &str,
190) -> (
191    String,
192    Option<String>,
193    Option<String>,
194    Option<serde_json::Value>,
195) {
196    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
197        return (body.to_owned(), None, None, None);
198    };
199
200    // Shape B: { "success": false, "error": { "code": …, "message": …,
201    //            "request_id": …, "details": … } }
202    if v.get("success").and_then(|s| s.as_bool()) == Some(false) {
203        if let Some(err) = v.get("error") {
204            let message = err
205                .get("message")
206                .and_then(|m| m.as_str())
207                .unwrap_or("unknown error")
208                .to_owned();
209            let code = err.get("code").and_then(|c| c.as_str()).map(str::to_owned);
210            let request_id = err
211                .get("request_id")
212                .and_then(|r| r.as_str())
213                .map(str::to_owned);
214            let details = err.get("details").filter(|d| !d.is_null()).cloned();
215            return (message, code, request_id, details);
216        }
217    }
218
219    // Shape A: { "error": { "message": …, "type": …, "code": … } }
220    if let Some(err) = v.get("error") {
221        let message = err
222            .get("message")
223            .and_then(|m| m.as_str())
224            .unwrap_or("unknown error")
225            .to_owned();
226        // prefer "code" over "type"
227        let code = err
228            .get("code")
229            .and_then(|c| c.as_str())
230            .filter(|s| !s.is_empty())
231            .or_else(|| err.get("type").and_then(|t| t.as_str()))
232            .map(str::to_owned);
233        return (message, code, None, None);
234    }
235
236    (body.to_owned(), None, None, None)
237}
238
239/// Classify into the right Error variant using the spec mapping table.
240pub(crate) fn classify_error(status: u16, code: Option<&str>, details: Box<ErrorDetails>) -> Error {
241    let details = match code {
242        Some(c) => match classify_by_code(c, status, details) {
243            Ok(err) => return err,
244            Err(details) => details, // unrecognized code — fall through to HTTP status
245        },
246        None => details,
247    };
248    classify_by_status(status, details)
249}
250
251/// Map a raw error code (case-insensitive) to a variant per the spec table.
252/// Returns the details back if the code is unrecognized.
253pub(crate) fn classify_by_code(
254    code: &str,
255    status: u16,
256    details: Box<ErrorDetails>,
257) -> Result<Error, Box<ErrorDetails>> {
258    Ok(match code.to_uppercase().as_str() {
259        "INVALID_API_KEY"
260        | "API_KEY_REQUIRED"
261        | "AUTHENTICATION_ERROR"
262        | "AUTHORIZATION_ERROR"
263        | "MODEL_NOT_ALLOWED" => Error::AccessDenied(details),
264
265        "USAGE_CAP_EXCEEDED" | "USAGE_CAP_ERROR" | "BUDGET_EXCEEDED" => {
266            Error::ServiceQuotaExceeded(details)
267        }
268
269        "RATE_LIMIT_EXCEEDED" | "RATE_LIMIT_ERROR" => Error::Throttling(details),
270
271        "INVALID_REQUEST_ERROR" | "VALIDATION_ERROR" => Error::Validation(details),
272
273        "MODEL_NOT_FOUND" => Error::ResourceNotFound(details),
274
275        "TIMEOUT_ERROR" => Error::ModelTimeout(details),
276
277        "STREAM_ERROR" => Error::ModelStreamError(details),
278
279        "UPSTREAM_ERROR" => {
280            if status >= 500 {
281                Error::ServiceUnavailable(details)
282            } else {
283                Error::ModelError(details)
284            }
285        }
286
287        _ => return Err(details),
288    })
289}
290
291/// Map a non-2xx response to an [`Error`] by HTTP status alone (spec §4
292/// status fallback) — used for presigned-URL PUTs, whose response bodies are
293/// storage-provider XML/HTML rather than gateway error payloads.
294pub(crate) fn status_error(status: u16, body: &str) -> Error {
295    let details = Box::new(ErrorDetails {
296        message: if body.is_empty() {
297            format!("HTTP {status}")
298        } else {
299            body.to_owned()
300        },
301        status_code: status,
302        raw_body: body.to_owned(),
303        ..Default::default()
304    });
305    classify_by_status(status, details)
306}
307
308/// HTTP status fallback (when the code is absent/unrecognized).
309fn classify_by_status(status: u16, details: Box<ErrorDetails>) -> Error {
310    match status {
311        400 => Error::Validation(details),
312        401 | 403 => Error::AccessDenied(details),
313        402 => Error::ServiceQuotaExceeded(details),
314        404 => Error::ResourceNotFound(details),
315        408 => Error::ModelTimeout(details),
316        429 => Error::Throttling(details),
317        500 => Error::InternalServer(details),
318        502..=504 => Error::ServiceUnavailable(details),
319        s if (400..500).contains(&s) => Error::Validation(details),
320        s if s >= 500 => Error::InternalServer(details),
321        // Non-200 2xx/3xx — unexpected from this API; not retryable.
322        s => Error::sdk(format!("unexpected HTTP status {s}: {}", details.message)),
323    }
324}
325
326/// Parse `Retry-After` header value (seconds as integer or float).
327pub(crate) fn parse_retry_after(value: &str) -> Option<f64> {
328    value.trim().parse::<f64>().ok()
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn shape_a_authentication_error() {
337        let body = r#"{"error":{"message":"Invalid API key","type":"authentication_error","param":null,"code":"invalid_api_key"}}"#;
338        let err = parse_error(401, body, None);
339        assert!(matches!(err, Error::AccessDenied(_)));
340        if let Error::AccessDenied(d) = err {
341            assert_eq!(d.message, "Invalid API key");
342            assert_eq!(d.error_code.as_deref(), Some("invalid_api_key"));
343            assert!(d.request_id.is_none());
344        }
345    }
346
347    #[test]
348    fn shape_b_model_not_allowed() {
349        let body = r#"{"success":false,"error":{"code":"MODEL_NOT_ALLOWED","message":"Model not allowed","request_id":"req-123","details":{"binding_cap":"team-alpha"}}}"#;
350        let err = parse_error(403, body, None);
351        assert!(matches!(err, Error::AccessDenied(_)));
352        if let Error::AccessDenied(d) = err {
353            assert_eq!(d.request_id.as_deref(), Some("req-123"));
354            // spec §4: shape-B error.details must be carried on the exception
355            let details = d.details.expect("details must be captured from shape B");
356            assert_eq!(details["binding_cap"], "team-alpha");
357        }
358    }
359
360    #[test]
361    fn shape_a_has_no_details() {
362        let body = r#"{"error":{"message":"bad request","type":"invalid_request_error"}}"#;
363        let err = parse_error(400, body, None);
364        if let Error::Validation(d) = err {
365            assert!(d.details.is_none());
366        } else {
367            panic!("expected Validation");
368        }
369    }
370
371    #[test]
372    fn shape_b_budget_exceeded() {
373        let body = r#"{"success":false,"error":{"code":"BUDGET_EXCEEDED","message":"over budget","request_id":null}}"#;
374        let err = parse_error(429, body, None);
375        assert!(matches!(err, Error::ServiceQuotaExceeded(_)));
376        // Must NOT be retryable
377        assert!(!err.is_retryable());
378    }
379
380    #[test]
381    fn rate_limit_is_retryable() {
382        let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error","code":"rate_limit_exceeded"}}"#;
383        let err = parse_error(429, body, Some(2.0));
384        assert!(matches!(err, Error::Throttling(_)));
385        assert!(err.is_retryable());
386        assert_eq!(err.retry_after_seconds(), Some(2.0));
387    }
388
389    #[test]
390    fn http_status_fallback_503() {
391        let err = parse_error(503, "Service Unavailable", None);
392        assert!(matches!(err, Error::ServiceUnavailable(_)));
393        assert!(err.is_retryable());
394    }
395
396    #[test]
397    fn upstream_error_5xx_is_service_unavailable() {
398        let body = r#"{"error":{"message":"upstream","code":"UPSTREAM_ERROR"}}"#;
399        let err = parse_error(502, body, None);
400        assert!(matches!(err, Error::ServiceUnavailable(_)));
401    }
402
403    #[test]
404    fn upstream_error_4xx_is_model_error() {
405        let body = r#"{"error":{"message":"upstream","code":"UPSTREAM_ERROR"}}"#;
406        let err = parse_error(400, body, None);
407        assert!(matches!(err, Error::ModelError(_)));
408    }
409
410    #[test]
411    fn parse_retry_after_value() {
412        assert_eq!(parse_retry_after("5"), Some(5.0));
413        assert_eq!(parse_retry_after("2.5"), Some(2.5));
414        assert_eq!(parse_retry_after("bad"), None);
415    }
416}