Skip to main content
As of PR #893, all feature-domain APIs return a typed, domain-specific error instead of anyhow::Error. Use ? to propagate errors into any anyhow context — all types implement std::error::Error and are #[non_exhaustive], so your existing error-handling code compiles unchanged. Lower-level APIs such as media upload/download are not covered by this page and may still surface anyhow::Error directly. As of PR #1090, the connect/lifecycle surface (Client::connect, wait_for_socket, wait_for_connected), the background Signal maintenance surface (Client::rotate_signed_pre_key, Client::flush_pending_signal_state), and the message-edit target-key resolvers (EncryptedEdit::original_sender_jid, SecretEncrypted::original_sender_jid/original_sender_for_dispatch) also return typed errors — see ConnectError, SignalMaintenanceError, and MessageEditError below. Client::logout() is now infallible ((), not Result<(), _>): the deregistration IQ it sends is best-effort, so there was nothing for a caller to branch on. As of PR #1100, every wrapping variant that used to be #[error(transparent)] is #[error("{0}")] instead — same Display output, but the wrapped error is now reachable via std::error::Error::source() instead of being erased. ErrorChainExt is a new extension trait, implemented for every std::error::Error, that walks that chain for you: server_rejection(), is_timeout(), is_transport_unavailable(), and store_failure() answer type-agnostically across every error on this page, without downcasting or string-matching. See Error chain recovery below. As of PR #1195, ErrorChainExt also answers http_status() — the HTTP status code behind a refused download, upload, sticker-pack fetch, or app-version fetch, recovered from a HttpStatusError node the same way server_rejection() recovers an IQ rejection. This is the one place the “lower-level APIs … may still surface anyhow::Error directly” caveat above gets a typed escape hatch: download/upload still return anyhow::Error, but the status inside that error is now recoverable by type instead of by parsing Display text. See Error chain recovery below. As of PR #1257, IqError::ServerError also carries response: RejectionStanza — the type="error" stanza itself, handed over whole the same way a type="result" response already was, alongside the four fields this crate parses off it (code, text, error_type, backoff). See RejectionStanza below.

Error hierarchy

Domain errors that embed IqError or ClientError via #[from] propagate those failures automatically via ?. Some errors (e.g. AppStateError, SignalError) use internal anyhow::Error wrapping instead and do not have Iq or Client variants.
ConnectError and SignalMaintenanceError are not variants of ClientError — they are separate top-level error types returned directly by their respective methods (previously those methods returned bare anyhow::Error). ClientError::AlreadyConnected was removed in PR #1090; the equivalent case now lives on ConnectError::AlreadyConnected.

Error chain recovery

Added in PR #1100. Before this, #[error(transparent)] on a wrapping variant made Display forward to the inner error but also made source() forward to that error’s own source — so the wrapped error itself was never reachable, and a consumer walking source() to find (say) a 403 from the server lost the typed node and was left parsing Display text. Every transparent in the crate (46 occurrences) is now #[error("{0}")]: byte-identical Display output, but source() now returns the wrapped error itself, so it can be downcast. ErrorChainExt is a blanket-implemented trait (impl<E: std::error::Error> ErrorChainExt for E) that turns that walk into a few type-agnostic questions, so a domain error added later answers them without implementing anything:
  • sources() — an iterator over the error and everything reachable from it via source(), nearest first. Use this to recover a domain type the other methods don’t model.
  • server_rejection() — the ServerRejection behind this error, if any of the three types that can carry one (wacore::request::IqError, crate::request::IqError, or the crate-boundary ServerErrorCode) appear anywhere in the chain. Reports IQ-level rejections only — MexError::ExtensionError’s code is a GraphQL extension code, a different space from the IQ code attribute, so it is deliberately not reported here.
  • http_status() — added in PR #1195. Recovers the HTTP status code behind this error, if any, from a HttpStatusError node anywhere in the chain. Populated by Client::download/download_to_writer/download_from_params*, Client::upload/upload_stream, Client::fetch_sticker_pack, and the internal app-version (sw.js) fetch behind Client::connect. Each of those attaches the status only when the client refused a completed HTTP exchange. None does not mean no HTTP exchange happened — a body that downloaded fine and then failed decryption or hash validation also reports None, since no refused status was ever attached. Treat None as “no typed refusal is on the chain,” not as “nothing came back from the CDN,” and inspect the underlying cause instead of assuming it is always the caller’s own bug. Kept separate from server_rejection() — a CDN refusing a byte range and the chat server refusing a stanza are different layers with different remedies, so one accessor answering for both would report a number while hiding which thing to retry.
  • is_timeout() — whether the operation ran out of time: a request that got no answer, or a connect/handshake step that never completed.
  • is_transport_unavailable() — whether the failure was the transport being gone (disconnected, socket/channel closed) rather than the operation being refused. Mirrors the judgement the send and receive paths already make internally when deciding whether a failure is worth retrying.
  • store_failure() — the StoreError behind this error, if a persistence backend failed anywhere in the chain.
