Skip to content
50 changes: 50 additions & 0 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,16 @@ impl Bot {
.await
{
warn!(target: "Bot/PairCode", "Timeout waiting for socket: {}", e);
// The request never happens, so `pair_with_code` never runs
// and never dispatches for it. Reported here instead: to a
// consumer waiting on a code this is the same fact as any
// other failure — none is coming — and leaving this one path
// silent would put the indefinite wait back.
client_for_pair.core.event_bus.dispatch(Event::PairingCodeError(
crate::types::events::PairingCodeError::builder()
.error(e.to_string())
.build(),
));
return;
}

Expand All @@ -622,6 +632,10 @@ impl Bot {
info!(target: "Bot/PairCode", "Pair code generated: {}", code);
}
Err(e) => {
// Only logged here: `pair_with_code` already dispatched
// `Event::PairingCodeError`, which is what a consumer
// observes — this task is detached, so returning the
// error is not an option.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
warn!(target: "Bot/PairCode", "Failed to request pair code: {}", e);
}
}
Expand Down Expand Up @@ -996,6 +1010,36 @@ impl<B, T, H, R> BotBuilder<B, T, H, R> {
})
}

/// Run `handler` when a pair-code request fails, so no code will be issued
/// ([`Event::PairingCodeError`]).
///
/// The counterpart to [`BotBuilder::on_pair_code`], and the only way to
/// observe the failure of a [`BotBuilder::with_pair_code`] request: that one
/// runs in a detached task, so its `Err` reaches no caller.
///
/// Branch on `err.rejection` rather than the message.
/// [`PairCodeRejection::is_throttled`](crate::pair_code::PairCodeRejection::is_throttled)
/// is the case to slow down for — re-requesting on the original schedule
/// spends more of the budget the server just refused — and `err.backoff`
/// carries the server's own delay when it named one.
pub fn on_pair_code_error<F, Fut>(self, handler: F) -> Self
where
F: Fn(crate::types::events::PairingCodeError, Arc<Client>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
self.on_event_for(&[EventKind::PairingCodeError], move |event, client| {
let fut = match &*event {
Event::PairingCodeError(e) => Some(handler(e.clone(), client)),
_ => None,
};
async move {
if let Some(fut) = fut {
fut.await
}
}
})
}

