Skip to main content

wraith_api/
error.rs

1use std::env::VarError;
2use std::fmt::{Display, Formatter};
3use std::time::Duration;
4
5#[derive(Debug)]
6pub enum ApiError {
7    MissingCredentials {
8        provider: &'static str,
9        env_vars: &'static [&'static str],
10    },
11    ExpiredOAuthToken,
12    Auth(String),
13    InvalidApiKeyEnv(VarError),
14    Http(reqwest::Error),
15    Io(std::io::Error),
16    Json(serde_json::Error),
17    Api {
18        status: reqwest::StatusCode,
19        error_type: Option<String>,
20        message: Option<String>,
21        body: String,
22        retryable: bool,
23    },
24    RetriesExhausted {
25        attempts: u32,
26        last_error: Box<ApiError>,
27    },
28    InvalidSseFrame(&'static str),
29    BackoffOverflow {
30        attempt: u32,
31        base_delay: Duration,
32    },
33}
34
35impl ApiError {
36    #[must_use]
37    pub const fn missing_credentials(
38        provider: &'static str,
39        env_vars: &'static [&'static str],
40    ) -> Self {
41        Self::MissingCredentials { provider, env_vars }
42    }
43
44    #[must_use]
45    pub fn is_retryable(&self) -> bool {
46        match self {
47            Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
48            Self::Api { retryable, .. } => *retryable,
49            Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
50            Self::MissingCredentials { .. }
51            | Self::ExpiredOAuthToken
52            | Self::Auth(_)
53            | Self::InvalidApiKeyEnv(_)
54            | Self::Io(_)
55            | Self::Json(_)
56            | Self::InvalidSseFrame(_)
57            | Self::BackoffOverflow { .. } => false,
58        }
59    }
60}
61
62impl Display for ApiError {
63    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::MissingCredentials { provider: _, env_vars } => {
66                if env_vars.contains(&"ANTHROPIC_API_KEY") {
67                    write!(
68                        f,
69                        "No API key found. Set ANTHROPIC_API_KEY or run `wraith login`.\nFor Anthropic: https://console.anthropic.com/"
70                    )
71                } else if env_vars.iter().any(|var| var.contains("OPENAI")) {
72                    write!(
73                        f,
74                        "No API key found. Set OPENAI_API_KEY to get started.\nFor OpenAI: https://platform.openai.com/api-keys"
75                    )
76                } else if env_vars.contains(&"GEMINI_API_KEY") {
77                    write!(
78                        f,
79                        "No API key found. Set GEMINI_API_KEY to get started.\nFor Gemini: https://aistudio.google.com/app/apikey"
80                    )
81                } else if env_vars.contains(&"OPENROUTER_API_KEY") {
82                    write!(
83                        f,
84                        "No API key found. Set OPENROUTER_API_KEY to get started.\nFor OpenRouter: https://openrouter.ai/keys"
85                    )
86                } else {
87                    write!(
88                        f,
89                        "missing credentials; export {} to get started",
90                        env_vars.join(" or ")
91                    )
92                }
93            },
94            Self::ExpiredOAuthToken => {
95                write!(
96                    f,
97                    "saved OAuth token is expired and no refresh token is available"
98                )
99            }
100            Self::Auth(message) => write!(f, "auth error: {message}"),
101            Self::InvalidApiKeyEnv(error) => {
102                write!(f, "failed to read credential environment variable: {error}")
103            }
104            Self::Http(error) => write!(f, "http error: {error}"),
105            Self::Io(error) => write!(f, "io error: {error}"),
106            Self::Json(error) => write!(f, "json error: {error}"),
107            Self::Api {
108                status,
109                error_type,
110                message,
111                body,
112                ..
113            } => match (error_type, message) {
114                (Some(error_type), Some(message)) => {
115                    write!(f, "api returned {status} ({error_type}): {message}")
116                }
117                _ => write!(f, "api returned {status}: {body}"),
118            },
119            Self::RetriesExhausted {
120                attempts,
121                last_error,
122            } => write!(f, "api failed after {attempts} attempts: {last_error}"),
123            Self::InvalidSseFrame(message) => write!(f, "invalid sse frame: {message}"),
124            Self::BackoffOverflow {
125                attempt,
126                base_delay,
127            } => write!(
128                f,
129                "retry backoff overflowed on attempt {attempt} with base delay {base_delay:?}"
130            ),
131        }
132    }
133}
134
135impl std::error::Error for ApiError {}
136
137impl From<reqwest::Error> for ApiError {
138    fn from(value: reqwest::Error) -> Self {
139        Self::Http(value)
140    }
141}
142
143impl From<std::io::Error> for ApiError {
144    fn from(value: std::io::Error) -> Self {
145        Self::Io(value)
146    }
147}
148
149impl From<serde_json::Error> for ApiError {
150    fn from(value: serde_json::Error) -> Self {
151        Self::Json(value)
152    }
153}
154
155impl From<VarError> for ApiError {
156    fn from(value: VarError) -> Self {
157        Self::InvalidApiKeyEnv(value)
158    }
159}