Skip to main content

synth_ai_core/
errors.rs

1//! Core error types for Synth SDK.
2//!
3//! This module provides shared error types that can be used across
4//! TypeScript, Python, and Go SDKs via bindings.
5
6use crate::http::HttpError;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// HTTP error details for API responses.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct HttpErrorInfo {
13    /// HTTP status code (e.g., 404, 500)
14    pub status: u16,
15    /// Request URL
16    pub url: String,
17    /// Error message
18    pub message: String,
19    /// First 200 chars of response body (for debugging)
20    pub body_snippet: Option<String>,
21}
22
23impl std::fmt::Display for HttpErrorInfo {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "HTTP {} for {}: {}", self.status, self.url, self.message)?;
26        if let Some(ref snippet) = self.body_snippet {
27            let truncated: String = snippet.chars().take(200).collect();
28            write!(f, " | body[0:200]={}", truncated)?;
29        }
30        Ok(())
31    }
32}
33
34/// Usage limit error details.
35///
36/// Raised when an organization's rate limit is exceeded.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct UsageLimitInfo {
39    /// Type of limit exceeded (e.g., "inference_tokens_per_day")
40    pub limit_type: String,
41    /// API that hit the limit (e.g., "inference", "verifiers", "prompt_opt")
42    pub api: String,
43    /// Current usage value
44    pub current: f64,
45    /// The limit value
46    pub limit: f64,
47    /// Organization's tier (e.g., "free", "starter", "growth")
48    pub tier: String,
49    /// Seconds until the limit resets (if available)
50    pub retry_after_seconds: Option<i64>,
51    /// URL to upgrade tier
52    pub upgrade_url: String,
53}
54
55impl Default for UsageLimitInfo {
56    fn default() -> Self {
57        Self {
58            limit_type: String::new(),
59            api: String::new(),
60            current: 0.0,
61            limit: 0.0,
62            tier: "free".to_string(),
63            retry_after_seconds: None,
64            upgrade_url: "https://usesynth.ai/pricing".to_string(),
65        }
66    }
67}
68
69impl UsageLimitInfo {
70    /// Build a usage limit payload from an HTTP 429 response detail.
71    ///
72    /// This parser supports FastAPI-style payloads where fields may be nested
73    /// under `detail`, and gracefully handles string-only `detail` values.
74    pub fn from_http_429(api: &str, detail: &crate::http::HttpErrorDetail) -> Self {
75        let mut info = Self {
76            limit_type: "rate_limit".to_string(),
77            api: api.to_string(),
78            current: 0.0,
79            limit: 0.0,
80            tier: "unavailable".to_string(),
81            retry_after_seconds: None,
82            upgrade_url: "https://usesynth.ai/pricing".to_string(),
83        };
84
85        if let Some(ref snippet) = detail.body_snippet {
86            if let Ok(body) = serde_json::from_str::<serde_json::Value>(snippet) {
87                let payload = body.get("detail").unwrap_or(&body);
88
89                if let Some(obj) = payload.as_object() {
90                    if let Some(value) = obj.get("error").and_then(|v| v.as_str()) {
91                        info.limit_type = value.to_string();
92                    } else if let Some(value) = obj.get("code").and_then(|v| v.as_str()) {
93                        info.limit_type = value.to_string();
94                    }
95
96                    if let Some(value) = obj.get("limit").and_then(|v| v.as_f64()) {
97                        info.limit = value;
98                    }
99                    if let Some(value) = obj
100                        .get("current")
101                        .and_then(|v| v.as_f64())
102                        .or_else(|| obj.get("current_active").and_then(|v| v.as_f64()))
103                    {
104                        info.current = value;
105                    }
106                    if let Some(value) = obj.get("tier").and_then(|v| v.as_str()) {
107                        info.tier = value.to_string();
108                    }
109                    if let Some(value) = obj.get("retry_after_seconds").and_then(|v| v.as_i64()) {
110                        info.retry_after_seconds = Some(value);
111                    }
112                } else if let Some(detail_msg) = payload.as_str() {
113                    // Handles {"detail": "Rate limit exceeded ..."}
114                    info.limit_type = detail_msg.to_string();
115                }
116            }
117        }
118
119        // If backend gave only cap, report "at cap" instead of misleading 0.
120        if info.current == 0.0 && info.limit > 0.0 {
121            info.current = info.limit;
122        }
123        info
124    }
125}
126
127impl std::fmt::Display for UsageLimitInfo {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(
130            f,
131            "Rate limit exceeded: {} ({}/{}) for tier '{}'. Upgrade at {}",
132            self.limit_type, self.current, self.limit, self.tier, self.upgrade_url
133        )
134    }
135}
136
137/// Job error details.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct JobErrorInfo {
140    /// Job ID that failed
141    pub job_id: String,
142    /// Error message
143    pub message: String,
144    /// Optional error code
145    pub code: Option<String>,
146}
147
148impl std::fmt::Display for JobErrorInfo {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        write!(f, "Job {} failed: {}", self.job_id, self.message)?;
151        if let Some(ref code) = self.code {
152            write!(f, " (code: {})", code)?;
153        }
154        Ok(())
155    }
156}
157
158/// Unified error enum for all Synth core errors.
159///
160/// This provides a single error type that can be mapped to language-specific
161/// exceptions in Python, TypeScript, etc.
162#[derive(Debug, Error)]
163pub enum CoreError {
164    /// Invalid input provided
165    #[error("invalid input: {0}")]
166    InvalidInput(String),
167
168    /// URL parsing failed
169    #[error("url parse error: {0}")]
170    UrlParse(#[from] url::ParseError),
171
172    /// HTTP request failed (network layer)
173    #[error("http error: {0}")]
174    Http(#[from] reqwest::Error),
175
176    /// HTTP response error (4xx/5xx)
177    #[error("{0}")]
178    HttpResponse(HttpErrorInfo),
179
180    /// Authentication failed
181    #[error("authentication failed: {0}")]
182    Authentication(String),
183
184    /// Validation error
185    #[error("validation error: {0}")]
186    Validation(String),
187
188    /// Usage/rate limit exceeded
189    #[error("{0}")]
190    UsageLimit(UsageLimitInfo),
191
192    /// Job operation failed
193    #[error("{0}")]
194    Job(JobErrorInfo),
195
196    /// Configuration error
197    #[error("config error: {0}")]
198    Config(String),
199
200    /// Timeout error
201    #[error("timeout: {0}")]
202    Timeout(String),
203
204    /// Protocol/wire format error
205    #[error("protocol error: {0}")]
206    Protocol(String),
207
208    /// Generic internal error
209    #[error("internal error: {0}")]
210    Internal(String),
211}
212
213impl CoreError {
214    /// Create an HTTP response error.
215    pub fn http_response(status: u16, url: &str, message: &str, body: Option<&str>) -> Self {
216        CoreError::HttpResponse(HttpErrorInfo {
217            status,
218            url: url.to_string(),
219            message: message.to_string(),
220            body_snippet: body.map(|s| s.chars().take(200).collect()),
221        })
222    }
223
224    /// Create an authentication error.
225    pub fn auth(message: impl Into<String>) -> Self {
226        CoreError::Authentication(message.into())
227    }
228
229    /// Create a validation error.
230    pub fn validation(message: impl Into<String>) -> Self {
231        CoreError::Validation(message.into())
232    }
233
234    /// Create a usage limit error.
235    pub fn usage_limit(
236        limit_type: &str,
237        api: &str,
238        current: f64,
239        limit: f64,
240        tier: &str,
241        retry_after: Option<i64>,
242    ) -> Self {
243        CoreError::UsageLimit(UsageLimitInfo {
244            limit_type: limit_type.to_string(),
245            api: api.to_string(),
246            current,
247            limit,
248            tier: tier.to_string(),
249            retry_after_seconds: retry_after,
250            upgrade_url: "https://usesynth.ai/pricing".to_string(),
251        })
252    }
253
254    /// Create a job error.
255    pub fn job(job_id: &str, message: &str, code: Option<&str>) -> Self {
256        CoreError::Job(JobErrorInfo {
257            job_id: job_id.to_string(),
258            message: message.to_string(),
259            code: code.map(String::from),
260        })
261    }
262
263    /// Create a timeout error.
264    pub fn timeout(message: impl Into<String>) -> Self {
265        CoreError::Timeout(message.into())
266    }
267
268    /// Create a config error.
269    pub fn config(message: impl Into<String>) -> Self {
270        CoreError::Config(message.into())
271    }
272
273    /// Check if this is an authentication error.
274    pub fn is_auth_error(&self) -> bool {
275        matches!(self, CoreError::Authentication(_))
276    }
277
278    /// Check if this is a rate limit error.
279    pub fn is_rate_limit(&self) -> bool {
280        matches!(self, CoreError::UsageLimit(_))
281    }
282
283    /// Check if this is a retryable error (5xx, timeout, network).
284    pub fn is_retryable(&self) -> bool {
285        match self {
286            CoreError::HttpResponse(info) => info.status >= 500,
287            CoreError::Http(_) => true,
288            CoreError::Timeout(_) => true,
289            _ => false,
290        }
291    }
292
293    /// Get HTTP status code if this is an HTTP error.
294    pub fn http_status(&self) -> Option<u16> {
295        match self {
296            CoreError::HttpResponse(info) => Some(info.status),
297            CoreError::Http(e) => e.status().map(|s| s.as_u16()),
298            _ => None,
299        }
300    }
301}
302
303impl From<HttpError> for CoreError {
304    fn from(err: HttpError) -> Self {
305        match err {
306            HttpError::Request(e) => CoreError::Http(e),
307            HttpError::Response(detail) => CoreError::HttpResponse(HttpErrorInfo {
308                status: detail.status,
309                url: detail.url,
310                message: detail.message,
311                body_snippet: detail.body_snippet,
312            }),
313            HttpError::InvalidUrl(msg) => CoreError::InvalidInput(msg),
314            HttpError::JsonParse(msg) => CoreError::Protocol(msg),
315        }
316    }
317}
318
319/// Result type alias using CoreError.
320pub type CoreResult<T> = Result<T, CoreError>;
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_http_error_display() {
328        let err = CoreError::http_response(404, "https://api.example.com/test", "not found", None);
329        let msg = format!("{}", err);
330        assert!(msg.contains("404"));
331        assert!(msg.contains("api.example.com"));
332    }
333
334    #[test]
335    fn test_usage_limit_display() {
336        let err = CoreError::usage_limit(
337            "inference_tokens_per_day",
338            "inference",
339            10000.0,
340            5000.0,
341            "free",
342            Some(3600),
343        );
344        let msg = format!("{}", err);
345        assert!(msg.contains("inference_tokens_per_day"));
346        assert!(msg.contains("free"));
347    }
348
349    #[test]
350    fn test_retryable() {
351        let err_500 =
352            CoreError::http_response(500, "https://api.example.com", "server error", None);
353        assert!(err_500.is_retryable());
354
355        let err_404 = CoreError::http_response(404, "https://api.example.com", "not found", None);
356        assert!(!err_404.is_retryable());
357
358        let err_auth = CoreError::auth("invalid key");
359        assert!(!err_auth.is_retryable());
360    }
361
362    #[test]
363    fn test_http_status() {
364        let err = CoreError::http_response(403, "https://api.example.com", "forbidden", None);
365        assert_eq!(err.http_status(), Some(403));
366
367        let err_auth = CoreError::auth("invalid key");
368        assert_eq!(err_auth.http_status(), None);
369    }
370}