Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ hmac = { workspace = true }
libc = "0.2"
metrics-exporter-prometheus = "0.18"
sha2 = { workspace = true }
# Parses the crate sources in tests/error_surface.rs. Text scanning silently
# missed error enums whose doc comments carried unbalanced braces.
syn = { version = "3.0", features = ["full", "parsing"] }
tokio = { workspace = true, features = ["full", "test-util"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { workspace = true, features = ["v4"] }
Expand Down
2 changes: 1 addition & 1 deletion src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ pub enum BotBuilderError {
/// Initializing the device row in the storage backend failed.
#[error("failed to initialize the device store: {0}")]
Store(#[from] StoreError),
#[error(transparent)]
#[error("{0}")]
Client(#[from] ClientBuilderError),
}

Expand Down
27 changes: 22 additions & 5 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,9 +569,9 @@ pub enum ClientError {
#[error("IQ request failed: {0}")]
Iq(#[from] crate::request::IqError),
/// Last-resort catch-all for internal failures threaded through `?` that do
/// not (yet) have a dedicated variant. Transparent so the underlying
/// error's `Display`/source chain is preserved.
#[error(transparent)]
/// not (yet) have a dedicated variant. `Display` forwards to the inner
/// error while `source()` still exposes it for downcast.
#[error("{0}")]
Internal(#[from] anyhow::Error),
}

Expand Down Expand Up @@ -625,7 +625,7 @@ pub enum ConnectError {
#[error("failed to open transport")]
Transport(#[source] anyhow::Error),
/// The noise handshake failed after the transport was up.
#[error(transparent)]
#[error("{0}")]
Handshake(#[from] handshake::HandshakeError),
}

Expand All @@ -650,7 +650,7 @@ pub enum SignalMaintenanceError {
#[error("IQ request failed: {0}")]
Iq(#[from] crate::request::IqError),
/// A Signal primitive failed (e.g. signing the new signed pre-key).
#[error(transparent)]
#[error("{0}")]
Signal(#[from] wacore::libsignal::protocol::SignalProtocolError),
/// The inbound drain batch could not be committed, so the Signal cache was
/// left unflushed on purpose and the server redelivers those messages.
Expand All @@ -664,6 +664,23 @@ pub enum SignalMaintenanceError {
DrainShuttingDown,
}

impl ConnectError {
/// A step of the connect flow ran out of time.
///
/// Matched exhaustively so a new variant has to be classified here rather
/// than defaulting to "not a timeout" unnoticed.
pub fn is_timeout(&self) -> bool {
match self {
ConnectError::Timeout { .. } => true,
ConnectError::Handshake(handshake) => handshake.is_timeout(),
ConnectError::AlreadyConnected
| ConnectError::NotActivated
| ConnectError::Version(_)
| ConnectError::Transport(_) => false,
}
}
}

impl ClientError {
pub fn is_transport_unavailable(&self) -> bool {
match self {
Expand Down
2 changes: 1 addition & 1 deletion src/client/voip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ impl Client {
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CallError {
#[error(transparent)]
#[error("{0}")]
Send(#[from] ClientError),
#[error("call_id cannot be empty")]
EmptyCallId,
Expand Down
225 changes: 225 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//! Typed recovery over the error chain.
//!
//! [`ErrorChainExt`] answers a few questions about any error without the caller
//! knowing its concrete type, so it need not walk [`std::error::Error::source`]
//! itself nor learn that three different types can carry a server rejection. It
//! is a read-only view with a blanket impl: a domain error added later answers
//! the same questions without implementing anything, and no new error type or
//! parallel hierarchy exists.
//!
//! ```no_run
//! use whatsapp_rust::ErrorChainExt;
//!
//! # fn demo(err: whatsapp_rust::features::GroupError) {
//! if let Some(rejection) = err.server_rejection() {
//! eprintln!("server said {}: {}", rejection.code, rejection.text);
//! } else if err.is_transport_unavailable() {
//! eprintln!("offline, will retry");
//! }
//! # }
//! ```
//!
//! From an `anyhow::Error`, annotate the cast: it carries two
//! `AsRef<dyn Error>` impls and both are covered here.
//!
//! ```no_run
//! # use whatsapp_rust::ErrorChainExt;
//! # fn demo(err: whatsapp_rust::anyhow::Error) {
//! let cause: &(dyn std::error::Error + 'static) = err.as_ref();
//! let _ = cause.server_rejection();
//! # }
//! ```
Comment thread
jlucaso1 marked this conversation as resolved.
//!
//! # Scope
//!
//! Only questions the crate already answers internally are exposed. There is
//! deliberately no "invalid input", "protocol violation" or "internal" query:
//! each domain spells those as its own `InvalidRequest(String)`-style variant
//! with no shared representation, so any such split would be invented here
//! rather than recovered. [`crate::features::MexError::ExtensionError`] is
//! likewise not reported as a server rejection: its `code` is a GraphQL
//! extension code, a different space from the IQ `code` attribute, and merging
//! the two would make the number meaningless.
//!
//! # Rendering
//!
//! A wrapping variant renders exactly what it wraps, so each error's own
//! `Display` is unchanged. A caller that concatenates the whole chain will see
//! consecutive nodes repeat the same sentence, which is the price of keeping
//! the wrapped error downcastable. Print the innermost cause, or collapse equal
//! neighbours, rather than joining every node.

use std::error::Error as StdError;

use crate::request::IqError as ClientIqError;
use wacore::request::{IqError as CoreIqError, ServerErrorCode};
use wacore::store::error::StoreError;

/// A rejection the server sent in response to a request.
///
/// Borrowed from whichever error in the chain carried it, so recovering one
/// costs no allocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ServerRejection<'a> {
/// The `code` attribute of the `<error>` node.
pub code: u16,
/// The `text` attribute; empty when the server sent none.
pub text: &'a str,
/// XMPP error class from the `type` attribute (e.g. `"wait"` vs
/// `"cancel"`); `None` if absent.
pub error_type: Option<&'a str>,
/// Server-directed retry delay in seconds from the `backoff` attribute;
/// `None` if absent.
pub backoff: Option<u32>,
}

/// Iterator over an error and everything reachable from its
/// [`source`](StdError::source).
#[derive(Clone)]
pub struct Sources<'a> {
next: Option<&'a (dyn StdError + 'static)>,
}

impl<'a> Iterator for Sources<'a> {
type Item = &'a (dyn StdError + 'static);

fn next(&mut self) -> Option<Self::Item> {
let current = self.next?;
self.next = current.source();
Some(current)
}
}

/// Answers a few questions about any error without knowing its concrete type.
///
/// Implemented for every [`std::error::Error`]; see the [module
/// docs](self) for what is deliberately left out.
pub trait ErrorChainExt {
/// The receiver as a trait object, so the provided methods can walk it.
#[doc(hidden)]
fn as_dyn_error(&self) -> &(dyn StdError + 'static);

/// This error and every error reachable from it, nearest first.
///
/// Use this to recover a domain type this trait does not model.
fn sources(&self) -> Sources<'_> {
Sources {
next: Some(self.as_dyn_error()),
}
}

/// The server rejection behind this error, if any.
///
/// Reports IQ-level rejections only. See the [module docs](self) for why
/// MEX extension errors are excluded.
fn server_rejection(&self) -> Option<ServerRejection<'_>> {
self.sources().find_map(server_rejection_of)
}

/// Whether the operation ran out of time waiting for the server.
///
/// Covers a request that got no answer and a connect or handshake step
/// that never completed.
fn is_timeout(&self) -> bool {
self.sources().any(|cause| {
if let Some(iq) = cause.downcast_ref::<ClientIqError>() {
return iq.is_timeout();
}
if let Some(iq) = cause.downcast_ref::<CoreIqError>() {
return iq.is_timeout();
}
if let Some(connect) = cause.downcast_ref::<crate::client::ConnectError>() {
return connect.is_timeout();
}
cause
.downcast_ref::<crate::handshake::HandshakeError>()
.is_some_and(crate::handshake::HandshakeError::is_timeout)
})
}

/// Whether the failure was the transport being gone rather than the
/// operation being refused.
///
/// Mirrors the judgement the send and receive paths already make when
/// deciding whether a failure is worth retrying.
fn is_transport_unavailable(&self) -> bool {
self.sources().any(|cause| {
if let Some(client) = cause.downcast_ref::<crate::client::ClientError>() {
return client.is_transport_unavailable();
}
if let Some(iq) = cause.downcast_ref::<ClientIqError>() {
return iq.is_transport_unavailable();
}
if let Some(encrypt) = cause.downcast_ref::<crate::socket::error::EncryptSendError>() {
return encrypt.is_transport_unavailable();
}
cause
.downcast_ref::<CoreIqError>()
.is_some_and(CoreIqError::is_transport_unavailable)
})
}

/// The persistence failure behind this error, if any.
fn store_failure(&self) -> Option<&StoreError> {
self.sources().find_map(|cause| cause.downcast_ref())
}
}

fn server_rejection_of<'a>(cause: &'a (dyn StdError + 'static)) -> Option<ServerRejection<'a>> {
if let Some(CoreIqError::ServerError {
code,
text,
error_type,
backoff,
}) = cause.downcast_ref::<CoreIqError>()
{
return Some(ServerRejection {
code: *code,
text,
error_type: error_type.as_deref(),
backoff: *backoff,
});
}
if let Some(ClientIqError::ServerError {
code,
text,
error_type,
backoff,
}) = cause.downcast_ref::<ClientIqError>()
{
return Some(ServerRejection {
code: *code,
text,
error_type: error_type.as_deref(),
backoff: *backoff,
});
}
let shared = cause.downcast_ref::<ServerErrorCode>()?;
Some(ServerRejection {
code: shared.code,
text: &shared.text,
error_type: shared.error_type.as_deref(),
backoff: shared.backoff,
})
}

impl<E: StdError + 'static> ErrorChainExt for E {
fn as_dyn_error(&self) -> &(dyn StdError + 'static) {
self
}
}

impl ErrorChainExt for dyn StdError + 'static {
fn as_dyn_error(&self) -> &(dyn StdError + 'static) {
self
}
}

// `anyhow::Error` derefs to this shape, so a caller holding one can reach the
// same answers via `err.as_ref()` without this crate naming `anyhow` in the API.
impl ErrorChainExt for dyn StdError + Send + Sync + 'static {
fn as_dyn_error(&self) -> &(dyn StdError + 'static) {
self
}
}
4 changes: 2 additions & 2 deletions src/features/blocking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ use wacore_binary::Jid;
#[non_exhaustive]
pub enum BlockingError {
/// The IQ to the server failed (transport, timeout, server rejection).
#[error(transparent)]
#[error("{0}")]
Iq(#[from] IqError),
/// The target JID is not a user JID, or has no resolvable LID↔PN mapping
/// (modern WA requires both sides for a block).
#[error("invalid blocklist target: {0}")]
InvalidJid(String),
/// Catch-all for internal failures (e.g. LID/PN store lookup).
#[error(transparent)]
#[error("{0}")]
Internal(#[from] anyhow::Error),
}

Expand Down
2 changes: 1 addition & 1 deletion src/features/chat_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub enum AppStateError {
#[error("invalid app-state request: {0}")]
InvalidRequest(String),
/// Encoding, key lookup, or sending the app-state patch failed.
#[error(transparent)]
#[error("{0}")]
Internal(#[from] anyhow::Error),
}

Expand Down
2 changes: 1 addition & 1 deletion src/features/chatstate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use wacore_binary::builder::NodeBuilder;
#[non_exhaustive]
pub enum ChatStateError {
/// Connection/transport failure sending the `<chatstate>` stanza.
#[error(transparent)]
#[error("{0}")]
Client(#[from] ClientError),
}

Expand Down
6 changes: 3 additions & 3 deletions src/features/community.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ use wacore_binary::Jid;
#[non_exhaustive]
pub enum CommunityError {
/// A `w:g2` IQ to the server failed.
#[error(transparent)]
#[error("{0}")]
Iq(#[from] IqError),
/// A MEX (GraphQL) metadata query/mutation failed or returned bad data.
#[error(transparent)]
#[error("{0}")]
Mex(#[from] MexError),
/// A delegated group operation failed (e.g. setting the community description).
#[error(transparent)]
#[error("{0}")]
Group(#[from] GroupError),
/// The request was malformed or the server response was missing required data.
#[error("invalid community request: {0}")]
Expand Down
2 changes: 1 addition & 1 deletion src/features/contacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub use wacore::stanza::business::VerifiedName;
#[non_exhaustive]
pub enum ContactError {
/// The usync/profile IQ to the server failed.
#[error(transparent)]
#[error("{0}")]
Iq(#[from] IqError),
/// An input JID is not supported for this query (only PN and LID are).
#[error("unsupported contact JID: {0}")]
Expand Down
6 changes: 3 additions & 3 deletions src/features/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ pub use wacore::iq::groups::{
#[non_exhaustive]
pub enum GroupError {
/// A `w:g2` IQ to the server failed (transport, timeout, server rejection).
#[error(transparent)]
#[error("{0}")]
Iq(#[from] IqError),
/// A MEX (GraphQL) group-property mutation failed.
#[error(transparent)]
#[error("{0}")]
Mex(#[from] MexError),
/// The request was malformed (e.g. empty invite code, batch over the limit,
/// expired V4 invite, non-group JID where one is required).
Expand All @@ -57,7 +57,7 @@ pub enum GroupError {
DescriptionConflict,
/// Catch-all for internal failures (LID/PN resolution, the protocol-message
/// send path behind `update_member_label`, cache plumbing).
#[error(transparent)]
#[error("{0}")]
Internal(#[from] anyhow::Error),
}

Expand Down
Loading
Loading