/// Run `handler` when the server asks the companion to refresh an
/// in-progress pairing code ([`Event::PairingCodeRefresh`]). The `bool` is
/// `force_manual`. The typical reaction is to request a fresh code via
Expand Down Expand Up @@ -1157,6 +1201,12 @@ impl<B, T, H, R> BotBuilder<B, T, H, R> {
/// (see [`BotBuilder::on_pair_code`]). This runs concurrently with QR code
/// pairing - whichever completes first wins.
///
/// The request runs in a detached task, so a failure cannot be returned to
/// the caller: it arrives as `Event::PairingCodeError` instead (see
/// [`BotBuilder::on_pair_code_error`]). Subscribe to it if the consumer must
Comment thread
jlucaso1 marked this conversation as resolved.
/// distinguish "still waiting for the user" from "no code is coming" — a
/// rate-limited request is otherwise indistinguishable from the former.
///
/// # Example
/// ```rust,ignore
/// use whatsapp_rust::pair_code::PairCodeOptions;
Expand Down
191 changes: 189 additions & 2 deletions src/pair_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ use wacore_binary::Jid;
use wacore_binary::{NodeContent, NodeContentRef, NodeRef};

pub use wacore::companion_reg::{CompanionOs, CompanionWebClientType};
pub use wacore::pair_code::{PairCodeError, PairCodeOptions};
pub use wacore::pair_code::{PairCodeError, PairCodeOptions, PairCodeRejection};

/// Errors raised by the high-level pair-code flow.
///
Expand All @@ -90,10 +90,44 @@ pub enum PairError {
/// OS, so a display-shaped rejection is already ruled out — see
/// [`wacore::companion_reg::CompanionOs`].) Any server `backoff` hint is
/// preserved on the wrapped [`IqError`].
#[error("pair-code IQ request failed")]
///
/// Renders what it wraps, per the [rendering
/// convention](crate::error#rendering) — so the code and text reach a log
/// line that only prints the error, without the reader having to reach for
/// the `Debug` form.
#[error("{0}")]
RequestFailed(#[from] IqError),
}

// Imported inside each body, not at module scope: `ErrorChainExt::as_dyn_error`
// would then be ambiguous with thiserror's own `AsDynError` for every `#[from]`
// in this module.
impl PairError {
/// How the server refused the request, as a status to branch on.
///
/// `None` when nothing was refused: local validation, no connection, or a
/// request that went unanswered. Prefer this to matching the message, which
/// is not a stable surface.
pub fn rejection(&self) -> Option<PairCodeRejection> {
use crate::error::ErrorChainExt;
self.server_rejection()
.map(|rejection| PairCodeRejection::from(i32::from(rejection.code)))
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
}

/// How long the server asked the client to wait before retrying, from the
/// `backoff` attribute.
///
/// Usually `None` — the server rarely populates it on this request, and WA
/// Web never reads it — but a value here is the server naming its own delay,
/// which beats an interval the consumer picked.
pub fn backoff(&self) -> Option<std::time::Duration> {
use crate::error::ErrorChainExt;
self.server_rejection()
.and_then(|rejection| rejection.backoff)
.map(|secs| std::time::Duration::from_secs(u64::from(secs)))
}
}

impl Client {
/// Initiates pair code authentication as an alternative to QR code pairing.
///
Expand Down Expand Up @@ -154,6 +188,35 @@ impl Client {
pub async fn pair_with_code(
self: &Arc<Self>,
options: PairCodeOptions,
) -> Result<String, PairError> {
// The failure is dispatched here rather than at each `return Err`
// below: stage 1 fails from a dozen places, and what a consumer needs
// from all of them is the same single fact — no code is coming. Wrapping
// the flow is also what stops a *later* early return from going
// unreported. `BotBuilder::with_pair_code` depends on it having no gaps,
// because it drives this from a detached task whose `Err` reaches nobody.
//
// Mirrors the success path, which likewise both returns the code and
// dispatches `Event::PairingCode`; a direct caller sees the failure
// twice, and a `with_pair_code` consumer sees it at all.
match self.pair_with_code_inner(options).await {
Ok(code) => Ok(code),
Err(e) => {
self.core.event_bus.dispatch(Event::PairingCodeError(
Comment thread
jlucaso1 marked this conversation as resolved.
Comment thread
jlucaso1 marked this conversation as resolved.
Comment on lines +237 to +238

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reporting validation failures against a live code

When a code is already outstanding and a second caller supplies an invalid phone number or custom code, pair_with_code_inner returns the validation error before checking pair_code_state; this catch-all then dispatches PairingCodeError even though the original code remains live. Fresh evidence beyond the fixed CodeAlreadyOutstanding case is that these pre-claim validation errors never reach that suppressed variant, so an uncorrelated handler can still treat the active flow as failed and tear it down or retry. Check for an outstanding flow before validation, or suppress pre-claim failures when another flow owns the slot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and this is the fourth instance of one class you have surfaced — duplicate request, withdrawn request, its IQ failing, and now a bad number beside a live code. Four is enough to say the approach was wrong rather than incomplete: I kept enumerating error variants when the event's meaning is a statement about state.

The event says "no code is coming". The question was therefore never how the request failed but whether a code is nonetheless on its way, and only the state answers that. In 6f46563 the suppression asks it:

async fn failure_is_not_this_flows_to_report(self: &Arc<Self>, e: &PairError) -> bool {
    if e.lost_the_flow_to_another_request() { return true; }
    // A failing request that still owned the slot has released it by now, so
    // an outstanding flow here belongs to somebody else.
    self.pair_code_state.lock().await.is_outstanding(wacore::time::now_secs())
}

That closes your case and any future variant that can reach the dispatch while a flow is live, without me having to predict which.

Both branches are load-bearing, which I checked rather than assumed by removing each in turn:

  • Drop the state check → a_validation_failure_beside_a_live_code_is_not_reported fails with a live code must not be reported as failed by an unrelated bad request.
  • Drop the variant check → the two cancellation tests fail. A cancellation with no replacement leaves the slot idle, so nothing is live and the state check cannot see it; the caller asked for exactly that and does not need telling.

Not reordering validation to run after the outstanding check, which was your other option. That would change which error a caller gets for a genuinely bad number — CodeAlreadyOutstanding instead of PhoneNumberTooShort — hiding the input bug behind a transient one. Validation should keep winning that race; it is only the reporting that was wrong.

1321 wacore / 1299 whatsapp-rust green, clippy and rustdoc clean.


Generated by Claude Code

crate::types::events::PairingCodeError::builder()
.maybe_rejection(e.rejection())
.maybe_backoff(e.backoff())
.error(e.to_string())
.build(),
));
Err(e)
}
}
}

async fn pair_with_code_inner(
self: &Arc<Self>,
options: PairCodeOptions,
) -> Result<String, PairError> {
// Strip non-digit characters from phone number (allows "+1-555-123-4567" format)
let phone_number: String = options
Expand Down Expand Up @@ -853,6 +916,130 @@ async fn handle_refresh_code(client: &Arc<Client>, reg_node: &NodeRef<'_>) -> bo
mod tests {
use super::*;

/// Pin the five arms against `WASmaxInMdIqMixinErrors.parseIqMixinErrors`,
/// the complete set WA Web's `companion_hello` response parser accepts, so a
/// renumber can't silently break a consumer's branching.
#[test]
fn rejection_codes_match_wa_web() {
assert_eq!(PairCodeRejection::BadRequest.code(), 400);
assert_eq!(PairCodeRejection::Forbidden.code(), 403);
assert_eq!(PairCodeRejection::RateOverlimit.code(), 429);
assert_eq!(PairCodeRejection::FeatureNotAvailable.code(), 452);
assert_eq!(PairCodeRejection::InternalServerError.code(), 500);
// A code outside WA Web's set keeps its number rather than collapsing
// into a named arm.
assert_eq!(
PairCodeRejection::from(418),
PairCodeRejection::Unknown(418)
);
}

/// The whole point of the typed status: a 429 is recoverable as
/// `RateOverlimit` without matching the message.
#[test]
fn rate_overlimit_is_recoverable_as_a_typed_status() {
let pe: PairError = IqError::ServerError {
code: 429,
text: "rate-overlimit".into(),
error_type: None,
backoff: Some(30),
}
.into();

assert_eq!(pe.rejection(), Some(PairCodeRejection::RateOverlimit));
assert_eq!(pe.backoff(), Some(std::time::Duration::from_secs(30)));
assert!(
pe.rejection().is_some_and(PairCodeRejection::is_throttled),
"429 must read as throttled"
);
// `RequestFailed` renders what it wraps, so a log line that prints only
// the error still names the refusal.
assert!(
pe.to_string().contains("429") && pe.to_string().contains("rate-overlimit"),
"Display should carry the server's code and text, got: {pe}"
);
}

/// `feature-not-available` is the one refusal that retrying cannot fix — it
/// must not read as throttled, or a consumer would back off forever instead
/// of falling back to the QR code the way WA Web does.
#[test]
fn feature_not_available_is_not_throttled() {
let pe: PairError = IqError::ServerError {
code: 452,
text: "feature-not-available".into(),
error_type: None,
backoff: None,
}
.into();

assert_eq!(pe.rejection(), Some(PairCodeRejection::FeatureNotAvailable));
assert!(!PairCodeRejection::FeatureNotAvailable.is_throttled());
assert_eq!(pe.backoff(), None);
}

/// A failure that never reached the server has no status to report, so
/// `rejection` stays `None` rather than inventing one.
#[test]
fn local_failure_reports_no_rejection() {
let pe: PairError = PairCodeError::PhoneNumberTooShort.into();
assert_eq!(pe.rejection(), None);
assert_eq!(pe.backoff(), None);
}

/// The regression the event exists for: a failed request must be observable
/// on the bus, not only through the `Err` that
/// `BotBuilder::with_pair_code`'s detached task throws away.
///
/// Uses a validation failure because it needs no server, and it covers the
/// harder half of the guarantee: the dispatch wraps the whole flow, so even
/// a path that returns before the IQ is built still reports.
#[tokio::test]
async fn failed_request_dispatches_pairing_code_error() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();

let err = client
.pair_with_code(PairCodeOptions {
phone_number: "123".to_string(),
..Default::default()
})
.await
.expect_err("a 3-digit number must be refused");
assert!(matches!(
err,
PairError::PairCode(PairCodeError::PhoneNumberTooShort)
));

poll_until("a PairingCodeError to reach the bus", || {
collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_)))
})
.await;

let events = collector.events();
let dispatched = events
.iter()
.find_map(|e| match &**e {
Event::PairingCodeError(e) => Some(e.clone()),
_ => None,
})
.expect("just polled for it");
assert_eq!(
dispatched.rejection, None,
"a local validation failure never reached the server"
);
assert_eq!(dispatched.backoff, None);
assert!(
dispatched.error.contains("too short"),
"the message should say what failed, got: {}",
dispatched.error
);
}

#[test]
fn pair_error_request_failed_preserves_iq_source() {
let iq = IqError::ServerError {
Expand Down
82 changes: 82 additions & 0 deletions wacore/src/pair_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,88 @@ impl PairCodeUtils {
}
}

/// How the server refused a `companion_hello`, as a matchable status.
///
/// The five named variants are the complete set WA Web's own response parser
/// accepts (`WASmaxInMdIqMixinErrors.parseIqMixinErrors`, reached from
/// `WASmaxInMdCompanionHelloResponseError`); anything else makes its RPC throw
/// "unknown error". They exist so a consumer can branch on the refusal instead
/// of matching the formatted message, which is not a stable surface.
///
/// The numbers are the `code` attribute. WA Web pairs each with a literal
/// `text` (`429`/`rate-overlimit`, `452`/`feature-not-available`, …) and
/// rejects a response whose two disagree, so the code alone identifies the
/// refusal.
///
/// WA Web branches on exactly two of them (`DevicePhoneNumberCodeScreen`, on
/// `CompanionHelloError.type.name`): [`RateOverlimit`](Self::RateOverlimit)
/// becomes "too many attempts, try again later" and
/// [`FeatureNotAvailable`](Self::FeatureNotAvailable) becomes "not available to
/// you yet, link with QR code instead". The rest share a generic "try again or
/// link with the QR code". In every case it resets the linking flow and waits
/// for the person to act — it never retries on its own, and never reads the
/// `backoff` hint, so treat that value as the server's advice rather than a
/// schedule WA Web is known to follow.
#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)]
#[wire(kind = "int")]
pub enum PairCodeRejection {
/// The request was malformed — **or** throttled for this phone number: the
/// server reuses `bad-request` for its per-number pair-code limit rather
/// than answering `rate-overlimit`. So this is not reliably a permanent
/// failure; see [`PairCodeRejection::is_throttled`].
#[wire = 400]
BadRequest,
#[wire = 403]
Forbidden,
/// The connection is asking for codes too fast. The server states the rate
/// is too high; the only correct response is to slow down.
#[wire = 429]
RateOverlimit,
/// Phone-number linking is not enabled for this account. Retrying will not
/// change that — WA Web sends the user to the QR code instead.
#[wire = 452]
FeatureNotAvailable,
#[wire = 500]
InternalServerError,
/// A `code` outside the set WA Web accepts. Its own RPC would raise
/// "unknown error" here; we keep the number so a consumer can log it and a
/// server-side addition is visible rather than silently reshaped.
#[wire_fallback]
Unknown(i32),
}

impl PairCodeRejection {
/// Whether this refusal is the server rate-limiting the request.
///
/// True for [`RateOverlimit`](Self::RateOverlimit) and
/// [`BadRequest`](Self::BadRequest), because the server throttles pair-code
/// requests per phone number under `bad-request` instead of
/// `rate-overlimit`. That makes the predicate deliberately wider than the
/// literal 429: a `bad-request` may equally be genuinely invalid content,
/// and the two are indistinguishable on the wire. Treat a true here as
/// "back off, then retry at most a bounded number of times" — not as proof
/// the request would ever succeed.
pub fn is_throttled(self) -> bool {
matches!(self, Self::RateOverlimit | Self::BadRequest)
}
}

impl core::fmt::Display for PairCodeRejection {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
// The `text` WA Web pairs with each code, so a log line reads like the
// stanza it came from.
let text = match self {
Self::BadRequest => "bad-request",
Self::Forbidden => "forbidden",
Self::RateOverlimit => "rate-overlimit",
Self::FeatureNotAvailable => "feature-not-available",
Self::InternalServerError => "internal-server-error",
Self::Unknown(code) => return write!(f, "unknown ({code})"),
};
write!(f, "{text} ({})", self.code())
}
}

/// Errors raised by wacore-side pair-code validation, key derivation, and
/// protocol-bundle building. The high-level crate wraps this in
/// `whatsapp_rust::pair_code::PairError` and adds an IQ-failure variant for the
Expand Down
Loading
Loading