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("request was cancelled")]
43 Cancelled,
44
45 #[error("client base URL is required; call ClientBuilder::base_url")]
46 MissingBaseUrl,
47
48 #[error("retries exhausted after {attempts} attempts")]
49 RetryExhausted { attempts: u32, last: Option<String> },
50
51 #[error("hook error: {0}")]
55 Hook(String),
56
57 #[error("{0}")]
58 Other(String),
59}
60
61impl Error {
62 pub fn http(status: StatusCode, message: impl Into<String>, body: Option<Bytes>) -> Self {
63 Self::http_with_status_text(
64 status,
65 status.canonical_reason().unwrap_or("").to_string(),
66 message,
67 body,
68 )
69 }
70
71 pub fn http_with_status_text(
72 status: StatusCode,
73 status_text: impl Into<String>,
74 message: impl Into<String>,
75 body: Option<Bytes>,
76 ) -> Self {
77 Self::Http {
78 status,
79 status_text: status_text.into(),
80 message: message.into(),
81 body,
82 }
83 }
84
85 pub fn status(&self) -> Option<StatusCode> {
86 match self {
87 Self::Http { status, .. } => Some(*status),
88 #[cfg(feature = "json")]
89 Self::Deserialize { status, .. } => Some(*status),
90 #[cfg(feature = "validate")]
91 Self::Validation { status, .. } => Some(*status),
92 _ => None,
93 }
94 }
95
96 pub fn status_text(&self) -> Option<&str> {
97 match self {
98 Self::Http { status_text, .. } => Some(status_text),
99 _ => None,
100 }
101 }
102
103 pub fn body(&self) -> Option<&Bytes> {
104 match self {
105 Self::Http { body, .. } => body.as_ref(),
106 #[cfg(feature = "json")]
107 Self::Deserialize { body, .. } => body.as_ref(),
108 #[cfg(feature = "validate")]
109 Self::Validation { body, .. } => body.as_ref(),
110 _ => None,
111 }
112 }
113
114 pub fn is_retry_exhausted(&self) -> bool {
116 matches!(self, Self::RetryExhausted { .. })
117 }
118
119 pub fn is_cancelled(&self) -> bool {
121 matches!(self, Self::Cancelled)
122 }
123
124 pub fn hook(msg: impl Into<String>) -> Self {
127 Self::Hook(msg.into())
128 }
129
130 pub fn is_hook(&self) -> bool {
132 matches!(self, Self::Hook(_))
133 }
134
135 pub(crate) fn retry_exhausted(attempts: u32, last: Error) -> Self {
136 Self::RetryExhausted {
137 attempts,
138 last: Some(last.to_string()),
139 }
140 }
141
142 #[cfg(feature = "json")]
144 pub fn api_json<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
145 let body = self.body()?;
146 serde_json::from_slice(body).ok()
147 }
148
149 #[cfg(feature = "validate")]
151 pub fn api_json_validated<T>(&self) -> Option<T>
152 where
153 T: serde::de::DeserializeOwned + garde::Validate,
154 T::Context: Default,
155 {
156 let body = self.body()?;
157 let value: T = serde_json::from_slice(body).ok()?;
158 value.validate().ok()?;
159 Some(value)
160 }
161}
162
163pub(crate) fn map_transport_error(err: reqwest::Error) -> Error {
164 if err.is_timeout() {
165 Error::Timeout
166 } else {
167 Error::Transport(err.to_string())
168 }
169}
170
171#[cfg(all(test, feature = "json"))]
172mod tests {
173 use super::*;
174 use serde::Deserialize;
175
176 #[derive(Debug, Deserialize, PartialEq)]
177 struct ApiError {
178 message: String,
179 }
180
181 #[test]
182 fn api_json_parses_http_body() {
183 let err = Error::http_with_status_text(
184 StatusCode::BAD_REQUEST,
185 "Bad Request",
186 "bad request",
187 Some(bytes::Bytes::from_static(br#"{"message":"invalid"}"#)),
188 );
189 let api: ApiError = err.api_json().unwrap();
190 assert_eq!(api.message, "invalid");
191 }
192
193 #[test]
194 fn status_and_status_text_accessors() {
195 let err = Error::http(StatusCode::NOT_FOUND, "not found", None);
196 assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
197 assert_eq!(err.status_text(), Some("Not Found"));
198 }
199
200 #[test]
201 fn api_json_returns_none_without_body() {
202 let err = Error::http(StatusCode::INTERNAL_SERVER_ERROR, "err", None);
203 assert!(err.api_json::<ApiError>().is_none());
204 }
205
206 #[test]
207 fn hook_constructor_and_is_hook() {
208 let err = Error::hook("blocked");
209 assert!(err.is_hook());
210 assert!(matches!(err, Error::Hook(msg) if msg == "blocked"));
211 }
212
213 #[test]
214 fn retry_exhausted_helper_sets_flag() {
215 let err = Error::retry_exhausted(3, Error::Timeout);
216 assert!(err.is_retry_exhausted());
217 assert!(matches!(
218 err,
219 Error::RetryExhausted {
220 attempts: 3,
221 last: Some(_)
222 }
223 ));
224 }
225}