From a caller holding anyhow::Error rather than a typed error, annotate the cast — anyhow::Error has two AsRef<dyn Error> impls, and both are covered:
http_status() follows the same pattern — useful for download/upload, which still return bare anyhow::Error:

ServerRejection

Borrowed from whichever error in the chain carried it, so recovering one costs no allocation.

RejectionStanza

Added in PR #1257. IqError::ServerError’s response field: the <iq type="error"> stanza the receive path decoded, kept as-is rather than reduced to the four fields ServerRejection exposes above. It wraps the same Arc<OwnedNodeRef> the success path already hands back, so attaching it to the error costs one refcount bump, not a copy.
ServerRejection’s four fields cover what WA Web’s own parseIqResponse reads off an error; RejectionStanza is the escape hatch for everything that parser (and this crate’s) leaves unread — further <iq>/<error> attributes, <error> children such as XMPP application-condition elements, and the raw bytes, which are the only faithful material for logging or replaying a rejection. Deref<Target = OwnedNodeRef> (see OwnedNodeRef) keeps every node accessor reachable directly on the wrapper — response.tag(), response.attrs(), response.get_optional_child(...), or response.get() for the underlying NodeRef. Debug is overridden to print only the tag (<iq>), not the stanza’s contents. This matters because background IQ failures on the connect path (the post-connect active IQ, props, blocklist, privacy settings) are logged with {e:?} at warn level, and an error stanza’s attributes or children can carry a JID — a straight derive would have written that into production logs where before only the four summarized fields went. Read the node explicitly (response.get(), response.attrs(), …) when you need its contents. Re-exported from the crate root as whatsapp_rust::RejectionStanza, and from whatsapp_rust::prelude.

HttpStatusError

Added in PR #1195, in whatsapp_rust::http. Carries the status of an HTTP exchange the client refused — the source node http_status() looks for.
The download and upload paths attach this as the source of the anyhow::Error they return — anyhow::Error::new(HttpStatusError { status }).context("Download failed with status: 403") — so the message a caller logs is unchanged while the status becomes reachable by type via http_status() instead of only by parsing that message. Re-exported as whatsapp_rust::http::HttpStatusError.
Rendering changed alongside the source() fix: a wrapping variant’s Display still prints exactly what it wraps, so code that concatenates every node in a chain (a logging layer, a display_chain helper) now sees the same sentence repeated once per wrapping variant — e.g. CommunityError::Group(GroupError::Iq(..)) is three nodes rendering one sentence three times. That repetition is the cost of keeping the wrapped error downcastable. Print the innermost cause, or collapse equal neighbours, rather than joining every node in the chain.
ErrorChainExt, ServerRejection, and Sources are re-exported from the crate root (whatsapp_rust::ErrorChainExt, whatsapp_rust::ServerRejection, whatsapp_rust::Sources). HttpStatusError lives at whatsapp_rust::http::HttpStatusError — it is not re-exported from the crate root, since most callers only need http_status() and never need to name the type itself.

Domain error types

Type definitions

SendError

GroupError

