1use crate::http::HttpError;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct HttpErrorInfo {
13 pub status: u16,
15 pub url: String,
17 pub message: String,
19 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#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct UsageLimitInfo {
39 pub limit_type: String,
41 pub api: String,
43 pub current: f64,
45 pub limit: f64,
47 pub tier: String,
49 pub retry_after_seconds: Option<i64>,
51 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 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 info.limit_type = detail_msg.to_string();
115 }
116 }
117 }
118
119 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#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct JobErrorInfo {
140 pub job_id: String,
142 pub message: String,
144 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#[derive(Debug, Error)]
163pub enum CoreError {
164 #[error("invalid input: {0}")]
166 InvalidInput(String),
167
168 #[error("url parse error: {0}")]
170 UrlParse(#[from] url::ParseError),
171
172 #[error("http error: {0}")]
174 Http(#[from] reqwest::Error),
175
176 #[error("{0}")]
178 HttpResponse(HttpErrorInfo),
179
180 #[error("authentication failed: {0}")]
182 Authentication(String),
183
184 #[error("validation error: {0}")]
186 Validation(String),
187
188 #[error("{0}")]
190 UsageLimit(UsageLimitInfo),
191
192 #[error("{0}")]
194 Job(JobErrorInfo),
195
196 #[error("config error: {0}")]
198 Config(String),
199
200 #[error("timeout: {0}")]
202 Timeout(String),
203
204 #[error("protocol error: {0}")]
206 Protocol(String),
207
208 #[error("internal error: {0}")]
210 Internal(String),
211}
212
213impl CoreError {
214 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 pub fn auth(message: impl Into<String>) -> Self {
226 CoreError::Authentication(message.into())
227 }
228
229 pub fn validation(message: impl Into<String>) -> Self {
231 CoreError::Validation(message.into())
232 }
233
234 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 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 pub fn timeout(message: impl Into<String>) -> Self {
265 CoreError::Timeout(message.into())
266 }
267
268 pub fn config(message: impl Into<String>) -> Self {
270 CoreError::Config(message.into())
271 }
272
273 pub fn is_auth_error(&self) -> bool {
275 matches!(self, CoreError::Authentication(_))
276 }
277
278 pub fn is_rate_limit(&self) -> bool {
280 matches!(self, CoreError::UsageLimit(_))
281 }
282
283 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 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
319pub 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}