diff --git a/api/bot.mdx b/api/bot.mdx index df03b0a9..c3805404 100644 --- a/api/bot.mdx +++ b/api/bot.mdx @@ -645,6 +645,10 @@ Bot::builder() Pair code runs concurrently with QR code pairing — whichever completes first wins. + +`with_pair_code` runs [`Client::pair_with_code`](/api/client#pair_with_code) in a detached task, so a failure never reaches a caller as an `Err` — it only reaches [`Event::PairingCodeError`](/concepts/events#pairingcodeerror). Register [`on_pair_code_error`](#on_pair_code_error) if the consumer must distinguish "still waiting for the user" from "no code is coming" (a rate-limited request otherwise looks identical to the former). + + The `companion_platform_display` shown on the phone is derived automatically from the resolved `platform_id` and a **canonicalized** OS derived from the device's `os` string: web variants emit ` ()` (Android `PlatformType`s map to `Chrome`, so they show as `Chrome (Android)` by default); explicit `AndroidPhone`/`AndroidTablet`/`AndroidAmbiguous` overrides emit `Android ()`. The OS is coerced into a small server-safe set (`Windows`/`Mac OS`/`Linux`/`Android`/`iOS`) because the pair-code server rejects a non-OS display with `bad-request` — an arbitrary branding `os` string falls back to `Linux`. Set `PairCodeOptions::display_os` to send a real, non-canonical OS name verbatim instead. See [Authentication — companion_platform_display](/concepts/authentication#companion-platform-display) for the full classification table. @@ -689,6 +693,65 @@ Bot::builder() This callback fires for **two** triggers, not just a server request: the server asking for a refresh (only while a pair-code flow is outstanding and the notification's ref matches it — a `refresh_code` notification for a stale or unrelated flow is ignored), and a `companion_finish` that went unanswered for a minute after the phone entered the code (`force_manual` is always `false` for this second trigger). See [Pair code refresh events](/concepts/authentication#pair-code-refresh-events) for the full breakdown. +### on_pair_code_error + +```rust +pub fn on_pair_code_error(self, handler: F) -> Self +where + F: Fn(PairingCodeError, Arc) -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static +``` + +Registers a handler for [`Event::PairingCodeError`](/concepts/events#pairingcodeerror), fired when a pair-code request fails and no code will be issued. The counterpart to [`on_pair_code_refresh`](#on_pair_code_refresh) on the failure path, and a dedicated convenience over matching the event yourself in `on_event` — either works, but this is the only way to observe a [`with_pair_code`](#with_pair_code) request failing at all: that request runs in a detached task, so its `Err` reaches no caller. + + + Async function that receives the `PairingCodeError` event and `Arc` + + +**Example:** +```rust +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use whatsapp_rust::pair_code::{PairCodeOptions, PairCodeRejection}; + +const MAX_THROTTLE_RETRIES: u32 = 3; +let retries = Arc::new(AtomicU32::new(0)); + +Bot::builder() + .with_pair_code(PairCodeOptions { + phone_number: "15551234567".to_string(), + ..Default::default() + }) + .on_pair_code_error(move |err, client| { + let retries = retries.clone(); + async move { + eprintln!("Pair code request failed: {}", err.error); + match err.rejection { + // Cap attempts — a persistently throttled number should stop + // retrying rather than loop forever on repeated PairingCodeError. + Some(r) if r.is_throttled() && retries.fetch_add(1, Ordering::Relaxed) < MAX_THROTTLE_RETRIES => { + // Back off using the server's own hint when it gave one. + let delay = err.backoff.unwrap_or(std::time::Duration::from_secs(30)); + tokio::time::sleep(delay).await; + let _ = client.pair_with_code(PairCodeOptions { + phone_number: "15551234567".to_string(), + ..Default::default() + }).await; + } + Some(PairCodeRejection::FeatureNotAvailable) => { + // Retrying will not help — fall back to QR pairing instead. + } + _ => {} + } + } + }) + // ... +``` + + +Branch on `err.rejection` rather than the message, which is not a stable surface — see [Pair code failure events](/concepts/authentication#pair-code-failure-events) for the full field breakdown and the two failures ([`PairCodeError::CodeAlreadyOutstanding`](/concepts/authentication#one-code-at-a-time) and `Cancelled`) that deliberately never reach this handler. + + --- ## Cache Configuration diff --git a/api/client.mdx b/api/client.mdx index 799e3ad0..8312126f 100644 --- a/api/client.mdx +++ b/api/client.mdx @@ -277,6 +277,10 @@ This can run concurrently with QR code pairing — whichever completes first win **One code at a time.** Fails with `PairCodeError::CodeAlreadyOutstanding` while a previous code is still outstanding, instead of silently replacing it. "Outstanding" means either the previous code's validity window hasn't elapsed yet, *or* its `primary_hello` was already accepted and a `pair-success` for it is still pending — that second case can outlast the validity window by up to a minute, and `remaining` reads as `0` for it since there's no window left to report. A second code does not replace the first for the phone: the server routes `primary_hello` by number and never sees the code itself, so whoever is still reading the older one reaches stage 2 regardless. Call [`cancel_pair_code`](#cancel_pair_code) first when the replacement is intentional. Do not call this on a schedule driven by QR-code rotation — the two flows have unrelated lifetimes. See [One code at a time](/concepts/authentication#one-code-at-a-time). + +On any failure other than `CodeAlreadyOutstanding` or `Cancelled`, this also dispatches [`Event::PairingCodeError`](/concepts/authentication#pair-code-failure-events) before returning the `Err` — the only surface [`BotBuilder::with_pair_code`](/api/bot#with_pair_code) can report through, since that path drives this call from a detached task. A direct caller sees the failure both ways: as the returned `Err` and, unless it's one of those two exclusions, on the event bus. + + Configuration for pair code authentication: - `phone_number` — Phone number in international format (e.g., `"15551234567"`) @@ -306,7 +310,27 @@ This can run concurrently with QR code pairing — whichever completes first win | `PairCode(AdvSecretKeyDerivation)` / `BundleKeyDerivation` | HKDF expand failed | | `PairCode(BundleAead)` | AES-GCM encryption of the key bundle failed (typed `CryptoProviderError` source) | | `PairCode(MissingPairingRef)` | Server response missing pairing ref | -| `RequestFailed` | Server IQ request failed (typed `IqError` source) | +| `RequestFailed` | Server IQ request failed (typed `IqError` source). `Display` renders exactly what it wraps (e.g. `429 (rate-overlimit)`) | + +`PairError` also exposes the server's refusal as a typed status, so a consumer doesn't have to match the message: + +```rust +impl PairError { + /// The server's refusal, classified from its `code`/`text` pair. An + /// unrecognized code still comes back as `Some(PairCodeRejection::Unknown)`. + /// `None` only when nothing was refused (local validation, no connection, + /// timeout), or the server paired a *named* code with a contradicting `text`. + pub fn rejection(&self) -> Option; + /// The server's requested retry delay, when it named one. + pub fn backoff(&self) -> Option; + /// `true` for `CodeAlreadyOutstanding` and `Cancelled` — failures that + /// don't mean "no code is coming", because another flow may still own + /// the slot. `Event::PairingCodeError` is not dispatched for these. + pub fn lost_the_flow_to_another_request(&self) -> bool; +} +``` + +See [Pair code failure events](/concepts/authentication#pair-code-failure-events) for `PairCodeRejection`'s five named variants (`BadRequest`, `Forbidden`, `RateOverlimit`, `FeatureNotAvailable`, `InternalServerError`) plus its `Unknown(i32)` fallback, and for `is_throttled()`. **Example:** ```rust diff --git a/concepts/authentication.mdx b/concepts/authentication.mdx index bde568f6..d54ca624 100644 --- a/concepts/authentication.mdx +++ b/concepts/authentication.mdx @@ -507,6 +507,60 @@ pub struct PairingCodeRefresh { In both cases the outstanding flow is cleared **before** the event fires, so a handler can call [`Client::pair_with_code`](/api/client#pair_with_code) immediately without hitting [`PairCodeError::CodeAlreadyOutstanding`](#one-code-at-a-time). The previous code is no longer valid either way. Register a handler with [`Bot::on_pair_code_refresh`](/api/bot#on_pair_code_refresh) or match on the event directly via `on_event`. +### Pair code failure events + +**Event:** `Event::PairingCodeError(PairingCodeError)` + +```rust +// wacore/src/types/events.rs +#[derive(Debug, Clone, Serialize, bon::Builder)] +#[non_exhaustive] +pub struct PairingCodeError { + pub rejection: Option, // The server's refusal, when it answered with one + pub backoff: Option, // The server's requested retry delay, when it named one + pub error: String, // The failure rendered for logs — do not branch on it +} +``` + +The counterpart to `PairingCode` on the failure path, and the only surface that reports a failed request when pairing is driven by [`BotBuilder::with_pair_code`](/api/bot#with_pair_code): that call runs [`Client::pair_with_code`](/api/client#pair_with_code) inside a detached task, so nothing returns its `Err` to a caller. `pair_with_code` dispatches this event in addition to returning `Err`, mirroring how the success path both returns the code and emits `Event::PairingCode`. Register a handler with [`BotBuilder::on_pair_code_error`](/api/bot#on_pair_code_error) or match on the event directly via `on_event`. + +Fires for **every** failure, including local validation — a phone number that's too short never reaches the server. `rejection` carries the server's refusal as a typed status in most cases where the server answered: `Some(PairCodeRejection::Unknown(code))` even for a code outside WA Web's own five — the code is still preserved, just not aliased to a named arm. `None` means one of two things instead: the failure never reached the server at all (local validation, no connection, timeout), *or* it did, but the server paired a **named** code with a `text` that contradicts it — see [`PairCodeRejection::from_server`](#paircoderejection) for why a contradiction is refused rather than trusted. In every `None` case the message from `error` is still the only description available. A claim the failed request itself held is released before this fires, so `pair_with_code` can be called again immediately. + +Two failures do **not** dispatch this event, because for them a code may still be on its way and the event would say the opposite: + +- **`PairCodeError::CodeAlreadyOutstanding`** — refused precisely because an earlier code is still live; the consumer already has it from the `PairingCode` event that minted it. +- **`PairCodeError::Cancelled`** — the caller withdrew this request via `cancel_pair_code`, and a replacement may already own the slot by the time this one resolves; reporting the withdrawn request would be uncorrelated with the flow that's actually running. + +Both are consequences of something the caller did, so neither is news to them, and a direct caller still receives the `Err` either way — only the event is suppressed. `PairError::lost_the_flow_to_another_request()` is `true` for exactly these two variants. + +#### PairCodeRejection + +```rust +// wacore/src/pair_code.rs +pub enum PairCodeRejection { + BadRequest, // 400 — malformed, or throttled per phone number (server reuses this code) + Forbidden, // 403 + RateOverlimit, // 429 — requesting codes too fast; the only correct response is to slow down + FeatureNotAvailable, // 452 — phone-number linking disabled for this account; retrying will not help + InternalServerError, // 500 + Unknown(i32), // a code outside WA Web's own accepted set +} +``` + +The five named variants are the complete set WA Web's response parser (`WASmaxInMdIqMixinErrors.parseIqMixinErrors`) accepts; anything else makes its own RPC throw "unknown error", which is what `Unknown(code)` preserves here. Classified via `PairCodeRejection::from_server(code, text)` from *both* wire attributes together — WA Web asserts them as a literal pair (e.g. `429`/`rate-overlimit`) and falls back to its generic error path when they disagree, so a contradicting `text` classifies as `None` rather than aliasing the code to the named arm. An absent `text` is not treated as a contradiction; the code alone decides in that case. + +`PairCodeRejection::is_throttled()` is `true` for `RateOverlimit` and `BadRequest` — deliberately wider than the literal 429, because the server throttles pair-code requests per phone number under `bad-request` instead of `rate-overlimit`, and the two are indistinguishable on the wire. Treat a `true` here as "back off, then retry a bounded number of times," not as proof the request would eventually succeed. `FeatureNotAvailable` is never throttled — retrying cannot fix it, and WA Web falls back to the QR code instead. + +`PairError` (the `Err` `pair_with_code` returns) exposes the same classification without depending on the event: + +```rust +impl PairError { + pub fn rejection(&self) -> Option; + pub fn backoff(&self) -> Option; + pub fn lost_the_flow_to_another_request(&self) -> bool; +} +``` + ### Two-Stage Flow #### Stage 1: Hello @@ -1095,7 +1149,11 @@ match client.pair_with_code(options).await { - A `PairError::RequestFailed` carrying `bad-request` (400) is not necessarily a permanent/invalid-input failure — the server reuses the same error for **rate-limiting** (it throttles pair-code requests per phone number), and the two are indistinguishable in the response. Back off and retry rather than treating every 400 as fatal. By default, the library canonicalizes `companion_platform_display`'s OS (see [`companion_platform_display`](#companion-platform-display)), so display-shaped rejections are generally ruled out unless you explicitly bypass canonicalization via `PairCodeOptions::display_os`. Any server `backoff` hint is preserved on the wrapped `IqError::ServerError` (see [Error Types](/api/errors#iqerror-base-type)). + Prefer [`PairError::rejection()`](#pair-code-failure-events) to matching on `bad-request` by string: the server reuses `PairCodeRejection::BadRequest` (400) for both malformed requests **and** its per-phone-number rate limit, and the two are indistinguishable on the wire. `PairCodeRejection::is_throttled()` covers this — it's `true` for both `BadRequest` and `RateOverlimit` — so back off and retry rather than treating every 400 as fatal. By default, the library canonicalizes `companion_platform_display`'s OS (see [`companion_platform_display`](#companion-platform-display)), so display-shaped rejections are generally ruled out unless you explicitly bypass canonicalization via `PairCodeOptions::display_os`. Any server `backoff` hint is preserved on the wrapped `IqError::ServerError` (see [Error Types](/api/errors#iqerror-base-type)) and surfaced directly via `PairError::backoff()`. + + + + `PairError::RequestFailed`'s `Display` now renders exactly what it wraps (`{0}`) instead of the fixed string `"pair-code IQ request failed"` — so a log line that only prints the error (`{e}`) still shows the server's code and text, e.g. `429 (rate-overlimit)`. The previous catch-all `CryptoError(String)` and `RequestFailed(String)` variants have been split into typed variants that preserve their underlying source. Match on `std::error::Error::source()` (or downcast it) to inspect the inner `CurveError`, `CryptoProviderError`, or `IqError`. diff --git a/concepts/events.mdx b/concepts/events.mdx index 6a266e13..57cd319f 100644 --- a/concepts/events.mdx +++ b/concepts/events.mdx @@ -137,6 +137,7 @@ pub enum Event { PairingQrCode(PairingQrCode), PairingCode(PairingCode), PairingCodeRefresh(PairingCodeRefresh), + PairingCodeError(PairingCodeError), PairingQrCodesExhausted(PairingQrCodesExhausted), PairSuccess(PairSuccess), PairError(PairError), @@ -576,6 +577,46 @@ Breaking change: `PairingCodeRefresh` moved from an inline `Event::PairingCodeRe The unanswered-`companion_finish` timeout trigger is new. If your handler assumed `PairingCodeRefresh` only ever came from the server, no code changes are required — the reaction (call `pair_with_code` again) is the same either way, and the outstanding flow is already cleared by the time the event fires in both cases. +### PairingCodeError + +**Emitted:** When a phone-number pair-code request fails, so no code will be shown. `Client::pair_with_code` dispatches this in addition to returning `Err` — it's the *only* surface that reports the failure when pairing is driven by [`BotBuilder::with_pair_code`](/api/bot#with_pair_code), since that request runs in a detached task and its `Err` reaches no caller. + +```rust +/// A phone-number pair-code request failed, so no code will be shown. +/// +/// Fires for every failure, including local validation (a too-short phone +/// number never reaches the server) — a consumer waiting on a code needs to +/// learn that it is not coming, whatever the reason. +#[derive(Debug, Clone, Serialize, bon::Builder)] +#[non_exhaustive] +pub struct PairingCodeError { + /// The server's refusal, when it answered with one. `None` when the + /// failure was local (validation, no connection) or the request went + /// unanswered (timeout) — nothing was refused, so there is no status to + /// report. + pub rejection: Option, + /// How long the server asked the client to wait, from the `backoff` + /// attribute. Usually absent. + pub backoff: Option, + /// The failure rendered for logs. Do not branch on it; use `rejection`. + pub error: String, +} +``` + +**Example:** +```rust +Event::PairingCodeError(PairingCodeError { rejection, backoff, error, .. }) => { + eprintln!("Pair code request failed: {error}"); + if rejection.is_some_and(PairCodeRejection::is_throttled) { + // Back off, then retry — using the server's own delay when it named one. + } +} +``` + + +Two failures never reach this event, because for them a code may still be on its way and the event would say the opposite: `PairCodeError::CodeAlreadyOutstanding` (an earlier code is still live — the consumer already has it from the `PairingCode` that minted it) and `PairCodeError::Cancelled` (the caller withdrew this request, and a replacement may already own the slot by the time it resolves). Both are consequences of something the caller did, so neither is news to them, and a direct caller still receives the `Err` either way. See [Pair code failure events](/concepts/authentication#pair-code-failure-events) for `PairCodeRejection`'s variants and the full breakdown, and [`BotBuilder::on_pair_code_error`](/api/bot#on_pair_code_error) to register a handler. + + ### PairingQrCodesExhausted **Emitted:** When the server's `` refs are used up — there is no QR left to render until the connection is re-established. WA Web's rotation timer (`Handle/PairDevice.js`) reports `UNPAIRED_IDLE` here and stops; it does not close the socket unconditionally, because a phone-number (pair-code) flow may still be riding the same connection.