Skip to main content

whatsapp_rust/message/
retry.rs

1//! Decrypt-failure handling, retry receipts and undecryptable events.
2
3use super::*;
4
5impl Client {
6    /// Request retransmission of an inbound message stanza.
7    ///
8    /// The stanza is parsed once into the canonical message metadata model.
9    /// This operation sends only the retry receipt; transport acknowledgement
10    /// remains the caller's responsibility.
11    #[cfg_attr(
12        feature = "tracing",
13        tracing::instrument(name = "wa.recv.request_retry", level = "debug", skip_all, err(Debug))
14    )]
15    pub async fn request_message_retry(
16        self: &Arc<Self>,
17        stanza: &NodeRef<'_>,
18        options: crate::features::RetryRequestOptions,
19    ) -> Result<crate::features::RetryRequestOutcome, crate::features::RetryRequestError> {
20        if stanza.tag.as_ref() != "message" {
21            return Err(crate::features::RetryRequestError::UnsupportedStanzaClass);
22        }
23        if stanza.get_attr("id").is_none() {
24            return Err(crate::features::RetryRequestError::MissingAttribute("id"));
25        }
26        if stanza.get_attr("from").is_none() {
27            return Err(crate::features::RetryRequestError::MissingAttribute("from"));
28        }
29        if !self.is_connected() {
30            return Err(crate::client::ClientError::NotConnected.into());
31        }
32
33        let device = self.persistence_manager.get_device_snapshot();
34        let own_pn = device
35            .pn
36            .as_ref()
37            .ok_or(crate::features::RetryRequestError::MissingLocalIdentity)?;
38        let info = wacore::messages::parse_message_info(stanza, own_pn, device.lid.as_ref())
39            .map_err(crate::features::RetryRequestError::InvalidStanza)?;
40        let max_sender_retry_count = message_enc_nodes_for_device(stanza, Some(own_pn))
41            .map(sender_retry_count)
42            .max()
43            .unwrap_or(0);
44        let info = Arc::new(info);
45        drop(device);
46
47        self.request_retry_for_info(
48            &info,
49            options,
50            (max_sender_retry_count > 0).then_some(max_sender_retry_count),
51        )
52        .await
53    }
54
55    /// Dispatch an `UndecryptableMessage` event at most once per `(chat, id)`
56    /// via the single-flight `get_with` semantic on `undecryptable_dispatched`.
57    /// The atomic arm avoids the get-then-insert race where two concurrent
58    /// callers would both dispatch. Mirrors WA Web's DB-level placeholder
59    /// uniqueness in `WAWebMessageProcessPlaceholder`.
60    ///
61    /// Returns `true` if this call dispatched the event, `false` if a
62    /// previous call already did.
63    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.recv.undecryptable", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), msg_id = %info.id)))]
64    pub(crate) async fn dispatch_undecryptable_event(
65        &self,
66        info: Arc<MessageInfo>,
67        is_unavailable: bool,
68        unavailable_type: crate::types::events::UnavailableType,
69        decrypt_fail_mode: crate::types::events::DecryptFailMode,
70    ) -> bool {
71        let dedup_key =
72            wacore::types::message::ChatMessageId::new(info.source.chat.clone(), info.id.clone());
73        // The init future only runs for the winning caller. Others receive
74        // the cached `()` and leave the flag as false.
75        let fresh = Arc::new(std::sync::atomic::AtomicBool::new(false));
76        let fresh_clone = fresh.clone();
77        self.undecryptable_dispatched
78            .get_with(dedup_key, async move {
79                fresh_clone.store(true, Ordering::Release);
80            })
81            .await;
82        let was_fresh = fresh.load(Ordering::Acquire);
83        if was_fresh {
84            wacore::telemetry::recv("undecryptable");
85            self.core.event_bus.dispatch(Event::UndecryptableMessage(
86                crate::types::events::UndecryptableMessage::builder()
87                    .info(info)
88                    .is_unavailable(is_unavailable)
89                    .unavailable_type(unavailable_type)
90                    .decrypt_fail_mode(decrypt_fail_mode)
91                    .build(),
92            ));
93        } else {
94            log::debug!(
95                "[msg:{}] UndecryptableMessage already dispatched for this id; skipping duplicate event",
96                info.id,
97            );
98        }
99        was_fresh
100    }
101
102    /// Dispatch an undecryptable event, then send the retry receipt and the
103    /// transport ack in one ordered, flushed task.
104    ///
105    /// The retry asks the sender to re-encrypt; the ack clears the stanza from
106    /// the server's offline queue (the retry alone does not). Both run in a
107    /// single `outbound_flush` task so `disconnect()` flushes them together and
108    /// the retry always goes out before the ack: if only one makes it, it is the
109    /// retry, so the message is never cleared without a resend request. status is
110    /// also acked here (flushed) rather than relying on the detached `should_ack`
111    /// gate, which can be dropped mid-flush on disconnect; the server dedups the
112    /// resulting duplicate ack.
113    ///
114    /// Returns `true` to be assigned to `dispatched_undecryptable` flag.
115    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.recv.decrypt_failure", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id, reason = ?reason)))]
116    pub(crate) async fn handle_decrypt_failure(
117        self: &Arc<Self>,
118        info: &Arc<MessageInfo>,
119        reason: RetryReason,
120        decrypt_fail_mode: crate::types::events::DecryptFailMode,
121    ) -> bool {
122        self.dispatch_undecryptable_event(
123            Arc::clone(info),
124            false,
125            crate::types::events::UnavailableType::Unknown,
126            decrypt_fail_mode,
127        )
128        .await;
129        let client = Arc::clone(self);
130        let info = Arc::clone(info);
131        self.outbound_flush.spawn(&*self.runtime, async move {
132            // A self-fanout is our own message; retrying it to ourselves is
133            // futile and the server's offline queue ignores a bare transport
134            // ack, so it would replay forever. Clear it with the sender receipt
135            // instead (same stanza the success/duplicate paths now emit). Mirror
136            // ack_received_message: a bot-authored message in a non-bot chat
137            // takes the bot-invoke-response bare ack (the retry path below), not
138            // the sender receipt. Gate on the same eligibility as the ack path.
139            if info.source.is_self_fanout()
140                && !info.source.is_bot_authored_non_bot_chat()
141                && Self::should_send_delivery_receipt(&info)
142            {
143                client.send_delivery_receipt(&info).await;
144                return;
145            }
146            // Only ack once the resend request is actually out; otherwise leave
147            // the stanza queued so the server redelivers and we retry.
148            let resend_sent = client.run_retry_receipt(&info, reason).await;
149            if resend_sent {
150                client.send_transport_ack(&info).await;
151            }
152        });
153        true
154    }
155
156    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.recv.plaintext_failure", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), msg_id = %info.id)))]
157    pub(crate) async fn handle_plaintext_failure(
158        self: &Arc<Self>,
159        info: &Arc<MessageInfo>,
160        decrypt_fail_mode: crate::types::events::DecryptFailMode,
161    ) -> bool {
162        let dispatched = self
163            .dispatch_undecryptable_event(
164                Arc::clone(info),
165                false,
166                crate::types::events::UnavailableType::Unknown,
167                decrypt_fail_mode,
168            )
169            .await;
170        self.spawn_nack(info, NackReason::InvalidProtobuf, None);
171        dispatched
172    }
173
174    /// Increments the retry count for a message and returns the new count.
175    /// Returns `None` if max retries have been reached.
176    ///
177    pub(crate) async fn increment_retry_count(
178        &self,
179        cache_key: &str,
180        reason: RetryReason,
181    ) -> Option<u8> {
182        self.message_retry_counts
183            .upsert_with_by_ref(cache_key, |current| {
184                let count = match current {
185                    Some((count, _)) if *count >= MAX_DECRYPT_RETRIES => return (None, None),
186                    Some((count, _)) => *count + 1,
187                    None => 1,
188                };
189                (Some((count, Some(reason))), Some(count))
190            })
191            .await
192    }
193
194    /// Raise the local retry count to a sender-echoed count without allowing a
195    /// concurrent local increment to be overwritten.
196    pub(crate) async fn preseed_retry_count(&self, cache_key: &str, sender_count: u8) {
197        self.message_retry_counts
198            .upsert_with_by_ref(cache_key, |current| match current {
199                Some((count, _)) if *count >= sender_count => (None, ()),
200                Some((_, reason)) => (Some((sender_count, *reason)), ()),
201                None => (Some((sender_count, None)), ()),
202            })
203            .await;
204    }
205
206    /// Generate consistent cache key for retry logic.
207    pub(crate) async fn make_retry_cache_key(
208        &self,
209        chat: &Jid,
210        msg_id: &str,
211        sender: &Jid,
212    ) -> String {
213        // Two independent LID/PN resolves for different JIDs — run concurrently.
214        let (chat, sender) = futures::join!(
215            self.resolve_encryption_jid(chat),
216            self.resolve_encryption_jid(sender),
217        );
218        // +40 covers @server suffixes, :device, separators for two JIDs
219        let mut key =
220            String::with_capacity(chat.user.len() + msg_id.len() + sender.user.len() + 40);
221        chat.push_to(&mut key);
222        key.push(':');
223        key.push_str(msg_id);
224        key.push(':');
225        sender.push_to(&mut key);
226        key
227    }
228
229    /// Spawns a task that sends a retry receipt for a failed decryption.
230    ///
231    /// This is used when sessions are not found or invalid to request the sender to resend
232    /// the message with a PreKeySignalMessage to re-establish the session.
233    ///
234    /// # Retry Count Tracking
235    ///
236    /// This method tracks retry counts per message (keyed by `{chat}:{msg_id}:{sender}`)
237    /// and stops sending retry receipts after `MAX_DECRYPT_RETRIES` (5) attempts to prevent
238    /// infinite retry loops. This matches WhatsApp Web's behavior.
239    ///
240    /// # PDO Backup
241    ///
242    /// A PDO (Peer Data Operation) request is spawned only on the FIRST retry attempt.
243    /// This asks our primary phone to share the already-decrypted message content.
244    /// PDO is NOT spawned on subsequent retries to avoid duplicate requests.
245    ///
246    /// When max retries is reached, a PDO request is attempted as a last resort;
247    /// the `pdo_requested` memo makes it a no-op if one already went out for
248    /// this message, so capped redeliveries cannot re-ask the phone.
249    ///
250    /// # Arguments
251    /// * `info` - The message info for the failed message
252    /// * `reason` - The retry reason code (matches WhatsApp Web's RetryReason enum)
253    #[cfg(test)]
254    pub(crate) fn spawn_retry_receipt(
255        self: &Arc<Self>,
256        info: &Arc<MessageInfo>,
257        reason: RetryReason,
258    ) {
259        let client = Arc::clone(self);
260        let info = Arc::clone(info);
261        self.outbound_flush.spawn(&*self.runtime, async move {
262            client.run_retry_receipt(&info, reason).await;
263        });
264    }
265
266    /// Increment the retry count and send the retry receipt (or, at the cap, a
267    /// last-resort PDO). This is the shared operation used by explicit requests
268    /// and the automatic decrypt-failure pipeline.
269    async fn request_retry_for_info(
270        self: &Arc<Self>,
271        info: &Arc<MessageInfo>,
272        options: crate::features::RetryRequestOptions,
273        sender_retry_count: Option<u8>,
274    ) -> Result<crate::features::RetryRequestOutcome, crate::features::RetryRequestError> {
275        let reason = options.reason();
276        let cache_key = self
277            .make_retry_cache_key(&info.source.chat, &info.id, &info.source.sender)
278            .await;
279
280        if let Some(sender_retry_count) = sender_retry_count {
281            self.preseed_retry_count(&cache_key, sender_retry_count)
282                .await;
283        }
284
285        let Some(retry_count) = self.increment_retry_count(&cache_key, reason).await else {
286            log::debug!(
287                "Max retries ({}) reached for message {} from {} [{:?}]. Requesting PDO fallback.",
288                MAX_DECRYPT_RETRIES,
289                info.id,
290                info.source.sender.observe(),
291                reason
292            );
293            self.run_pdo_request(info).await;
294            return Ok(crate::features::RetryRequestOutcome::LimitReached);
295        };
296
297        if retry_count > HIGH_RETRY_COUNT_THRESHOLD {
298            log::warn!(
299                "High retry count ({}) for message {} in chat {} from {} [{:?}]",
300                retry_count,
301                info.id,
302                info.source.chat.observe(),
303                info.source.sender.observe(),
304                reason
305            );
306        }
307
308        let send_result = self
309            .send_retry_receipt(info, retry_count, reason, options.force_include_keys())
310            .await;
311
312        // PDO is an independent first-attempt recovery path. Preserve it even
313        // when building or sending the retry receipt fails; the caller still
314        // receives that failure and the automatic pipeline still withholds its
315        // transport acknowledgement.
316        if retry_count == 1 {
317            self.run_pdo_request(info).await;
318        }
319
320        let send_outcome = send_result?;
321
322        let outcome = match send_outcome {
323            crate::retry::RetryReceiptSendOutcome::Sent { included_keys } => {
324                wacore::telemetry::retry_receipt(reason.as_str());
325                if retry_count >= MAX_DECRYPT_RETRIES {
326                    wacore::telemetry::high_retry(reason.as_str());
327                }
328                debug!(
329                    "Sent retry receipt #{} for message {} in chat {} from {} [{:?}]",
330                    retry_count,
331                    info.id,
332                    info.source.chat.observe(),
333                    info.source.sender.observe(),
334                    reason
335                );
336                crate::features::RetryRequestOutcome::Sent {
337                    retry_count,
338                    included_keys,
339                }
340            }
341            crate::retry::RetryReceiptSendOutcome::Suppressed => {
342                crate::features::RetryRequestOutcome::Suppressed { retry_count }
343            }
344        };
345
346        Ok(outcome)
347    }
348
349    /// Awaitable automatic wrapper so retry can be ordered before transport ack.
350    ///
351    /// Returns whether the caller should send the ack: `false` when we intended
352    /// to retry but the send failed (so the stanza stays queued for another try),
353    /// `true` when the resend went out or we deliberately gave up at the cap.
354    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.recv.retry_receipt", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id, reason = ?reason)))]
355    async fn run_retry_receipt(
356        self: &Arc<Self>,
357        info: &Arc<MessageInfo>,
358        reason: RetryReason,
359    ) -> bool {
360        match self
361            .request_retry_for_info(
362                info,
363                crate::features::RetryRequestOptions::new().with_reason(reason),
364                None,
365            )
366            .await
367        {
368            Ok(_) => true,
369            Err(error) => {
370                log::error!(
371                    "Failed to send retry receipt for message {} [{:?}]: {error:?}",
372                    info.id,
373                    reason
374                );
375                false
376            }
377        }
378    }
379}