DescriptionConflict (added in PR #1097) is returned by Groups::set_description when the prev token no longer matches the group’s current description — see GroupError in the groups reference.

BlockingError

BusinessError

get_catalog, get_collections, and get_order go over MEX; update_profile, set_cover_photo, and remove_cover_photo go over IQ. InvalidUpdate is client-side validation on update_profile — see BusinessProfileUpdateError in the business reference for every rejection reason.

AppStateError

Shared by ChatActions and Labels.

ChatStateError

CommunityError

ContactError

NewsletterError

PollError

ProfileError

SignalError

TcTokenError

MediaReuploadError

PresenceError

ConnectError

Returned by Client::connect, Client::wait_for_socket, and Client::wait_for_connected. Added in PR #1090, replacing bare anyhow::Error on all three methods. As of PR #1258, connect()’s success case changed too — it resolves to Result<Connection<'_>, ConnectError> rather than Result<(), ConnectError>; see Connection.
Variants:
  • AlreadyConnected — a connection is already up, or another connect() attempt is already in flight. This is the old ClientError::AlreadyConnected case, moved here.
  • NotActivated — construction never completed (only reachable with the client-lifecycle feature), so the attempt was rejected before any I/O.
  • Shutdown — added in PR #1258. The client was already shut down (disconnect(), logout(), or signal_shutdown_sync()) before or during this connect() attempt. Shutdown is final and non-reversible, so this is refused rather than reviving a client the application was already told is gone — build a new client instead of reconnecting this one.
  • Timeout — a step of the connect flow ran out of time. stage says which one (see ConnectStage below); wait_for_socket/wait_for_connected always report Socket/Ready respectively.
  • Version / Transport — the app-version resolution or transport factory failed outright (not a timeout). As of PR #1195, if Version was caused by a non-2xx response fetching the app version (sw.js), the status is recoverable via err.http_status() (ErrorChainExt) instead of only appearing in the message.
  • Handshake — the Noise handshake failed after the transport was up. Wraps HandshakeError (see WebSocket & Noise Protocol) via #[from], so ? still works and matches!(err, ConnectError::Handshake(e) if e.is_transient()) replaces the old err.downcast_ref::<HandshakeError>() pattern for deciding whether a failed reconnect attempt is worth retrying.
Added in PR #1100: ConnectError::is_timeout() is an exhaustively-matched method that reports true for Timeout and for a Handshake(e) where e.is_timeout() (HandshakeError gained the same method). Prefer ErrorChainExt::is_timeout() unless you specifically hold a ConnectError and want to skip the chain walk.

ConnectStage

The step of the connect flow a ConnectError::Timeout refers to:
Both ConnectError and ConnectStage are re-exported from the crate root (whatsapp_rust::ConnectError, whatsapp_rust::ConnectStage) and from whatsapp_rust::prelude.

SignalMaintenanceError

Returned by Client::rotate_signed_pre_key and Client::flush_pending_signal_state. Added in PR #1090, replacing bare anyhow::Error on both methods. The split that matters to a caller is corruption versus everything else: CorruptKey will keep failing until the stored material is replaced, while Storage, Iq, Signal, and DrainCommitFailed are worth retrying on the same client — none of them mean the local key material itself is bad. DrainShuttingDown is different again — it fires because the client itself is being torn down, so retrying the same call on that instance fails the same way; the only recovery is a fresh client.
Variants:
  • CorruptKey — key material is unusable: bad encoding, or a missing/wrong-sized field on a staged signed pre-key. Almost always means a retry would read back the same bytes, so this is not worth retrying without intervention.
  • Storage — the storage backend failed a read, write, or flush. The typed backend error stays reachable via std::error::Error::source().
  • Iq — the rotation IQ was rejected by the server or never reached it (embeds IqError via #[from]).
  • Signal — a Signal primitive failed (e.g. signing the new signed pre-key).
  • DrainCommitFailed — the inbound drain batch could not be committed, so the Signal cache was deliberately left unflushed and the server will redeliver.
  • DrainShuttingDown — the client is going away while an inbound drain is active; flushing would persist ratchet advances whose messages have no durable row. Not worth retrying on this client — it will report the same error until the client is dropped and replaced.
SignalMaintenanceError is re-exported from the crate root as whatsapp_rust::SignalMaintenanceError. Signal::* methods on the Signal struct are unaffected — SignalError already had Internal/Protocol variants and now converts SignalMaintenanceError via From (mapping Signal(e) to SignalError::Protocol(e) and everything else to SignalError::Internal).

MessageEditError

Returned by EncryptedEdit::original_sender_jid, SecretEncrypted::original_sender_jid, and SecretEncrypted::original_sender_for_dispatch — the target-key sender resolvers used when decrypting secret_encrypted_message envelopes (message edits, poll edits/add-option, event edits). Added in PR #1090, replacing bare anyhow::Error. See Decrypting secret-encrypted envelopes for how these methods are used.
Variants:
  • InvalidTargetJid — a JID carried by the target message key did not parse. field names the offending wire field ("participant" or "remoteJid"); the underlying JidError is available via source().
  • MissingTargetSender — the target key carried neither participant nor remote_jid, and from_me was not Some(true), so no author can be derived from it.
Both variants mean the peer sent a target message key that cannot be attributed — retrying the same envelope yields the same result. MessageEditError is re-exported from the crate root as whatsapp_rust::MessageEditError.

ClientError (base type)

AlreadyConnected was removed from ClientError in PR #1090 — it had no remaining constructor once Client::connect() moved to ConnectError. If you matched on ClientError::AlreadyConnected, switch to ConnectError::AlreadyConnected.

IqError (base type)

IqError::ClientState holds a Box<ClientError> (not ClientError directly) to break the mutual-size cycle between ClientError and IqError. Pattern matching needs dereferencing: IqError::ClientState(e) => { /* *e is a ClientError */ }.
Added in PR #1100: wacore::request::IqError gained public is_timeout() (true only for Timeout) and is_transport_unavailable() (true for NotConnected, Disconnected, and InternalChannelClosed) methods, each an exhaustive match so a future variant has to be classified rather than silently defaulting to false. whatsapp_rust::request::IqError (the crate-level type shown above, with the extra Socket/EncryptSend/ClientState/EncodeError/ParseError variants) makes the same judgement internally but does not expose it publicly — go through ErrorChainExt instead, which handles both types. Added in PR #1257: ServerError carries response: RejectionStanza — see RejectionStanza above and the migration note below. Matching with .. is unaffected by this field. A match that already names all four former fields without .. needs .. added (or response bound too) — Rust rejects a struct pattern missing a field with E0027. Constructing the variant by hand (mainly test fixtures) needs updating the same way.

Migration guide

From anyhow::Error

If your handler used ? into anyhow:
If you were matching on anyhow::Error downcasts, switch to typed matching:

IqError::ClientState boxing

From PR #1090: connect()/logout()/Signal maintenance

As of PR #1258, connect()’s Ok case changed too — this snippet only shows the error-matching migration, so it still discards a successful connection. See the connect() migration for the full picture:

From PR #1100: error chain recovery

If you were walking source() by hand to recover a server rejection or classify a failure, switch to ErrorChainExt. This is the shape the crate’s own internal helper had before PR #1100 (ClientError::is_transport_unavailable is public; the IqError half required a call this crate could make on its own type but a downstream consumer could not, since it wasn’t exposed):
If you concatenated an error’s full chain (e.g. a logging layer joining every source() node into one string), be aware the text changed: a wrapping variant still renders exactly what it wraps, so #[error(transparent)] becoming #[error("{0}")] means a chain like CommunityError::Group(GroupError::Iq(..)) now repeats the same sentence once per wrapping node instead of once. Each error’s own Display output — what you get from {e} on a single error value — is unchanged. Print the innermost cause, or collapse equal neighbours, rather than joining every node. #[non_exhaustive] was added to four error enums that were missing it: wacore::pair_code::PairCodeError, wacore::shortcake::ShortcakeError, wacore::iq::chatstate::ChatstateParseError, and wacore::iq::dirty::DirtyBitParseError. An exhaustive match on any of these from outside their defining crate now fails with E0004; add a _ => {} arm.

From PR #1195: recovering an HTTP status by type

If you were matching on the text of a download/upload error to classify it (e.g. e.to_string().contains("403")), switch to ErrorChainExt::http_status():
Messages are unchanged either way — http_status() is purely additive, recovering a fact that was already in the text but previously reachable only by parsing it.

From PR #1257: ServerError carries the rejection stanza

IqError::ServerError gained a response: RejectionStanza field carrying the type="error" stanza verbatim, alongside the four fields it already parsed off it. Matching with .. is unaffected — this is the common case, and nearly every match in the codebase already used it:
If you matched all four former fields by name without .., the pattern now fails to compile with E0027 (“pattern does not mention field response”) — add .., or bind response too:
Constructing the variant by hand — mainly test fixtures — now needs the stanza it was rejected with:
A fixture with no real wire response can build one directly, but OwnedNodeRef::new expects node bytes with the format byte already stripped, not the raw output of marshal — the same unpack step the receive path runs ahead of every OwnedNodeRef::new call:
From<wacore::request::IqError> for whatsapp_rust::request::IqError is also removed — a bare From conversion has no response available to attach, which is exactly the material this change stops discarding. Replace it with IqError::from_response: