Skip to main content

xui_rs/
error.rs

1//! Typed SDK errors and transport-independent error introspection.
2
3use reqwest::{Method, StatusCode};
4use std::{fmt, string::FromUtf8Error, time::Duration};
5use thiserror::Error;
6use url::Url;
7
8use crate::{ProxyError, ProxyScheme};
9
10/// Stable category for an [`enum@Error`] without exposing its variant fields.
11///
12/// This is useful for metrics and policy decisions that should not depend on
13/// the concrete error payload. Use [`Error::status`], [`Error::method`], and
14/// [`Error::url`] when the associated HTTP context is needed.
15#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16#[non_exhaustive]
17pub enum ErrorKind {
18    /// Invalid client or request configuration.
19    Configuration,
20    /// Missing or expired authentication.
21    Unauthorized,
22    /// Authorization or CSRF rejection.
23    Forbidden,
24    /// Non-success HTTP status other than 401 or 403.
25    HttpStatus,
26    /// HTTP transport or response-body IO failure.
27    Transport,
28    /// HTTP response body exceeded its configured memory limit.
29    ResponseTooLarge,
30    /// Request serialization failure.
31    Encode,
32    /// Application-level rejection in a successful HTTP response.
33    Api,
34    /// HTTP response JSON decoding failure.
35    Decode,
36    /// Invalid UTF-8 response body.
37    Utf8,
38    /// WebSocket connection timeout.
39    WebSocketConnectTimeout,
40    /// WebSocket handshake, transport, or protocol failure.
41    WebSocket,
42    /// Explicit outbound proxy connection or negotiation failure.
43    Proxy,
44    /// WebSocket event JSON decoding failure.
45    EventDecode,
46    /// Unsupported WebSocket application frame.
47    UnexpectedWebSocketFrame,
48    /// Successful response missing its required object.
49    MissingObject,
50}
51
52impl ErrorKind {
53    /// Returns a stable snake-case label suitable for logs and metrics.
54    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/// Errors returned by the SDK.
83#[derive(Debug, Error)]
84#[non_exhaustive]
85pub enum Error {
86    /// The client configuration is invalid.
87    #[error("invalid client configuration: {0}")]
88    Configuration(String),
89
90    /// The panel rejected the caller's credentials or session.
91    #[error("{method} {url} requires authentication")]
92    Unauthorized {
93        /// HTTP method used for the request.
94        method: Method,
95        /// Request URL.
96        url: Box<Url>,
97    },
98
99    /// The panel rejected a request, commonly due to an invalid CSRF token.
100    #[error("{method} {url} was forbidden")]
101    Forbidden {
102        /// HTTP method used for the request.
103        method: Method,
104        /// Request URL.
105        url: Box<Url>,
106    },
107
108    /// The panel returned an unsuccessful HTTP status.
109    #[error("{method} {url} returned HTTP {status}")]
110    HttpStatus {
111        /// HTTP method used for the request.
112        method: Method,
113        /// Request URL.
114        url: Box<Url>,
115        /// Response status.
116        status: StatusCode,
117    },
118
119    /// The request could not be sent or its body could not be read.
120    #[error("request {method} {url} failed: {source}")]
121    Transport {
122        /// HTTP method used for the request.
123        method: Method,
124        /// Request URL.
125        url: Box<Url>,
126        /// Underlying HTTP client error.
127        #[source]
128        source: reqwest::Error,
129    },
130
131    /// The response body exceeded the configured in-memory size limit.
132    #[error("response from {method} {url} exceeded the {limit}-byte body limit")]
133    ResponseTooLarge {
134        /// HTTP method used for the request.
135        method: Method,
136        /// Request URL, redacted for public subscription routes.
137        url: Box<Url>,
138        /// Configured maximum response body size in bytes.
139        limit: usize,
140        /// Server-advertised body size, when a `Content-Length` was present.
141        content_length: Option<u64>,
142    },
143
144    /// A request value could not be encoded in the endpoint's wire format.
145    #[error("could not encode request for {operation}: {source}")]
146    Encode {
147        /// Human-readable operation name.
148        operation: &'static str,
149        /// Underlying JSON encoding error.
150        #[source]
151        source: serde_json::Error,
152    },
153
154    /// The panel returned HTTP success with `success: false`.
155    #[error("panel rejected {method} {url}: {message}")]
156    Api {
157        /// HTTP method used for the request.
158        method: Method,
159        /// Request URL.
160        url: Box<Url>,
161        /// Human-readable message returned by 3x-ui.
162        message: String,
163    },
164
165    /// The panel response did not match the documented schema.
166    #[error("could not decode response from {method} {url}: {source}")]
167    Decode {
168        /// HTTP method used for the request.
169        method: Method,
170        /// Request URL.
171        url: Box<Url>,
172        /// JSON decoding error.
173        #[source]
174        source: serde_json::Error,
175    },
176
177    /// A text endpoint returned bytes that are not valid UTF-8.
178    #[error("response from {method} {url} was not valid UTF-8: {source}")]
179    Utf8 {
180        /// HTTP method used for the request.
181        method: Method,
182        /// Redacted request URL.
183        url: Box<Url>,
184        /// Underlying UTF-8 decoding error.
185        #[source]
186        source: FromUtf8Error,
187    },
188
189    /// Establishing the authenticated WebSocket exceeded the configured
190    /// connection timeout.
191    #[error("WebSocket connection to {url} timed out after {timeout:?}")]
192    WebSocketConnectTimeout {
193        /// WebSocket endpoint.
194        url: Box<Url>,
195        /// Configured connection timeout.
196        timeout: Duration,
197    },
198
199    /// A WebSocket handshake or frame operation failed.
200    #[error("WebSocket operation on {url} failed: {source}")]
201    WebSocket {
202        /// WebSocket endpoint.
203        url: Box<Url>,
204        /// Underlying protocol/transport error.
205        #[source]
206        source: Box<tokio_tungstenite::tungstenite::Error>,
207    },
208
209    /// An explicit proxy could not establish a WebSocket tunnel.
210    #[error("{scheme} proxy could not connect WebSocket to {url}: {source}")]
211    Proxy {
212        /// Proxy protocol in use. The proxy endpoint is deliberately omitted.
213        scheme: ProxyScheme,
214        /// Target WebSocket endpoint, never the proxy endpoint.
215        url: Box<Url>,
216        /// Underlying proxy transport or negotiation failure.
217        #[source]
218        source: Box<ProxyError>,
219    },
220
221    /// A WebSocket text message did not match its source-defined JSON shape.
222    #[error("could not decode WebSocket {message_type:?} event: {source}")]
223    EventDecode {
224        /// Message name when the envelope was valid enough to identify it.
225        message_type: Option<String>,
226        /// Underlying JSON decoding error.
227        #[source]
228        source: serde_json::Error,
229    },
230
231    /// The panel sent a data-frame format not used by the v3.8.5 protocol.
232    #[error("unexpected WebSocket {kind} frame from {url}")]
233    UnexpectedWebSocketFrame {
234        /// WebSocket endpoint.
235        url: Box<Url>,
236        /// Human-readable frame kind.
237        kind: &'static str,
238    },
239
240    /// A successful response omitted its documented `obj` value.
241    #[error("response from {method} {url} did not contain obj")]
242    MissingObject {
243        /// HTTP method used for the request.
244        method: Method,
245        /// Request URL.
246        url: Box<Url>,
247    },
248}
249
250impl Error {
251    /// Returns the stable category of this error.
252    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    /// Returns the HTTP status that caused this error, when available.
274    ///
275    /// Authentication errors report 401 or 403 even though they have dedicated
276    /// variants. Application-level [`Error::Api`] responses used HTTP success
277    /// and therefore return `None`.
278    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    /// Returns the HTTP method associated with the failed operation.
288    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    /// Returns the request or WebSocket URL associated with the failure.
304    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    /// Returns `true` when authentication must be supplied or refreshed.
324    pub const fn is_unauthorized(&self) -> bool {
325        matches!(self, Self::Unauthorized { .. })
326    }
327
328    /// Returns `true` when the operation was rejected with HTTP 403.
329    pub const fn is_forbidden(&self) -> bool {
330        matches!(self, Self::Forbidden { .. })
331    }
332
333    /// Returns `true` when the panel asked the caller to slow down.
334    pub const fn is_rate_limited(&self) -> bool {
335        matches!(self.status(), Some(StatusCode::TOO_MANY_REQUESTS))
336    }
337
338    /// Returns `true` when the response body exceeded its configured limit.
339    pub const fn is_response_too_large(&self) -> bool {
340        matches!(self, Self::ResponseTooLarge { .. })
341    }
342
343    /// Returns the configured response body limit that was exceeded.
344    pub const fn response_body_limit(&self) -> Option<usize> {
345        match self {
346            Self::ResponseTooLarge { limit, .. } => Some(*limit),
347            _ => None,
348        }
349    }
350
351    /// Returns the server-advertised `Content-Length` for an oversized body.
352    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    /// Returns `true` for an HTTP 5xx response.
360    pub fn is_server_error(&self) -> bool {
361        self.status().is_some_and(|status| status.is_server_error())
362    }
363
364    /// Returns `true` when an HTTP or WebSocket transport timed out.
365    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
380/// Result type used throughout the SDK.
381pub 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}