Skip to main content
Public operations expose typed errors with their source chains. Match extensible errors with a fallback arm. Use ? to propagate into an erased anyhow return boundary when needed. Client::download and download_to_writer return ClientDownloadError; upload currently returns anyhow::Error. logout() returns LogoutReport with separate deregistration and shutdown outcomes. See lifecycle reports and download outcomes. ErrorChainExt recovers server_rejection(), http_status(), is_timeout(), is_transport_unavailable(), and store_failure() from typed source chains without parsing display strings. See error chain recovery.

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*, Client::upload/upload_stream, Client::fetch_sticker_pack, and the internal app-version fetch behind Client::connect (sw.js, or the Facebook JS SDK bundle on wasm32; see HTTP Client). 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:
Call http_status() directly on typed download errors. An upload error returned as anyhow::Error uses the annotated as_ref() projection above:

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 retain HttpStatusError in their error source chains. Typed ClientDownloadError and erased upload errors both expose that status through ErrorChainExt::http_status(). 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

NoRecipientDevice — added in PR #1299. A DM had no recipient device available: every resolved recipient device failed encryption, or no recipient device resolved for a non-self destination, so nothing was sent. Distinct from a transport failure: the connection is fine and the message id was never on the wire. The useful retry is SendOptions::default().with_device_freshness(Freshness::Refresh) passed to send(SendRequest), which forces the recipient’s device list to re-resolve instead of reading the cached one. An immediate resend with the same (cached) options would hit the same empty result. Wraps NoRecipientDeviceError, the typed cause from wacore, reachable via source(). PrimaryDeviceRejected — added in PR #1362. The pre-key fetch that establishes sessions ahead of a DM send got back a 406 naming a primary device (device 0), either the recipient’s or the sender’s own, so nothing was built or sent. This mirrors WA Web’s ensureE2ESessions, which throws on a named rejection unless every rejected device is a companion (device != null && device !== DEFAULT_DEVICE_ID). A companion’s 406, and any non-406 rejection code even on a primary, behave as before: refreshed and skipped, not fatal. The device lists the fetch named are already refreshed by the time this returns, so retry immediately — the retry re-resolves them instead of repeating the same question. This is distinct from NoRecipientDevice: that variant means encryption was attempted and failed, or had nothing to attempt, while PrimaryDeviceRejected means the fetch that would have supplied key material was refused outright, before encryption started.

NoRecipientDeviceError

Added in PR #1299, in wacore::send. The typed cause carried by SendError::NoRecipientDevice.
Variants:
  • EncryptionFailed — every device resolved for the recipient was attempted and none produced an <enc> node. attempted is the device count; source is the first per-device failure (a missing session, a refused pre-key bundle), reachable via source().
  • Unresolved — the fan-out held no device for the recipient to begin with, so nothing was attempted and there is no per-device cause. Only returned when the destination is not one of the sender’s own identities — an empty recipient half for a self chat (note to self) is the normal shape, since every resolved device is the sender’s own, and that case still returns Ok.
Not returned when every one of the sender’s own devices fails to encrypt in a self chat — that stays on the pre-existing Internal catch-all, since it isn’t about a recipient at all.

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

Username is returned by Contacts::find_by_username when the given handle can’t be turned into a valid username lookup (wrong length, or a username key usync’s own validation rejects) — see UsernameLookupError in the contacts reference.

NewsletterError

NotFound is returned by Newsletter::get_metadata when no channel exists behind a well-formed newsletter JID. A malformed server response still returns InvalidRequest.

PollError

ProfileError

SignalError

TcTokenError

MediaReuploadError

Reupload errors distinguish missing local LID, conflicting requests, phased timeout or waiter closure, and receipt rejection with its optional numeric code and original response. Client and internal causes use Arc so coalesced callers retain the same cause. See reupload errors.

PresenceError

ConnectError

Returned by Client::connect, Client::wait_for_socket_ready, and Client::wait_for_session_ready. 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 (shutdown(), 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.
  • Paused — added in PR #1265. Client::pause is in effect. Unlike Shutdown this is not final: Client::resume lifts it and connect() works again. Re-checked at every step of the connect graph (version fetch, transport open, handshake, publish), so an attempt already in flight when pause() lands is retracted rather than published, not just refused for attempts that start after.
  • Timeout — a step of the connect flow ran out of time. stage says which one (see ConnectStage below); wait_for_socket_ready/wait_for_session_ready 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, or the Facebook JS SDK bundle on wasm32), the status is recoverable via err.http_status() (ErrorChainExt) instead of only appearing in the message. As of PR #1360, Version is no longer necessarily the outcome of a failed fetch on the wasm32 target: that target’s source is survivable, so a client blocked from reaching it connects on a fallback version instead — see Connected. The native target’s source stays fatal, so Version there is unchanged.
  • 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. Read the pinned target-key resolvers when handling an encrypted add-on.
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.

BotMessageError

Returned by wacore::bot_message::decrypt_bot_message and its private decryption helper. Added in PR #1261, replacing bare anyhow::Result.
Unlike every other error type on this page, BotMessageError lives in the wacore crate, not whatsapp_rust — and its introduction is a breaking change to wacore’s public API: decrypt_bot_message’s return type changed from anyhow::Result<T> to Result<T, BotMessageError>, so a caller matching on the previous anyhow::Error needs to switch to this typed enum.
Variants:
  • InvalidSecretLength — the bot message secret is not the expected size.
  • InvalidIvLength — the IV carried by the payload is not the expected size.
  • PayloadTooShort — the payload is too short to contain what it claims to.
  • KeyDerivation — deriving the decryption key from the secret failed.
  • AuthenticationFailed — the ciphertext did not verify (AES-GCM tag mismatch).
BotMessageError::stage(&self) -> BotMessageFailure classifies which stage of decryption a failure belongs to, without matching every variant by name:
Use stage() when you only care whether the envelope, the secret, or the authentication step failed — for metrics or a coarse retry policy — rather than the specific BotMessageError variant.

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 for the preserved node. 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.

Handling current errors

Match the operation’s typed error and retain its source chain. For example, an IQ ServerError retains the original rejection node and its optional backoff; an HTTP adapter preserves typed status failures. Avoid parsing display strings to recover status codes. ClientDownloadError wraps the final outcome after any media refresh/retry. A writer download may have already changed the sink when it fails; consult the download contract before reusing it. A successful send returns SendResult, including the constructed message and optional recipient_fanout. Inspect partial fanout when your application needs to report devices the send skipped. It does not confirm recipient delivery. PrimaryDeviceRejected is an error for a named primary-device pre-key rejection, rather than a successful send to no established session. Edits, revokes, pins, and keeps also return SendResult. Construct an EditRequest or use a typed MessageRef as shown in message actions. Channel actions use NewsletterMessageRef and return the outcome documented by the newsletter API. Use fallback arms when matching extensible errors. Community creation may have succeeded before a configuration failure: ConfigurationFailed retains the created JID and operation so you can continue safely without creating a duplicate community.

Storage retry exhaustion

wacore::store::error::StoreError::RetriesExhausted carries op, attempts and a source error. attempts includes the initial attempt. Preserve all three fields when forwarding or constructing this error; match with .. when you only need one field. is_database_busy_or_locked() recognizes typed retry exhaustion as contention and follows typed Database/Connection wrappers. A typed non-contention StoreError remains non-contention even if its display text contains “busy” or “locked”. Text fallback remains for opaque database causes such as Diesel’s SQLite errors. Inspect sources rather than treating display text as a stable classification API.