use crate::http::HttpError;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpErrorInfo {
pub status: u16,
pub url: String,
pub message: String,
pub body_snippet: Option<String>,
}
impl std::fmt::Display for HttpErrorInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "HTTP {} for {}: {}", self.status, self.url, self.message)?;
if let Some(ref snippet) = self.body_snippet {
let truncated: String = snippet.chars().take(200).collect();
write!(f, " | body[0:200]={}", truncated)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageLimitInfo {
pub limit_type: String,
pub api: String,
pub current: f64,
pub limit: f64,
pub tier: String,
pub retry_after_seconds: Option<i64>,
pub upgrade_url: String,
}
impl Default for UsageLimitInfo {
fn default() -> Self {
Self {
limit_type: String::new(),
api: String::new(),
current: 0.0,
limit: 0.0,
tier: "free".to_string(),
retry_after_seconds: None,
upgrade_url: "https://usesynth.ai/pricing".to_string(),
}
}
}
impl UsageLimitInfo {
pub fn from_http_429(api: &str, detail: &crate::http::HttpErrorDetail) -> Self {
let mut info = Self {
limit_type: "rate_limit".to_string(),
api: api.to_string(),
current: 0.0,
limit: 0.0,
tier: "unavailable".to_string(),
retry_after_seconds: None,
upgrade_url: "https://usesynth.ai/pricing".to_string(),
};
if let Some(ref snippet) = detail.body_snippet {
if let Ok(body) = serde_json::from_str::<serde_json::Value>(snippet) {
let payload = body.get("detail").unwrap_or(&body);
if let Some(obj) = payload.as_object() {
if let Some(value) = obj.get("error").and_then(|v| v.as_str()) {
info.limit_type = value.to_string();
} else if let Some(value) = obj.get("code").and_then(|v| v.as_str()) {
info.limit_type = value.to_string();
}
if let Some(value) = obj.get("limit").and_then(|v| v.as_f64()) {
info.limit = value;
}
if let Some(value) = obj
.get("current")
.and_then(|v| v.as_f64())
.or_else(|| obj.get("current_active").and_then(|v| v.as_f64()))
{
info.current = value;
}
if let Some(value) = obj.get("tier").and_then(|v| v.as_str()) {
info.tier = value.to_string();
}
if let Some(value) = obj.get("retry_after_seconds").and_then(|v| v.as_i64()) {
info.retry_after_seconds = Some(value);
}
} else if let Some(detail_msg) = payload.as_str() {
info.limit_type = detail_msg.to_string();
}
}
}
if info.current == 0.0 && info.limit > 0.0 {
info.current = info.limit;
}
info
}
}
impl std::fmt::Display for UsageLimitInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Rate limit exceeded: {} ({}/{}) for tier '{}'. Upgrade at {}",
self.limit_type, self.current, self.limit, self.tier, self.upgrade_url
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobErrorInfo {
pub job_id: String,
pub message: String,
pub code: Option<String>,
}
impl std::fmt::Display for JobErrorInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Job {} failed: {}", self.job_id, self.message)?;
if let Some(ref code) = self.code {
write!(f, " (code: {})", code)?;
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum CoreError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("url parse error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("{0}")]
HttpResponse(HttpErrorInfo),
#[error("authentication failed: {0}")]
Authentication(String),
#[error("validation error: {0}")]
Validation(String),
#[error("{0}")]
UsageLimit(UsageLimitInfo),
#[error("{0}")]
Job(JobErrorInfo),
#[error("config error: {0}")]
Config(String),
#[error("timeout: {0}")]
Timeout(String),
#[error("protocol error: {0}")]
Protocol(String),
#[error("internal error: {0}")]
Internal(String),
}
impl CoreError {
pub fn http_response(status: u16, url: &str, message: &str, body: Option<&str>) -> Self {
CoreError::HttpResponse(HttpErrorInfo {
status,
url: url.to_string(),
message: message.to_string(),
body_snippet: body.map(|s| s.chars().take(200).collect()),
})
}
pub fn auth(message: impl Into<String>) -> Self {
CoreError::Authentication(message.into())
}
pub fn validation(message: impl Into<String>) -> Self {
CoreError::Validation(message.into())
}
pub fn usage_limit(
limit_type: &str,
api: &str,
current: f64,
limit: f64,
tier: &str,
retry_after: Option<i64>,
) -> Self {
CoreError::UsageLimit(UsageLimitInfo {
limit_type: limit_type.to_string(),
api: api.to_string(),
current,
limit,
tier: tier.to_string(),
retry_after_seconds: retry_after,
upgrade_url: "https://usesynth.ai/pricing".to_string(),
})
}
pub fn job(job_id: &str, message: &str, code: Option<&str>) -> Self {
CoreError::Job(JobErrorInfo {
job_id: job_id.to_string(),
message: message.to_string(),
code: code.map(String::from),
})
}
pub fn timeout(message: impl Into<String>) -> Self {
CoreError::Timeout(message.into())
}
pub fn config(message: impl Into<String>) -> Self {
CoreError::Config(message.into())
}
pub fn is_auth_error(&self) -> bool {
matches!(self, CoreError::Authentication(_))
}
pub fn is_rate_limit(&self) -> bool {
matches!(self, CoreError::UsageLimit(_))
}
pub fn is_retryable(&self) -> bool {
match self {
CoreError::HttpResponse(info) => info.status >= 500,
CoreError::Http(_) => true,
CoreError::Timeout(_) => true,
_ => false,
}
}
pub fn http_status(&self) -> Option<u16> {
match self {
CoreError::HttpResponse(info) => Some(info.status),
CoreError::Http(e) => e.status().map(|s| s.as_u16()),
_ => None,
}
}
}
impl From<HttpError> for CoreError {
fn from(err: HttpError) -> Self {
match err {
HttpError::Request(e) => CoreError::Http(e),
HttpError::Response(detail) => CoreError::HttpResponse(HttpErrorInfo {
status: detail.status,
url: detail.url,
message: detail.message,
body_snippet: detail.body_snippet,
}),
HttpError::InvalidUrl(msg) => CoreError::InvalidInput(msg),
HttpError::JsonParse(msg) => CoreError::Protocol(msg),
}
}
}
pub type CoreResult<T> = Result<T, CoreError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_error_display() {
let err = CoreError::http_response(404, "https://api.example.com/test", "not found", None);
let msg = format!("{}", err);
assert!(msg.contains("404"));
assert!(msg.contains("api.example.com"));
}
#[test]
fn test_usage_limit_display() {
let err = CoreError::usage_limit(
"inference_tokens_per_day",
"inference",
10000.0,
5000.0,
"free",
Some(3600),
);
let msg = format!("{}", err);
assert!(msg.contains("inference_tokens_per_day"));
assert!(msg.contains("free"));
}
#[test]
fn test_retryable() {
let err_500 =
CoreError::http_response(500, "https://api.example.com", "server error", None);
assert!(err_500.is_retryable());
let err_404 = CoreError::http_response(404, "https://api.example.com", "not found", None);
assert!(!err_404.is_retryable());
let err_auth = CoreError::auth("invalid key");
assert!(!err_auth.is_retryable());
}
#[test]
fn test_http_status() {
let err = CoreError::http_response(403, "https://api.example.com", "forbidden", None);
assert_eq!(err.http_status(), Some(403));
let err_auth = CoreError::auth("invalid key");
assert_eq!(err_auth.http_status(), None);
}
}