1use bytes::Bytes;
7use http::StatusCode;
8use thiserror::Error;
9
10#[derive(Debug, Error, Clone)]
12#[must_use = "errors must be handled or propagated with `?`"]
13pub enum Error {
14 #[error("invalid base URL: {0}")]
16 InvalidBaseUrl(#[from] url::ParseError),
17
18 #[error("transport error: {0}")]
20 Transport(String),
21
22 #[error("HTTP {status} {status_text}: {message}")]
24 Http {
25 status: StatusCode,
27 status_text: String,
29 message: String,
31 body: Option<Bytes>,
33 },
34
35 #[cfg(feature = "json")]
37 #[error("failed to deserialize response body: {message}")]
38 Deserialize {
39 status: StatusCode,
40 message: String,
41 body: Option<Bytes>,
42 },
43
44 #[cfg(feature = "validate")]
46 #[error("response validation failed: {message}")]
47 Validation {
48 status: StatusCode,
49 message: String,
50 body: Option<Bytes>,
51 },
52
53 #[error("request timed out")]
55 Timeout,
56
57 #[error("request was cancelled")]
59 Cancelled,
60
61 #[error("client base URL is required; call ClientBuilder::base_url")]
63 MissingBaseUrl,
64
65 #[error("retries exhausted after {attempts} attempts")]
67 RetryExhausted {
68 attempts: u32,
70 last: Option<String>,
72 },
73
74 #[error("hook error: {0}")]
78 Hook(String),
79
80 #[error("{0}")]
82 Other(String),
83}
84
85impl Error {
86 pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
88 Self::http_with_status_text(
89 status,
90 status.canonical_reason().unwrap_or("").to_string(),
91 message,
92 body,
93 )
94 }
95
96 pub fn http_with_status_text(
98 status: StatusCode,
99 status_text: impl Into<String>,
100 message: impl Into<String>,
101 body: Option<Bytes>,
102 ) -> Self {
103 Self::Http {
104 status,
105 status_text: status_text.into(),
106 message: message.into(),
107 body,
108 }
109 }
110
111 pub fn status(&self) -> Option<StatusCode> {
113 match self {
114 Self::Http { status, .. } => Some(*status),
115 #[cfg(feature = "json")]
116 Self::Deserialize { status, .. } => Some(*status),
117 #[cfg(feature = "validate")]
118 Self::Validation { status, .. } => Some(*status),
119 _ => None,
120 }
121 }
122
123 pub fn status_text(&self) -> Option<&str> {
125 match self {
126 Self::Http { status_text, .. } => Some(status_text),
127 _ => None,
128 }
129 }
130
131 pub fn body(&self) -> Option<&Bytes> {
133 match self {
134 Self::Http { body, .. } => body.as_ref(),
135 #[cfg(feature = "json")]
136 Self::Deserialize { body, .. } => body.as_ref(),
137 #[cfg(feature = "validate")]
138 Self::Validation { body, .. } => body.as_ref(),
139 _ => None,
140 }
141 }
142
143 pub fn is_retry_exhausted(&self) -> bool {
145 matches!(self, Self::RetryExhausted { .. })
146 }
147
148 pub fn is_cancelled(&self) -> bool {
150 matches!(self, Self::Cancelled)
151 }
152
153 pub fn hook(msg: impl Into<String>) -> Self {
156 Self::Hook(msg.into())
157 }
158
159 pub fn is_hook(&self) -> bool {
161 matches!(self, Self::Hook(_))
162 }
163
164 pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
165 Self::RetryExhausted {
166 attempts,
167 last: Some(last.to_string()),
168 }
169 }
170
171 #[cfg(feature = "json")]
195 pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
196 let body = self.body()?;
197 serde_json::from_slice(body).ok()
198 }
199
200 #[cfg(feature = "validate")]
202 pub fn api_json_validated<T>(&self) -> Option<T>
203 where
204 T: serde::de::DeserializeOwned + garde::Validate,
205 T::Context: Default,
206 {
207 let body = self.body()?;
208 let value: T = serde_json::from_slice(body).ok()?;
209 value.validate().ok()?;
210 Some(value)
211 }
212}
213
214pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
215 if err.is_timeout() {
216 Error::Timeout
217 } else {
218 Error::Transport(err.to_string())
219 }
220}
221
222#[cfg(all(test, feature = "json"))]
223mod tests {
224 use super::*;
225 use serde::Deserialize;
226
227 #[derive(Debug, Deserialize, PartialEq)]
228 struct ApiError {
229 message: String,
230 }
231
232 #[test]
233 fn api_json_parses_http_body() {
234 let err = Error::http_with_status_text(
235 StatusCode::BAD_REQUEST,
236 "Bad Request",
237 "bad request",
238 Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
239 );
240 let api: ApiError = err.api_json().unwrap();
241 assert_eq!(api.message, "invalid");
242 }
243
244 #[test]
245 fn status_and_status_text_accessors() {
246 let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
247 assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
248 assert_eq!(err.status_text(), Some("Not Found"));
249 }
250
251 #[test]
252 fn api_json_returns_none_without_body() {
253 let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
254 assert!(err.api_json::<ApiError>().is_none());
255 }
256
257 #[test]
258 fn hook_constructor_and_is_hook() {
259 let err = Error::hook("blocked");
260 assert!(err.is_hook());
261 assert!(matches!(err, Error::Hook(msg) if msg == "blocked"));
262 }
263
264 #[test]
265 fn retry_exhausted_helper_sets_flag() {
266 let err = Error::retry_exhausted(3, Error::Timeout);
267 assert!(err.is_retry_exhausted());
268 assert!(matches!(
269 err,
270 Error::RetryExhausted {
271 attempts: 3,
272 last: Some(_)
273 }
274 ));
275 }
276}