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