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}