1use std::time::{Duration, SystemTime, SystemTimeError};
2
3use std::fmt;
4use thiserror::Error;
5
6pub type Result<T> = std::result::Result<T, Error>;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct HttpError {
12 pub status: u16,
14 pub message: Option<String>,
16 pub request_id: Option<String>,
18 pub body_snippet: Option<String>,
20}
21
22impl fmt::Display for HttpError {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 write!(f, "HTTP {}", self.status)?;
25 if let Some(message) = &self.message {
26 write!(f, ": {message}")?;
27 }
28 if let Some(request_id) = &self.request_id {
29 write!(f, " [request-id: {request_id}]")?;
30 }
31 Ok(())
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TransportError {
38 pub status: Option<u16>,
40 pub message: Option<String>,
42 pub request_id: Option<String>,
44 pub body_snippet: Option<String>,
46 pub retry_after: Option<Duration>,
48 pub retryable: bool,
50 pub code: &'static str,
52 pub method: Option<String>,
54 pub uri: Option<String>,
56 pub timeout_phase: Option<&'static str>,
58 pub transport_kind: Option<&'static str>,
60}
61
62impl fmt::Display for TransportError {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 if let Some(status) = self.status {
65 write!(f, "HTTP {status}")?;
66 } else {
67 write!(f, "request failed")?;
68 }
69 if let Some(message) = &self.message {
70 write!(f, ": {message}")?;
71 }
72 if let Some(request_id) = &self.request_id {
73 write!(f, " [request-id: {request_id}]")?;
74 }
75 Ok(())
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum ErrorKind {
83 Auth,
85 NotFound,
87 Conflict,
89 RateLimited,
91 Api,
93 Transport,
95 Serialization,
97 Timestamp,
99 Signature,
101 InvalidConfig,
103}
104
105#[derive(Debug, Error)]
106#[non_exhaustive]
107pub enum Error {
109 #[error("API error (code={code}): {message}")]
111 Api {
112 code: i64,
114 message: String,
116 request_id: Option<String>,
118 body_snippet: Option<String>,
120 },
121
122 #[error("Authentication failed: {0}")]
124 Auth(HttpError),
125
126 #[error("Resource not found: {0}")]
128 NotFound(HttpError),
129
130 #[error("Resource conflict: {0}")]
132 Conflict(HttpError),
133
134 #[error("Rate limited: {error}")]
136 RateLimited {
137 error: HttpError,
139 retry_after: Option<Duration>,
141 },
142
143 #[error("HTTP transport error: {0}")]
145 Transport(Box<TransportError>),
146
147 #[error("Serialization error: {0}")]
149 Serialization(#[from] serde_json::Error),
150
151 #[error("Timestamp generation failed: {0}")]
153 Timestamp(#[from] SystemTimeError),
154
155 #[error("Signature generation failed")]
157 Signature,
158
159 #[error("Invalid configuration: {message}")]
161 InvalidConfig {
162 message: String,
164 source: Option<Box<dyn std::error::Error + Send + Sync>>,
166 },
167}
168
169fn reqx_timeout_phase_name(error: &reqx::Error) -> Option<&'static str> {
170 match error {
171 reqx::Error::Timeout { phase, .. } => Some(match phase {
172 reqx::TimeoutPhase::Transport => "transport",
173 reqx::TimeoutPhase::ResponseBody => "response_body",
174 }),
175 _ => None,
176 }
177}
178
179fn reqx_transport_kind_name(error: &reqx::Error) -> Option<&'static str> {
180 match error {
181 reqx::Error::Transport { kind, .. } => Some(match kind {
182 reqx::TransportErrorKind::Dns => "dns",
183 reqx::TransportErrorKind::Connect => "connect",
184 reqx::TransportErrorKind::Tls => "tls",
185 reqx::TransportErrorKind::Read => "read",
186 reqx::TransportErrorKind::Other => "other",
187 }),
188 _ => None,
189 }
190}
191
192impl From<reqx::Error> for Error {
193 fn from(source: reqx::Error) -> Self {
194 let code = source.code().as_str();
195 let status = source.status_code();
196 let request_id = source.request_id().map(ToOwned::to_owned);
197 let retry_after = source.retry_after(SystemTime::now());
198 let method = source.request_method().map(ToString::to_string);
199 let uri = source.request_uri_redacted_owned();
200 let timeout_phase = reqx_timeout_phase_name(&source);
201 let transport_kind = reqx_transport_kind_name(&source);
202 let retryable = match source.code() {
203 reqx::ErrorCode::Timeout
204 | reqx::ErrorCode::DeadlineExceeded
205 | reqx::ErrorCode::Transport
206 | reqx::ErrorCode::RetryBudgetExhausted
207 | reqx::ErrorCode::CircuitOpen => true,
208 reqx::ErrorCode::HttpStatus => matches!(status, Some(429 | 500..=599)),
209 _ => false,
210 };
211
212 if let Some(status) = status {
213 let error = HttpError {
214 status,
215 message: None,
216 request_id: request_id.clone(),
217 body_snippet: None,
218 };
219
220 return match status {
221 401 | 403 => Self::Auth(error),
222 404 => Self::NotFound(error),
223 409 | 412 => Self::Conflict(error),
224 429 => Self::RateLimited { retry_after, error },
225 _ => Self::Transport(Box::new(TransportError {
226 status: Some(status),
227 message: None,
228 request_id,
229 body_snippet: None,
230 retry_after,
231 retryable,
232 code,
233 method,
234 uri,
235 timeout_phase,
236 transport_kind,
237 })),
238 };
239 }
240
241 Self::Transport(Box::new(TransportError {
242 status: None,
243 message: Some(source.to_string()),
244 request_id,
245 body_snippet: None,
246 retry_after,
247 retryable,
248 code,
249 method,
250 uri,
251 timeout_phase,
252 transport_kind,
253 }))
254 }
255}
256
257impl Error {
258 #[must_use]
260 pub fn kind(&self) -> ErrorKind {
261 match self {
262 Self::Auth(_) => ErrorKind::Auth,
263 Self::NotFound(_) => ErrorKind::NotFound,
264 Self::Conflict(_) => ErrorKind::Conflict,
265 Self::RateLimited { .. } => ErrorKind::RateLimited,
266 Self::Api { .. } => ErrorKind::Api,
267 Self::Transport(_) => ErrorKind::Transport,
268 Self::Serialization(_) => ErrorKind::Serialization,
269 Self::Timestamp(_) => ErrorKind::Timestamp,
270 Self::Signature => ErrorKind::Signature,
271 Self::InvalidConfig { .. } => ErrorKind::InvalidConfig,
272 }
273 }
274
275 #[must_use]
277 pub fn status(&self) -> Option<u16> {
278 match self {
279 Self::Auth(error) | Self::NotFound(error) | Self::Conflict(error) => Some(error.status),
280 Self::RateLimited { error, .. } => Some(error.status),
281 Self::Transport(error) => error.status,
282 _ => None,
283 }
284 }
285
286 #[must_use]
288 pub fn request_id(&self) -> Option<&str> {
289 match self {
290 Self::Api { request_id, .. } => request_id.as_deref(),
291 Self::Auth(error) | Self::NotFound(error) | Self::Conflict(error) => {
292 error.request_id.as_deref()
293 }
294 Self::RateLimited { error, .. } => error.request_id.as_deref(),
295 Self::Transport(error) => error.request_id.as_deref(),
296 _ => None,
297 }
298 }
299
300 #[must_use]
302 pub fn body_snippet(&self) -> Option<&str> {
303 match self {
304 Self::Api { body_snippet, .. } => body_snippet.as_deref(),
305 Self::Auth(error) | Self::NotFound(error) | Self::Conflict(error) => {
306 error.body_snippet.as_deref()
307 }
308 Self::RateLimited { error, .. } => error.body_snippet.as_deref(),
309 Self::Transport(error) => error.body_snippet.as_deref(),
310 _ => None,
311 }
312 }
313
314 #[must_use]
316 pub fn is_auth_error(&self) -> bool {
317 matches!(self, Self::Auth(_))
318 }
319
320 #[must_use]
322 pub fn is_retryable(&self) -> bool {
323 match self {
324 Self::RateLimited { .. } => true,
325 Self::Transport(error) => error.retryable,
326 Self::Api { code, .. } => matches!(*code, 130101 | 130102),
327 _ => false,
328 }
329 }
330
331 #[must_use]
333 pub fn retry_after(&self) -> Option<Duration> {
334 match self {
335 Self::RateLimited { retry_after, .. } => *retry_after,
336 Self::Transport(error) => error.retry_after,
337 _ => None,
338 }
339 }
340}