1use reqwest::{Method, StatusCode};
4use std::{fmt, string::FromUtf8Error, time::Duration};
5use thiserror::Error;
6use url::Url;
7
8use crate::{ProxyError, ProxyScheme};
9
10#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16#[non_exhaustive]
17pub enum ErrorKind {
18 Configuration,
20 Unauthorized,
22 Forbidden,
24 HttpStatus,
26 Transport,
28 ResponseTooLarge,
30 Encode,
32 Api,
34 Decode,
36 Utf8,
38 WebSocketConnectTimeout,
40 WebSocket,
42 Proxy,
44 EventDecode,
46 UnexpectedWebSocketFrame,
48 MissingObject,
50}
51
52impl ErrorKind {
53 pub const fn as_str(self) -> &'static str {
55 match self {
56 Self::Configuration => "configuration",
57 Self::Unauthorized => "unauthorized",
58 Self::Forbidden => "forbidden",
59 Self::HttpStatus => "http_status",
60 Self::Transport => "transport",
61 Self::ResponseTooLarge => "response_too_large",
62 Self::Encode => "encode",
63 Self::Api => "api",
64 Self::Decode => "decode",
65 Self::Utf8 => "utf8",
66 Self::WebSocketConnectTimeout => "websocket_connect_timeout",
67 Self::WebSocket => "websocket",
68 Self::Proxy => "proxy",
69 Self::EventDecode => "event_decode",
70 Self::UnexpectedWebSocketFrame => "unexpected_websocket_frame",
71 Self::MissingObject => "missing_object",
72 }
73 }
74}
75
76impl fmt::Display for ErrorKind {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str(self.as_str())
79 }
80}
81
82#[derive(Debug, Error)]
84#[non_exhaustive]
85pub enum Error {
86 #[error("invalid client configuration: {0}")]
88 Configuration(String),
89
90 #[error("{method} {url} requires authentication")]
92 Unauthorized {
93 method: Method,
95 url: Box<Url>,
97 },
98
99 #[error("{method} {url} was forbidden")]
101 Forbidden {
102 method: Method,
104 url: Box<Url>,
106 },
107
108 #[error("{method} {url} returned HTTP {status}")]
110 HttpStatus {
111 method: Method,
113 url: Box<Url>,
115 status: StatusCode,
117 },
118
119 #[error("request {method} {url} failed: {source}")]
121 Transport {
122 method: Method,
124 url: Box<Url>,
126 #[source]
128 source: reqwest::Error,
129 },
130
131 #[error("response from {method} {url} exceeded the {limit}-byte body limit")]
133 ResponseTooLarge {
134 method: Method,
136 url: Box<Url>,
138 limit: usize,
140 content_length: Option<u64>,
142 },
143
144 #[error("could not encode request for {operation}: {source}")]
146 Encode {
147 operation: &'static str,
149 #[source]
151 source: serde_json::Error,
152 },
153
154 #[error("panel rejected {method} {url}: {message}")]
156 Api {
157 method: Method,
159 url: Box<Url>,
161 message: String,
163 },
164
165 #[error("could not decode response from {method} {url}: {source}")]
167 Decode {
168 method: Method,
170 url: Box<Url>,
172 #[source]
174 source: serde_json::Error,
175 },
176
177 #[error("response from {method} {url} was not valid UTF-8: {source}")]
179 Utf8 {
180 method: Method,
182 url: Box<Url>,
184 #[source]
186 source: FromUtf8Error,
187 },
188
189 #[error("WebSocket connection to {url} timed out after {timeout:?}")]
192 WebSocketConnectTimeout {
193 url: Box<Url>,
195 timeout: Duration,
197 },
198
199 #[error("WebSocket operation on {url} failed: {source}")]
201 WebSocket {
202 url: Box<Url>,
204 #[source]
206 source: Box<tokio_tungstenite::tungstenite::Error>,
207 },
208
209 #[error("{scheme} proxy could not connect WebSocket to {url}: {source}")]
211 Proxy {
212 scheme: ProxyScheme,
214 url: Box<Url>,
216 #[source]
218 source: Box<ProxyError>,
219 },
220
221 #[error("could not decode WebSocket {message_type:?} event: {source}")]
223 EventDecode {
224 message_type: Option<String>,
226 #[source]
228 source: serde_json::Error,
229 },
230
231 #[error("unexpected WebSocket {kind} frame from {url}")]
233 UnexpectedWebSocketFrame {
234 url: Box<Url>,
236 kind: &'static str,
238 },
239
240 #[error("response from {method} {url} did not contain obj")]
242 MissingObject {
243 method: Method,
245 url: Box<Url>,
247 },
248}
249
250impl Error {
251 pub const fn kind(&self) -> ErrorKind {
253 match self {
254 Self::Configuration(_) => ErrorKind::Configuration,
255 Self::Unauthorized { .. } => ErrorKind::Unauthorized,
256 Self::Forbidden { .. } => ErrorKind::Forbidden,
257 Self::HttpStatus { .. } => ErrorKind::HttpStatus,
258 Self::Transport { .. } => ErrorKind::Transport,
259 Self::ResponseTooLarge { .. } => ErrorKind::ResponseTooLarge,
260 Self::Encode { .. } => ErrorKind::Encode,
261 Self::Api { .. } => ErrorKind::Api,
262 Self::Decode { .. } => ErrorKind::Decode,
263 Self::Utf8 { .. } => ErrorKind::Utf8,
264 Self::WebSocketConnectTimeout { .. } => ErrorKind::WebSocketConnectTimeout,
265 Self::WebSocket { .. } => ErrorKind::WebSocket,
266 Self::Proxy { .. } => ErrorKind::Proxy,
267 Self::EventDecode { .. } => ErrorKind::EventDecode,
268 Self::UnexpectedWebSocketFrame { .. } => ErrorKind::UnexpectedWebSocketFrame,
269 Self::MissingObject { .. } => ErrorKind::MissingObject,
270 }
271 }
272
273 pub const fn status(&self) -> Option<StatusCode> {
279 match self {
280 Self::Unauthorized { .. } => Some(StatusCode::UNAUTHORIZED),
281 Self::Forbidden { .. } => Some(StatusCode::FORBIDDEN),
282 Self::HttpStatus { status, .. } => Some(*status),
283 _ => None,
284 }
285 }
286
287 pub const fn method(&self) -> Option<&Method> {
289 match self {
290 Self::Unauthorized { method, .. }
291 | Self::Forbidden { method, .. }
292 | Self::HttpStatus { method, .. }
293 | Self::Transport { method, .. }
294 | Self::ResponseTooLarge { method, .. }
295 | Self::Api { method, .. }
296 | Self::Decode { method, .. }
297 | Self::Utf8 { method, .. }
298 | Self::MissingObject { method, .. } => Some(method),
299 _ => None,
300 }
301 }
302
303 pub fn url(&self) -> Option<&Url> {
305 match self {
306 Self::Unauthorized { url, .. }
307 | Self::Forbidden { url, .. }
308 | Self::HttpStatus { url, .. }
309 | Self::Transport { url, .. }
310 | Self::ResponseTooLarge { url, .. }
311 | Self::Api { url, .. }
312 | Self::Decode { url, .. }
313 | Self::Utf8 { url, .. }
314 | Self::WebSocketConnectTimeout { url, .. }
315 | Self::WebSocket { url, .. }
316 | Self::Proxy { url, .. }
317 | Self::UnexpectedWebSocketFrame { url, .. }
318 | Self::MissingObject { url, .. } => Some(url),
319 Self::Configuration(_) | Self::Encode { .. } | Self::EventDecode { .. } => None,
320 }
321 }
322
323 pub const fn is_unauthorized(&self) -> bool {
325 matches!(self, Self::Unauthorized { .. })
326 }
327
328 pub const fn is_forbidden(&self) -> bool {
330 matches!(self, Self::Forbidden { .. })
331 }
332
333 pub const fn is_rate_limited(&self) -> bool {
335 matches!(self.status(), Some(StatusCode::TOO_MANY_REQUESTS))
336 }
337
338 pub const fn is_response_too_large(&self) -> bool {
340 matches!(self, Self::ResponseTooLarge { .. })
341 }
342
343 pub const fn response_body_limit(&self) -> Option<usize> {
345 match self {
346 Self::ResponseTooLarge { limit, .. } => Some(*limit),
347 _ => None,
348 }
349 }
350
351 pub const fn advertised_content_length(&self) -> Option<u64> {
353 match self {
354 Self::ResponseTooLarge { content_length, .. } => *content_length,
355 _ => None,
356 }
357 }
358
359 pub fn is_server_error(&self) -> bool {
361 self.status().is_some_and(|status| status.is_server_error())
362 }
363
364 pub fn is_timeout(&self) -> bool {
366 match self {
367 Self::Transport { source, .. } => source.is_timeout(),
368 Self::WebSocketConnectTimeout { .. } => true,
369 Self::WebSocket { source, .. } => matches!(
370 source.as_ref(),
371 tokio_tungstenite::tungstenite::Error::Io(error)
372 if error.kind() == std::io::ErrorKind::TimedOut
373 ),
374 Self::Proxy { source, .. } => source.is_timeout(),
375 _ => false,
376 }
377 }
378}
379
380pub type Result<T> = std::result::Result<T, Error>;
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::io;
387
388 fn endpoint() -> Url {
389 Url::parse("https://panel.example.com/secret/api").unwrap()
390 }
391
392 #[test]
393 fn every_error_kind_has_a_stable_display_label() {
394 let values = [
395 (ErrorKind::Configuration, "configuration"),
396 (ErrorKind::Unauthorized, "unauthorized"),
397 (ErrorKind::Forbidden, "forbidden"),
398 (ErrorKind::HttpStatus, "http_status"),
399 (ErrorKind::Transport, "transport"),
400 (ErrorKind::ResponseTooLarge, "response_too_large"),
401 (ErrorKind::Encode, "encode"),
402 (ErrorKind::Api, "api"),
403 (ErrorKind::Decode, "decode"),
404 (ErrorKind::Utf8, "utf8"),
405 (
406 ErrorKind::WebSocketConnectTimeout,
407 "websocket_connect_timeout",
408 ),
409 (ErrorKind::WebSocket, "websocket"),
410 (ErrorKind::Proxy, "proxy"),
411 (ErrorKind::EventDecode, "event_decode"),
412 (
413 ErrorKind::UnexpectedWebSocketFrame,
414 "unexpected_websocket_frame",
415 ),
416 (ErrorKind::MissingObject, "missing_object"),
417 ];
418 for (kind, label) in values {
419 assert_eq!(kind.as_str(), label);
420 assert_eq!(kind.to_string(), label);
421 }
422 }
423
424 #[tokio::test]
425 #[allow(clippy::too_many_lines)]
426 async fn variants_expose_consistent_http_and_socket_context() {
427 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
428 let address = listener.local_addr().unwrap();
429 drop(listener);
430 let transport_source = reqwest::get(format!("http://{address}")).await.unwrap_err();
431 let json_source = || serde_json::from_str::<serde_json::Value>("{").unwrap_err();
432 let utf8_source = String::from_utf8(vec![0xff]).unwrap_err();
433 let errors = vec![
434 (
435 Error::Configuration("bad configuration".into()),
436 ErrorKind::Configuration,
437 false,
438 ),
439 (
440 Error::Unauthorized {
441 method: Method::GET,
442 url: Box::new(endpoint()),
443 },
444 ErrorKind::Unauthorized,
445 true,
446 ),
447 (
448 Error::Forbidden {
449 method: Method::POST,
450 url: Box::new(endpoint()),
451 },
452 ErrorKind::Forbidden,
453 true,
454 ),
455 (
456 Error::HttpStatus {
457 method: Method::PUT,
458 url: Box::new(endpoint()),
459 status: StatusCode::BAD_GATEWAY,
460 },
461 ErrorKind::HttpStatus,
462 true,
463 ),
464 (
465 Error::Transport {
466 method: Method::GET,
467 url: Box::new(endpoint()),
468 source: transport_source,
469 },
470 ErrorKind::Transport,
471 true,
472 ),
473 (
474 Error::ResponseTooLarge {
475 method: Method::GET,
476 url: Box::new(endpoint()),
477 limit: 1024,
478 content_length: None,
479 },
480 ErrorKind::ResponseTooLarge,
481 true,
482 ),
483 (
484 Error::Encode {
485 operation: "test",
486 source: json_source(),
487 },
488 ErrorKind::Encode,
489 false,
490 ),
491 (
492 Error::Api {
493 method: Method::PATCH,
494 url: Box::new(endpoint()),
495 message: "rejected".into(),
496 },
497 ErrorKind::Api,
498 true,
499 ),
500 (
501 Error::Decode {
502 method: Method::GET,
503 url: Box::new(endpoint()),
504 source: json_source(),
505 },
506 ErrorKind::Decode,
507 true,
508 ),
509 (
510 Error::Utf8 {
511 method: Method::GET,
512 url: Box::new(endpoint()),
513 source: utf8_source,
514 },
515 ErrorKind::Utf8,
516 true,
517 ),
518 (
519 Error::WebSocketConnectTimeout {
520 url: Box::new(endpoint()),
521 timeout: Duration::from_millis(10),
522 },
523 ErrorKind::WebSocketConnectTimeout,
524 true,
525 ),
526 (
527 Error::WebSocket {
528 url: Box::new(endpoint()),
529 source: Box::new(tokio_tungstenite::tungstenite::Error::Io(io::Error::new(
530 io::ErrorKind::TimedOut,
531 "timed out",
532 ))),
533 },
534 ErrorKind::WebSocket,
535 true,
536 ),
537 (
538 Error::Proxy {
539 scheme: ProxyScheme::Http,
540 url: Box::new(endpoint()),
541 source: Box::new(ProxyError::Io(io::Error::new(
542 io::ErrorKind::TimedOut,
543 "timed out",
544 ))),
545 },
546 ErrorKind::Proxy,
547 true,
548 ),
549 (
550 Error::EventDecode {
551 message_type: Some("status".into()),
552 source: json_source(),
553 },
554 ErrorKind::EventDecode,
555 false,
556 ),
557 (
558 Error::UnexpectedWebSocketFrame {
559 url: Box::new(endpoint()),
560 kind: "binary data",
561 },
562 ErrorKind::UnexpectedWebSocketFrame,
563 true,
564 ),
565 (
566 Error::MissingObject {
567 method: Method::GET,
568 url: Box::new(endpoint()),
569 },
570 ErrorKind::MissingObject,
571 true,
572 ),
573 ];
574
575 for (error, kind, has_url) in errors {
576 assert_eq!(error.kind(), kind);
577 assert_eq!(error.url().is_some(), has_url);
578 assert!(!error.to_string().is_empty());
579 if error.method().is_some() {
580 assert!(has_url);
581 }
582 }
583 }
584
585 #[test]
586 fn predicates_cover_positive_and_negative_paths() {
587 let forbidden = Error::Forbidden {
588 method: Method::POST,
589 url: Box::new(endpoint()),
590 };
591 assert!(forbidden.is_forbidden());
592 assert_eq!(forbidden.status(), Some(StatusCode::FORBIDDEN));
593
594 let server = Error::HttpStatus {
595 method: Method::GET,
596 url: Box::new(endpoint()),
597 status: StatusCode::INTERNAL_SERVER_ERROR,
598 };
599 assert!(server.is_server_error());
600 assert!(!server.is_rate_limited());
601
602 let plain = Error::Configuration("plain".into());
603 assert_eq!(plain.status(), None);
604 assert_eq!(plain.method(), None);
605 assert_eq!(plain.url(), None);
606 assert!(!plain.is_unauthorized());
607 assert!(!plain.is_forbidden());
608 assert!(!plain.is_response_too_large());
609 assert_eq!(plain.response_body_limit(), None);
610 assert_eq!(plain.advertised_content_length(), None);
611 assert!(!plain.is_server_error());
612 assert!(!plain.is_timeout());
613 }
614}