> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Types

> Typed error reference for all whatsapp-rust public APIs

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`](#connecterror), [`SignalMaintenanceError`](#signalmaintenanceerror), and [`MessageEditError`](#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`](#error-chain-recovery) 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](#error-chain-recovery) below.

## Error hierarchy

```
ClientError          (transport/connection base — embedded by select domain errors)
  ├── NotConnected
  ├── Socket(SocketError)
  ├── EncryptSend(EncryptSendError)
  ├── NotLoggedIn
  ├── Iq(IqError)
  └── Internal(anyhow::Error)

ConnectError          (Client::connect, wait_for_socket, wait_for_connected)
  ├── AlreadyConnected
  ├── NotActivated
  ├── Timeout { stage: ConnectStage, timeout: Duration }
  ├── Version(anyhow::Error)
  ├── Transport(anyhow::Error)
  └── Handshake(HandshakeError)

SignalMaintenanceError  (Client::rotate_signed_pre_key, Client::flush_pending_signal_state)
  ├── CorruptKey(String)
  ├── Storage(anyhow::Error)
  ├── Iq(IqError)
  ├── Signal(SignalProtocolError)
  ├── DrainCommitFailed
  └── DrainShuttingDown

MessageEditError      (EncryptedEdit / SecretEncrypted target-key resolvers)
  ├── InvalidTargetJid { field, source: JidError }
  └── MissingTargetSender

IqError              (IQ request failures — embedded by most domain errors)
  ├── Timeout
  ├── NotConnected
  ├── Socket(SocketError)
  ├── EncryptSend(EncryptSendError)
  ├── ClientState(Box<ClientError>)
  ├── Disconnected(Box<Node>)
  ├── ServerError
  ├── UnexpectedResponseType
  ├── InternalChannelClosed
  ├── DuplicateRequestId(String)
  ├── EncodeError
  └── ParseError
```

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.

<Note>
  `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`.
</Note>

## 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:

```rust theme={null}
pub trait ErrorChainExt {
    fn sources(&self) -> Sources<'_>;
    fn server_rejection(&self) -> Option<ServerRejection<'_>>;
    fn is_timeout(&self) -> bool;
    fn is_transport_unavailable(&self) -> bool;
    fn store_failure(&self) -> Option<&StoreError>;
}
```

* `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`](#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.
* `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.

```rust theme={null}
use whatsapp_rust::ErrorChainExt;

match client.groups().set_description(&jid, Some(desc), PreviousDescription::Resolve).await {
    Ok(_) => {}
    Err(e) if e.is_transport_unavailable() => { /* retry once reconnected */ }
    Err(e) => {
        if let Some(rejection) = e.server_rejection() {
            eprintln!("server said {}: {}", rejection.code, rejection.text);
        } else {
            eprintln!("group update failed: {e}");
        }
    }
}
```

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:

```rust theme={null}
let cause: &(dyn std::error::Error + 'static) = err.as_ref();
if cause.is_timeout() { /* ... */ }
```

### ServerRejection

```rust theme={null}
#[non_exhaustive]
pub struct ServerRejection<'a> {
    pub code: u16,
    pub text: &'a str,
    pub error_type: Option<&'a str>,
    pub backoff: Option<u32>,
}
```

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

<Note>
  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.
</Note>

`ErrorChainExt`, `ServerRejection`, and `Sources` are re-exported from the crate root (`whatsapp_rust::ErrorChainExt`, `whatsapp_rust::ServerRejection`, `whatsapp_rust::Sources`).

## Domain error types

| Error type               | Returned by                                                                                                                   | Module          |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `SendError`              | `send_message`, `revoke_message`, `edit_message`, `pin_message`, `send_reaction`, and all other send-path methods             | `whatsapp_rust` |
| `GroupError`             | All `Groups::*` methods                                                                                                       | `whatsapp_rust` |
| `BlockingError`          | All `Blocking::*` methods                                                                                                     | `whatsapp_rust` |
| `AppStateError`          | All `ChatActions::*` and `Labels::*` methods                                                                                  | `whatsapp_rust` |
| `ChatStateError`         | All `Chatstate::send*` methods                                                                                                | `whatsapp_rust` |
| `CommunityError`         | All `Community::*` methods                                                                                                    | `whatsapp_rust` |
| `ContactError`           | All `Contacts::*` methods                                                                                                     | `whatsapp_rust` |
| `NewsletterError`        | All `Newsletter::*` methods                                                                                                   | `whatsapp_rust` |
| `PollError`              | `Polls::{create, create_quiz, vote, decrypt_vote, aggregate_votes}`                                                           | `whatsapp_rust` |
| `ProfileError`           | All `Profile::*` methods                                                                                                      | `whatsapp_rust` |
| `SignalError`            | All `Signal::*` methods                                                                                                       | `whatsapp_rust` |
| `TcTokenError`           | All `TcToken::*` methods                                                                                                      | `whatsapp_rust` |
| `MediaReuploadError`     | `MediaReupload::request`                                                                                                      | `whatsapp_rust` |
| `PresenceError`          | All `Presence::*` methods                                                                                                     | `whatsapp_rust` |
| `ConnectError`           | `Client::connect`, `Client::wait_for_socket`, `Client::wait_for_connected`                                                    | `whatsapp_rust` |
| `SignalMaintenanceError` | `Client::rotate_signed_pre_key`, `Client::flush_pending_signal_state`                                                         | `whatsapp_rust` |
| `MessageEditError`       | `EncryptedEdit::original_sender_jid`, `SecretEncrypted::original_sender_jid`, `SecretEncrypted::original_sender_for_dispatch` | `whatsapp_rust` |

## Type definitions

### SendError

```rust theme={null}
#[non_exhaustive]
pub enum SendError {
    #[error("{0}")]
    Client(ClientError),
    #[error("client is not logged in")]
    NotLoggedIn,
    #[error("IQ request failed: {0}")]
    Iq(#[from] IqError),
    #[error("invalid send request: {0}")]
    InvalidRequest(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

### GroupError

```rust theme={null}
#[non_exhaustive]
pub enum GroupError {
    #[error("{0}")]
    Iq(#[from] IqError),
    #[error("{0}")]
    Mex(#[from] MexError),
    #[error("invalid group request: {0}")]
    InvalidRequest(String),
    #[error("the group description changed since it was read")]
    DescriptionConflict,
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

`DescriptionConflict` (added in PR #1097) is returned by [`Groups::set_description`](/api/groups#set_description) when the `prev` token no longer matches the group's current description — see [`GroupError` in the groups reference](/api/groups#grouperror).

### BlockingError

```rust theme={null}
#[non_exhaustive]
pub enum BlockingError {
    #[error("{0}")]
    Iq(#[from] IqError),
    #[error("invalid blocklist target: {0}")]
    InvalidJid(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

### AppStateError

Shared by `ChatActions` and `Labels`.

```rust theme={null}
#[non_exhaustive]
pub enum AppStateError {
    #[error("invalid app-state request: {0}")]
    InvalidRequest(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

### ChatStateError

```rust theme={null}
#[non_exhaustive]
pub enum ChatStateError {
    #[error("{0}")]
    Client(#[from] ClientError),
}
```

### CommunityError

```rust theme={null}
#[non_exhaustive]
pub enum CommunityError {
    #[error("{0}")]
    Iq(#[from] IqError),
    #[error("{0}")]
    Mex(#[from] MexError),
    #[error("{0}")]
    Group(#[from] GroupError),
    #[error("invalid community request: {0}")]
    InvalidRequest(String),
}
```

### ContactError

```rust theme={null}
#[non_exhaustive]
pub enum ContactError {
    #[error("{0}")]
    Iq(#[from] IqError),
    #[error("unsupported contact JID: {0}")]
    InvalidJid(String),
}
```

### NewsletterError

```rust theme={null}
#[non_exhaustive]
pub enum NewsletterError {
    #[error("{0}")]
    Mex(#[from] MexError),
    #[error("{0}")]
    Iq(#[from] IqError),
    #[error("{0}")]
    Client(#[from] ClientError),
    #[error("invalid newsletter request: {0}")]
    InvalidRequest(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

### PollError

```rust theme={null}
#[non_exhaustive]
pub enum PollError {
    #[error("{0}")]
    Send(#[from] SendError),
    #[error("invalid poll: {0}")]
    InvalidPoll(String),
    #[error("client is not logged in")]
    NotLoggedIn,
    #[error("poll vote crypto failed: {0}")]
    Crypto(#[source] anyhow::Error),
}
```

### ProfileError

```rust theme={null}
#[non_exhaustive]
pub enum ProfileError {
    #[error("{0}")]
    Iq(#[from] IqError),
    #[error("{0}")]
    Client(#[from] ClientError),
    #[error("invalid argument: {0}")]
    InvalidArgument(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

### SignalError

```rust theme={null}
#[non_exhaustive]
pub enum SignalError {
    #[error("{0}")]
    Protocol(#[from] SignalProtocolError),
    #[error("unsupported signal operation: {0}")]
    Unsupported(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

### TcTokenError

```rust theme={null}
#[non_exhaustive]
pub enum TcTokenError {
    #[error("{0}")]
    Iq(#[from] IqError),
    #[error("{0}")]
    Store(#[from] StoreError),
}
```

### MediaReuploadError

```rust theme={null}
#[non_exhaustive]
pub enum MediaReuploadError {
    #[error("{0}")]
    Client(#[from] ClientError),
    #[error("client is not logged in")]
    NotLoggedIn,
    #[error("invalid media reupload request: {0}")]
    InvalidRequest(String),
    #[error("media retry notification timed out")]
    Timeout,
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

### PresenceError

```rust theme={null}
#[non_exhaustive]
pub enum PresenceError {
    #[error("cannot send presence without a push name set")]
    PushNameEmpty,
    #[error("{0}")]
    Client(#[from] ClientError),
    #[error("{0}")]
    Other(#[from] anyhow::Error),
}
```

### ConnectError

Returned by [`Client::connect`](/api/client#connect), [`Client::wait_for_socket`](/api/client#wait_for_socket), and [`Client::wait_for_connected`](/api/client#wait_for_connected). Added in PR #1090, replacing bare `anyhow::Error` on all three methods.

```rust theme={null}
#[non_exhaustive]
pub enum ConnectError {
    #[error("client is already connected")]
    AlreadyConnected,
    #[error("client construction did not activate")]
    NotActivated,
    #[error("{stage} timed out after {timeout:?}")]
    Timeout {
        stage: ConnectStage,
        timeout: std::time::Duration,
    },
    #[error("failed to resolve app version")]
    Version(#[source] anyhow::Error),
    #[error("failed to open transport")]
    Transport(#[source] anyhow::Error),
    #[error("{0}")]
    Handshake(#[from] crate::handshake::HandshakeError),
}
```

**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.
* `Timeout` — a step of the connect flow ran out of time. `stage` says which one (see [`ConnectStage`](#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).
* `Handshake` — the Noise handshake failed after the transport was up. Wraps `HandshakeError` (see [WebSocket & Noise Protocol](/advanced/websocket-handling#handshake-errors)) 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()`](#error-chain-recovery) 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:

```rust theme={null}
#[non_exhaustive]
pub enum ConnectStage {
    /// Resolving the app version advertised to the server.
    VersionFetch,
    /// Opening the underlying transport.
    Transport,
    /// Waiting for the noise socket, which is ready before login.
    Socket,
    /// Waiting for login plus the critical app state sync to finish.
    Ready,
}
```

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`](/api/client#signed-pre-key-rotation) and [`Client::flush_pending_signal_state`](/api/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.

```rust theme={null}
#[non_exhaustive]
pub enum SignalMaintenanceError {
    #[error("corrupt signed pre-key material: {0}")]
    CorruptKey(String),
    #[error("signal storage failure: {0}")]
    Storage(#[source] anyhow::Error),
    #[error("IQ request failed: {0}")]
    Iq(#[from] IqError),
    #[error("{0}")]
    Signal(#[from] SignalProtocolError),
    #[error(
        "inbound drain batch commit failed; Signal cache left unflushed so the server redelivers"
    )]
    DrainCommitFailed,
    #[error("client dropping while inbound drain is active; skipping Signal flush")]
    DrainShuttingDown,
}
```

**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`](#iqerror-base-type) 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`](/api/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](/api/polls#decrypting-secret-encrypted-envelopes) for how these methods are used.

```rust theme={null}
#[non_exhaustive]
pub enum MessageEditError {
    #[error("invalid {field} in target message key")]
    InvalidTargetJid {
        field: &'static str,
        #[source]
        source: JidError,
    },
    #[error("target message key missing participant and remote_jid")]
    MissingTargetSender,
}
```

**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)

```rust theme={null}
#[non_exhaustive]
pub enum ClientError {
    #[error("client is not connected")]
    NotConnected,
    #[error("client is not logged in")]
    NotLoggedIn,
    #[error("socket error: {0}")]
    Socket(SocketError),
    #[error("encrypt/send error: {0}")]
    EncryptSend(EncryptSendError),
    #[error("IQ request failed: {0}")]
    Iq(#[from] IqError),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

<Note>
  `AlreadyConnected` was removed from `ClientError` in PR #1090 — it had no remaining constructor once `Client::connect()` moved to [`ConnectError`](#connecterror). If you matched on `ClientError::AlreadyConnected`, switch to `ConnectError::AlreadyConnected`.
</Note>

### IqError (base type)

```rust theme={null}
#[non_exhaustive]
pub enum IqError {
    #[error("IQ request timed out")]
    Timeout,
    #[error("client is not connected")]
    NotConnected,
    #[error("socket error")]
    Socket(#[from] SocketError),
    #[error("encrypted send pipeline failed")]
    EncryptSend(#[from] EncryptSendError),
    #[error("client state prevented send")]
    ClientState(#[source] Box<ClientError>),  // boxed to break the ClientError<->IqError cycle
    #[error("received disconnect node during IQ wait: {0:?}")]
    Disconnected(Box<Node>),
    #[error("received a server error response: code={code}, text='{text}'")]
    ServerError { code: u16, text: String, error_type: Option<String>, backoff: Option<u32> },
    #[error("received unexpected IQ response type: {got:?}")]
    UnexpectedResponseType { got: Option<String> },
    #[error("internal channel closed unexpectedly")]
    InternalChannelClosed,
    #[error("IQ request ID is already in flight: {0}")]
    DuplicateRequestId(String),
    #[error("failed to encode IQ request")]
    EncodeError(#[source] anyhow::Error),
    #[error("failed to parse IQ response")]
    ParseError(#[from] anyhow::Error),
}
```

<Note>
  `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 */ }`.
</Note>

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`](#error-chain-recovery) instead, which handles both types.

## Migration guide

### From `anyhow::Error`

If your handler used `?` into `anyhow`:

```rust theme={null}
// Before — still compiles unchanged
async fn my_fn() -> anyhow::Result<()> {
    client.send_message(jid, msg).await?;  // SendError: Into<anyhow::Error>
    Ok(())
}
```

If you were matching on `anyhow::Error` downcasts, switch to typed matching:

```rust theme={null}
// Before
match client.send_message(jid, msg).await {
    Ok(r) => { /* ... */ }
    Err(e) => eprintln!("send failed: {e}"),
}

// After — match specific variants
use whatsapp_rust::SendError;
match client.send_message(jid, msg).await {
    Ok(r) => { /* ... */ }
    Err(SendError::NotLoggedIn) => eprintln!("not authenticated"),
    Err(SendError::Iq(e)) => eprintln!("IQ failed: {e}"),
    Err(SendError::InvalidRequest(msg)) => eprintln!("bad request: {msg}"),
    Err(e) => eprintln!("send failed: {e}"),
}
```

### IqError::ClientState boxing

```rust theme={null}
// Before
Err(IqError::ClientState(e)) => { /* e: ClientError */ }

// After — dereference the box
Err(IqError::ClientState(e)) => { /* *e: ClientError */ }
```

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

```rust theme={null}
// Before
if let Err(e) = client.connect().await {
    if e.downcast_ref::<ClientError>().is_some_and(|e| matches!(e, ClientError::AlreadyConnected)) {
        // ...
    }
}
client.logout().await?;

// After
use whatsapp_rust::ConnectError;
if let Err(e) = client.connect().await {
    if matches!(e, ConnectError::AlreadyConnected) {
        // ...
    }
}
client.logout().await; // infallible — no `?` needed anymore
```

### 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`](#error-chain-recovery). 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):

```rust theme={null}
// Before (internal to this crate — the IqError check isn't public API)
fn is_transport_unavailable(err: &anyhow::Error) -> bool {
    err.chain().any(|cause| {
        cause
            .downcast_ref::<ClientError>()
            .is_some_and(ClientError::is_transport_unavailable)
            || cause
                .downcast_ref::<IqError>()
                .is_some_and(|iq| matches!(iq, IqError::NotConnected | IqError::Disconnected(_) | IqError::InternalChannelClosed))
    })
}

// After — works the same from inside or outside the crate
use whatsapp_rust::ErrorChainExt;
fn is_transport_unavailable(err: &anyhow::Error) -> bool {
    let cause: &(dyn std::error::Error + 'static) = err.as_ref();
    cause.is_transport_unavailable()
}
```

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.
