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