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}