Skip to main content

whatsapp_rust/
error.rs

1//! Typed recovery over the error chain.
2//!
3//! [`ErrorChainExt`] answers a few questions about any error without the caller
4//! knowing its concrete type, so it need not walk [`std::error::Error::source`]
5//! itself nor learn that three different types can carry a server rejection. It
6//! is a read-only view with a blanket impl: a domain error added later answers
7//! the same questions without implementing anything, and no new error type or
8//! parallel hierarchy exists.
9//!
10//! ```no_run
11//! use whatsapp_rust::ErrorChainExt;
12//!
13//! # fn demo(err: whatsapp_rust::features::GroupError) {
14//! if let Some(rejection) = err.server_rejection() {
15//!     eprintln!("server said {}: {}", rejection.code, rejection.text);
16//! } else if err.is_transport_unavailable() {
17//!     eprintln!("offline, will retry");
18//! }
19//! # }
20//! ```
21//!
22//! From an `anyhow::Error`, annotate the cast: it carries two
23//! `AsRef<dyn Error>` impls and both are covered here.
24//!
25//! ```no_run
26//! # use whatsapp_rust::ErrorChainExt;
27//! # fn demo(err: whatsapp_rust::anyhow::Error) {
28//! let cause: &(dyn std::error::Error + 'static) = err.as_ref();
29//! let _ = cause.server_rejection();
30//! # }
31//! ```
32//!
33//! # Scope
34//!
35//! Only questions the crate already answers internally are exposed. There is
36//! deliberately no "invalid input", "protocol violation" or "internal" query:
37//! each domain spells those as its own `InvalidRequest(String)`-style variant
38//! with no shared representation, so any such split would be invented here
39//! rather than recovered. [`crate::features::MexError::ExtensionError`] is
40//! likewise not reported as a server rejection: its `code` is a GraphQL
41//! extension code, a different space from the IQ `code` attribute, and merging
42//! the two would make the number meaningless.
43//!
44//! An HTTP status is kept apart from an IQ `code` for the same reason, but it
45//! *is* recoverable — under its own question, [`ErrorChainExt::http_status`].
46//! The media paths refuse a CDN or upload host on a status the caller often has
47//! to act on differently from a stanza rejection, so the fact is modelled; what
48//! is avoided is one accessor that answers for both layers and leaves the
49//! caller unable to tell which refused.
50//!
51//! # Rendering
52//!
53//! A wrapping variant renders exactly what it wraps, so each error's own
54//! `Display` is unchanged. A caller that concatenates the whole chain will see
55//! consecutive nodes repeat the same sentence, which is the price of keeping
56//! the wrapped error downcastable. Print the innermost cause, or collapse equal
57//! neighbours, rather than joining every node.
58
59use std::error::Error as StdError;
60
61use crate::http::HttpStatusError;
62use crate::request::IqError as ClientIqError;
63use wacore::request::{IqError as CoreIqError, ServerErrorCode};
64use wacore::store::error::StoreError;
65
66/// A rejection the server sent in response to a request.
67///
68/// Borrowed from whichever error in the chain carried it, so recovering one
69/// costs no allocation.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[non_exhaustive]
72pub struct ServerRejection<'a> {
73    /// The `code` attribute of the `<error>` node.
74    pub code: u16,
75    /// The `text` attribute; empty when the server sent none.
76    pub text: &'a str,
77    /// XMPP error class from the `type` attribute (e.g. `"wait"` vs
78    /// `"cancel"`); `None` if absent.
79    pub error_type: Option<&'a str>,
80    /// Server-directed retry delay in seconds from the `backoff` attribute;
81    /// `None` if absent.
82    pub backoff: Option<u32>,
83}
84
85/// Iterator over an error and everything reachable from its
86/// [`source`](StdError::source).
87#[derive(Clone)]
88pub struct Sources<'a> {
89    next: Option<&'a (dyn StdError + 'static)>,
90}
91
92impl<'a> Iterator for Sources<'a> {
93    type Item = &'a (dyn StdError + 'static);
94
95    fn next(&mut self) -> Option<Self::Item> {
96        let current = self.next?;
97        self.next = current.source();
98        Some(current)
99    }
100}
101
102/// Answers a few questions about any error without knowing its concrete type.
103///
104/// Implemented for every [`std::error::Error`]; see the [module
105/// docs](self) for what is deliberately left out.
106pub trait ErrorChainExt {
107    /// The receiver as a trait object, so the provided methods can walk it.
108    #[doc(hidden)]
109    fn as_dyn_error(&self) -> &(dyn StdError + 'static);
110
111    /// This error and every error reachable from it, nearest first.
112    ///
113    /// Use this to recover a domain type this trait does not model.
114    fn sources(&self) -> Sources<'_> {
115        Sources {
116            next: Some(self.as_dyn_error()),
117        }
118    }
119
120    /// The server rejection behind this error, if any.
121    ///
122    /// Reports IQ-level rejections only. See the [module docs](self) for why
123    /// MEX extension errors are excluded.
124    fn server_rejection(&self) -> Option<ServerRejection<'_>> {
125        self.sources().find_map(server_rejection_of)
126    }
127
128    /// The HTTP status behind this error, if any.
129    ///
130    /// Reports a status the client refused a *completed* HTTP exchange on —
131    /// today the media download and upload paths. `None` means no HTTP
132    /// exchange got that far: the request never left, or the failure was ours.
133    /// That distinction is the point. A caller serving media onwards has to
134    /// tell "the CDN says this is gone" from "we broke", and only the first is
135    /// an upstream status it may pass on.
136    ///
137    /// Separate from [`server_rejection`](Self::server_rejection), which
138    /// answers for IQ stanzas. The two numbers come from different layers, and
139    /// a `403` from a CDN and a `403` from the chat server call for different
140    /// things; merging them would name the number while hiding which one to
141    /// act on. Same reasoning the [module docs](self) give for leaving
142    /// `MexError`'s GraphQL code out of `server_rejection`.
143    fn http_status(&self) -> Option<u16> {
144        self.sources()
145            .find_map(|cause| cause.downcast_ref::<HttpStatusError>())
146            .map(|refused| refused.status)
147    }
148
149    /// Whether the operation ran out of time waiting for the server.
150    ///
151    /// Covers a request that got no answer and a connect or handshake step
152    /// that never completed.
153    fn is_timeout(&self) -> bool {
154        self.sources().any(|cause| {
155            if let Some(iq) = cause.downcast_ref::<ClientIqError>() {
156                return iq.is_timeout();
157            }
158            if let Some(iq) = cause.downcast_ref::<CoreIqError>() {
159                return iq.is_timeout();
160            }
161            if let Some(connect) = cause.downcast_ref::<crate::client::ConnectError>() {
162                return connect.is_timeout();
163            }
164            cause
165                .downcast_ref::<crate::handshake::HandshakeError>()
166                .is_some_and(crate::handshake::HandshakeError::is_timeout)
167        })
168    }
169
170    /// Whether the failure was the transport being gone rather than the
171    /// operation being refused.
172    ///
173    /// Mirrors the judgement the send and receive paths already make when
174    /// deciding whether a failure is worth retrying.
175    fn is_transport_unavailable(&self) -> bool {
176        self.sources().any(|cause| {
177            if let Some(client) = cause.downcast_ref::<crate::client::ClientError>() {
178                return client.is_transport_unavailable();
179            }
180            if let Some(iq) = cause.downcast_ref::<ClientIqError>() {
181                return iq.is_transport_unavailable();
182            }
183            if let Some(encrypt) = cause.downcast_ref::<crate::socket::error::EncryptSendError>() {
184                return encrypt.is_transport_unavailable();
185            }
186            cause
187                .downcast_ref::<CoreIqError>()
188                .is_some_and(CoreIqError::is_transport_unavailable)
189        })
190    }
191
192    /// The persistence failure behind this error, if any.
193    fn store_failure(&self) -> Option<&StoreError> {
194        self.sources().find_map(|cause| cause.downcast_ref())
195    }
196}
197
198fn server_rejection_of<'a>(cause: &'a (dyn StdError + 'static)) -> Option<ServerRejection<'a>> {
199    if let Some(CoreIqError::ServerError {
200        code,
201        text,
202        error_type,
203        backoff,
204    }) = cause.downcast_ref::<CoreIqError>()
205    {
206        return Some(ServerRejection {
207            code: *code,
208            text,
209            error_type: error_type.as_deref(),
210            backoff: *backoff,
211        });
212    }
213    if let Some(ClientIqError::ServerError {
214        code,
215        text,
216        error_type,
217        backoff,
218    }) = cause.downcast_ref::<ClientIqError>()
219    {
220        return Some(ServerRejection {
221            code: *code,
222            text,
223            error_type: error_type.as_deref(),
224            backoff: *backoff,
225        });
226    }
227    let shared = cause.downcast_ref::<ServerErrorCode>()?;
228    Some(ServerRejection {
229        code: shared.code,
230        text: &shared.text,
231        error_type: shared.error_type.as_deref(),
232        backoff: shared.backoff,
233    })
234}
235
236impl<E: StdError + 'static> ErrorChainExt for E {
237    fn as_dyn_error(&self) -> &(dyn StdError + 'static) {
238        self
239    }
240}
241
242impl ErrorChainExt for dyn StdError + 'static {
243    fn as_dyn_error(&self) -> &(dyn StdError + 'static) {
244        self
245    }
246}
247
248// `anyhow::Error` derefs to this shape, so a caller holding one can reach the
249// same answers via `err.as_ref()` without this crate naming `anyhow` in the API.
250impl ErrorChainExt for dyn StdError + Send + Sync + 'static {
251    fn as_dyn_error(&self) -> &(dyn StdError + 'static) {
252        self
253    }
254}