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 std::fmt::Display for UsageLimitInfo {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "Rate limit exceeded: {} ({}/{}) for tier '{}'. Upgrade at {}",
74            self.limit_type, self.current, self.limit, self.tier, self.upgrade_url
75        )
76    }
77}
78
79/// Job error details.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct JobErrorInfo {
82    /// Job ID that failed
83    pub job_id: String,
84    /// Error message
85    pub message: String,
86    /// Optional error code
87    pub code: Option<String>,
88}
89
90impl std::fmt::Display for JobErrorInfo {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "Job {} failed: {}", self.job_id, self.message)?;
93        if let Some(ref code) = self.code {
94            write!(f, " (code: {})", code)?;
95        }
96        Ok(())
97    }
98}
99
100/// Unified error enum for all Synth core errors.
101///
102/// This provides a single error type that can be mapped to language-specific
103/// exceptions in Python, TypeScript, etc.
104#[derive(Debug, Error)]
105pub enum CoreError {
106    /// Invalid input provided
107    #[error("invalid input: {0}")]
108    InvalidInput(String),
109
110    /// URL parsing failed
111    #[error("url parse error: {0}")]
112    UrlParse(#[from] url::ParseError),
113
114    /// HTTP request failed (network layer)
115    #[error("http error: {0}")]
116    Http(#[from] reqwest::Error),
117
118    /// HTTP response error (4xx/5xx)
119    #[error("{0}")]
120    HttpResponse(HttpErrorInfo),
121
122    /// Authentication failed
123    #[error("authentication failed: {0}")]
124    Authentication(String),
125
126    /// Validation error
127    #[error("validation error: {0}")]
128    Validation(String),
129
130    /// Usage/rate limit exceeded
131    #[error("{0}")]
132    UsageLimit(UsageLimitInfo),
133
134    /// Job operation failed
135    #[error("{0}")]
136    Job(JobErrorInfo),
137
138    /// Configuration error
139    #[error("config error: {0}")]
140    Config(String),
141
142    /// Timeout error
143    #[error("timeout: {0}")]
144    Timeout(String),
145
146    /// Protocol/wire format error
147    #[error("protocol error: {0}")]
148    Protocol(String),
149
150    /// Generic internal error
151    #[error("internal error: {0}")]
152    Internal(String),
153}
154
155impl CoreError {
156    /// Create an HTTP response error.
157    pub fn http_response(status: u16, url: &str, message: &str, body: Option<&str>) -> Self {
158        CoreError::HttpResponse(HttpErrorInfo {
159            status,
160            url: url.to_string(),
161            message: message.to_string(),
162            body_snippet: body.map(|s| s.chars().take(200).collect()),
163        })
164    }
165
166    /// Create an authentication error.
167    pub fn auth(message: impl Into<String>) -> Self {
168        CoreError::Authentication(message.into())
169    }
170
171    /// Create a validation error.
172    pub fn validation(message: impl Into<String>) -> Self {
173        CoreError::Validation(message.into())
174    }
175
176    /// Create a usage limit error.
177    pub fn usage_limit(
178        limit_type: &str,
179        api: &str,
180        current: f64,
181        limit: f64,
182        tier: &str,
183        retry_after: Option<i64>,
184    ) -> Self {
185        CoreError::UsageLimit(UsageLimitInfo {
186            limit_type: limit_type.to_string(),
187            api: api.to_string(),
188            current,
189            limit,
190            tier: tier.to_string(),
191            retry_after_seconds: retry_after,
192            upgrade_url: "https://usesynth.ai/pricing".to_string(),
193        })
194    }
195
196    /// Create a job error.
197    pub fn job(job_id: &str, message: &str, code: Option<&str>) -> Self {
198        CoreError::Job(JobErrorInfo {
199            job_id: job_id.to_string(),
200            message: message.to_string(),
201            code: code.map(String::from),
202        })
203    }
204
205    /// Create a timeout error.
206    pub fn timeout(message: impl Into<String>) -> Self {
207        CoreError::Timeout(message.into())
208    }
209
210    /// Create a config error.
211    pub fn config(message: impl Into<String>) -> Self {
212        CoreError::Config(message.into())
213    }
214
215    /// Check if this is an authentication error.
216    pub fn is_auth_error(&self) -> bool {
217        matches!(self, CoreError::Authentication(_))
218    }
219
220    /// Check if this is a rate limit error.
221    pub fn is_rate_limit(&self) -> bool {
222        matches!(self, CoreError::UsageLimit(_))
223    }
224
225    /// Check if this is a retryable error (5xx, timeout, network).
226    pub fn is_retryable(&self) -> bool {
227        match self {
228            CoreError::HttpResponse(info) => info.status >= 500,
229            CoreError::Http(_) => true,
230            CoreError::Timeout(_) => true,
231            _ => false,
232        }
233    }
234
235    /// Get HTTP status code if this is an HTTP error.
236    pub fn http_status(&self) -> Option<u16> {
237        match self {
238            CoreError::HttpResponse(info) => Some(info.status),
239            CoreError::Http(e) => e.status().map(|s| s.as_u16()),
240            _ => None,
241        }
242    }
243}
244
245impl From<HttpError> for CoreError {
246    fn from(err: HttpError) -> Self {
247        match err {
248            HttpError::Request(e) => CoreError::Http(e),
249            HttpError::Response(detail) => CoreError::HttpResponse(HttpErrorInfo {
250                status: detail.status,
251                url: detail.url,
252                message: detail.message,
253                body_snippet: detail.body_snippet,
254            }),
255            HttpError::InvalidUrl(msg) => CoreError::InvalidInput(msg),
256            HttpError::JsonParse(msg) => CoreError::Protocol(msg),
257        }
258    }
259}
260
261/// Result type alias using CoreError.
262pub type CoreResult<T> = Result<T, CoreError>;
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_http_error_display() {
270        let err = CoreError::http_response(404, "https://api.example.com/test", "not found", None);
271        let msg = format!("{}", err);
272        assert!(msg.contains("404"));
273        assert!(msg.contains("api.example.com"));
274    }
275
276    #[test]
277    fn test_usage_limit_display() {
278        let err = CoreError::usage_limit(
279            "inference_tokens_per_day",
280            "inference",
281            10000.0,
282            5000.0,
283            "free",
284            Some(3600),
285        );
286        let msg = format!("{}", err);
287        assert!(msg.contains("inference_tokens_per_day"));
288        assert!(msg.contains("free"));
289    }
290
291    #[test]
292    fn test_retryable() {
293        let err_500 =
294            CoreError::http_response(500, "https://api.example.com", "server error", None);
295        assert!(err_500.is_retryable());
296
297        let err_404 = CoreError::http_response(404, "https://api.example.com", "not found", None);
298        assert!(!err_404.is_retryable());
299
300        let err_auth = CoreError::auth("invalid key");
301        assert!(!err_auth.is_retryable());
302    }
303
304    #[test]
305    fn test_http_status() {
306        let err = CoreError::http_response(403, "https://api.example.com", "forbidden", None);
307        assert_eq!(err.http_status(), Some(403));
308
309        let err_auth = CoreError::auth("invalid key");
310        assert_eq!(err_auth.http_status(), None);
311    }
312}