1use bytes::Bytes;
2use http::StatusCode;
3use thiserror::Error;
4
5#[derive(Debug, Error, Clone)]
7#[must_use = "errors must be handled or propagated with `?`"]
8pub enum Error {
9 #[error("invalid base URL: {0}")]
10 InvalidBaseUrl(#[from] url::ParseError),
11
12 #[error("transport error: {0}")]
13 Transport(String),
14
15 #[error("HTTP {status} {status_text}: {message}")]
16 Http {
17 status: StatusCode,
18 status_text: String,
19 message: String,
20 body: Option<Bytes>,
21 },
22
23 #[cfg(feature = "json")]
24 #[error("failed to deserialize response body: {message}")]
25 Deserialize {
26 status: StatusCode,
27 message: String,
28 body: Option<Bytes>,
29 },
30
31 #[cfg(feature = "validate")]
32 #[error("response validation failed: {message}")]
33 Validation {
34 status: StatusCode,
35 message: String,
36 body: Option<Bytes>,
37 },
38
39 #[error("request timed out")]
40 Timeout,
41
42 #[error("retries exhausted after {attempts} attempts")]
43 RetryExhausted { attempts: u32, last: Option<String> },
44
45 #[error("hook error: {0}")]
46 Hook(String),
47
48 #[error("{0}")]
49 Other(String),
50}
51
52impl Error {
53 pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
54 Self::http_with_status_text(
55 status,
56 status.canonical_reason().unwrap_or("").to_string(),
57 message,
58 body,
59 )
60 }
61
62 pub fn http_with_status_text(
63 status: StatusCode,
64 status_text: impl Into<String>,
65 message: impl Into<String>,
66 body: Option<Bytes>,
67 ) -> Self {
68 Self::Http {
69 status,
70 status_text: status_text.into(),
71 message: message.into(),
72 body,
73 }
74 }
75
76 pub fn status(&self) -> Option<StatusCode> {
77 match self {
78 Self::Http { status, .. } => Some(*status),
79 #[cfg(feature = "json")]
80 Self::Deserialize { status, .. } => Some(*status),
81 #[cfg(feature = "validate")]
82 Self::Validation { status, .. } => Some(*status),
83 _ => None,
84 }
85 }
86
87 pub fn status_text(&self) -> Option<&str> {
88 match self {
89 Self::Http { status_text, .. } => Some(status_text),
90 _ => None,
91 }
92 }
93
94 pub fn body(&self) -> Option<&Bytes> {
95 match self {
96 Self::Http { body, .. } => body.as_ref(),
97 #[cfg(feature = "json")]
98 Self::Deserialize { body, .. } => body.as_ref(),
99 #[cfg(feature = "validate")]
100 Self::Validation { body, .. } => body.as_ref(),
101 _ => None,
102 }
103 }
104
105 pub fn is_retry_exhausted(&self) -> bool {
107 matches!(self, Self::RetryExhausted { .. })
108 }
109
110 pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
111 Self::RetryExhausted {
112 attempts,
113 last: Some(last.to_string()),
114 }
115 }
116
117 #[cfg(feature = "json")]
119 pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
120 let body = self.body()?;
121 serde_json::from_slice(body).ok()
122 }
123
124 #[cfg(feature = "validate")]
126 pub fn api_json_validated<T>(&self) -> Option<T>
127 where
128 T: serde::de::DeserializeOwned + garde::Validate,
129 T::Context: Default,
130 {
131 let body = self.body()?;
132 let value: T = serde_json::from_slice(body).ok()?;
133 value.validate().ok()?;
134 Some(value)
135 }
136}
137
138pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
139 if err.is_timeout() {
140 Error::Timeout
141 } else {
142 Error::Transport(err.to_string())
143 }
144}
145
146#[cfg(all(test, feature = "json"))]
147mod tests {
148 use super::*;
149 use serde::Deserialize;
150
151 #[derive(Debug, Deserialize, PartialEq)]
152 struct ApiError {
153 message: String,
154 }
155
156 #[test]
157 fn api_json_parses_http_body() {
158 let err = Error::http_with_status_text(
159 StatusCode::BAD_REQUEST,
160 "Bad Request",
161 "bad request",
162 Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
163 );
164 let api: ApiError = err.api_json().unwrap();
165 assert_eq!(api.message, "invalid");
166 }
167
168 #[test]
169 fn status_and_status_text_accessors() {
170 let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
171 assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
172 assert_eq!(err.status_text(), Some("Not Found"));
173 }
174
175 #[test]
176 fn api_json_returns_none_without_body() {
177 let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
178 assert!(err.api_json::<ApiError>().is_none());
179 }
180
181 #[test]
182 fn retry_exhausted_helper_sets_flag() {
183 let err = Error::retry_exhausted(3, Error::Timeout);
184 assert!(err.is_retry_exhausted());
185 assert!(matches!(
186 err,
187 Error::RetryExhausted {
188 attempts: 3,
189 last: Some(_)
190 }
191 ));
192 }
193}