Skip to main content

Overview

WhatsApp Web has no volume-based throttle on inbound retry receipts. WAWebHandleRetryRequest serializes them per chat, refuses past MAX_RETRY, and otherwise processes every receipt — its gates are all semantic (ALREADY_DELIVERED, CHANGED_IDENTITY, RECORD_MISSING, DEVICE_NOT_RECIPIENT, HIGH_RETRY_COUNT, MESSAGE_EXPIRED), never “this member is asking too often.” This SDK mirrors that: by default, every inbound <receipt type="retry"> runs the full repair path (markForgetSenderKey, key-bundle processing, resend).
The sender-key repair (markForgetSenderKey) no longer waits on finding the original message (#1300). A group/status send marks its entire distribution list as holding the sender key, including a device whose SKDM failed to encrypt. That makes the retry receipt’s cold mark the only path back for that device. Before #1300, the mark ran behind the recent-message lookup, along with everything else downstream in the handler: the group-info fetch, the unknown-participant rotateKey block, key-bundle processing, and the resend itself. The lookup carries its own TTL (sent_message_ttl_secs, default 2h), and a genuine retry storm can outlive it — a device that did stayed stranded warm forever, even across restarts. #1300 moved only the cold mark ahead of the lookup. The group-info fetch, rotateKey block, and resend stayed gated on a lookup hit — and so, at the time, did key-bundle processing. #1303 later moved part of key-bundle processing ahead of the lookup too; see the next note.
Key-bundle installation now joins the cold mark ahead of the lookup, for every route except a DM (#1303). #1303 splits update_local_signal_session into two steps. install_retry_key_bundle runs the processKeyBundle step: it only reads keys the receipt already carries, with no network call and no deletion. reconcile_retry_session runs the reg-ID-mismatch session delete and the base-key bookkeeping; only the gated resend can undo either one. For Group, Status, and broadcast-list retries, install_retry_key_bundle now also runs before the recent-message lookup, immediately ahead of the cold mark: install first, then mark, so a device only reads warm once its session can actually carry the next SKDM. reconcile_retry_session still waits for the lookup on every route, since its branches either delete a session nothing else rebuilds or stamp a row keyed by the message ID. A DM’s encryption JID stays unsettled until the lookup runs, because an alternate PN/LID hit can still rewrite it — so Direct retries keep both steps behind the lookup, unchanged from before #1303.
A primary device for which local SKDM encryption produced no node stopped being part of that “entire distribution list” marking, as of #1328. This covers a local failure only — a failed pre-key fetch, a session-setup error — not a node that was sent but never reached or was never processed by the recipient; that case has no sender-side signal and still relies on this page’s retry-receipt repair, same as before. The whole-set marking described above (markHasSenderKey(x, M), unchanged) still applies to every external participant’s companion device that was targeted — such a companion whose SKDM failed to encrypt is still marked keyed, and this page’s retry-receipt cold mark is still the only path back for it. A primary device is now excluded from that marking when local encryption produced no SKDM for it, matching getKeyDistributionMsg’s isPrimaryDevice gate: WA Web can never reach the marking with a failed primary in the target set at all, because a primary’s encryption failure rejects the entire send. This SDK’s best-effort send carries on instead of failing the whole group over one member, so it enforces the same guarantee directly, by excluding that device from the marking rather than failing the send. Such a primary is retargeted on the very next send instead of depending on this page’s retry-receipt repair — closing what had been a stall that, in the field report motivating this fix, only cleared once the affected member sent traffic of her own.
A single-user WA Web client never sends at a volume where this matters. A bot in a large group can, if a cohort of members has pairwise sessions that never establish — each of those members retries on every message, driving the repair path at storm rate. Registering a RetryAdmission policy lets you bound that cost without the SDK diverging from WhatsApp Web by default. Leaving it unset keeps exact WA Web behavior, at zero cost — the check on the receive path is a single OnceLock::get().
This is an opt-in seam for bot-scale deployments. Dropping a retry receipt is a deliberate decision to skip an eligible repair request; the SDK will never do this on its own.

Scope

The policy is only consulted for group and status@broadcast retry receipts from other accounts. It is never consulted for:
  • Retries from your own companion devices (is_peer) — their session must always be able to rebuild.
  • Any 1:1 (DM) retry.
The check runs immediately after the unknown-device (hasDevice) gate, before the sender-key repair and the rest of the handler. For an unknown device, note that schedule_unknown_device_sync (a device-list resync) queues before both the hasDevice gate and this admission check — a policy cannot bound that resync, only the repair work that follows it. A false from admit skips all repair work: the sender-key cold mark (markForgetSenderKey), key-bundle installation (install_retry_key_bundle, pre-lookup for the routes this policy covers), the recent-message lookup, the group-info fetch, the unknown-participant rotateKey block, session reconciliation (reconcile_retry_session, post-lookup), and the resend. The SDK does not queue a dropped receipt; the requester re-requests it on its own timer, so a policy should refill over time to keep genuine recovery possible.
As of #1300 and #1303, install_retry_key_bundle and mark_requester_for_fresh_skdm (markForgetSenderKey) both run right after this admission check, ahead of the recent-message lookup, the group-info fetch, and the rotateKey block. The install step runs first; the cold mark runs last, so a device only reads warm once its session is actually installed. The admission check still gates all of that repair work — only the position of these two steps relative to the lookup changed.

The RetryAdmission trait

Object-safe and WASM-safe (MaybeSendSync is Send + Sync on native targets, unbounded on wasm32). admit is called inline on the receive path and is deliberately synchronous — a slow or awaiting policy would stall retry processing for the pending key, and a gate never needs to wait. Keep it to a fast, local decision (an atomic counter, a token-bucket check); do no I/O or blocking inside it. The device is intentionally part of requester, but a policy may key on the user alone: WhatsApp Web re-targets a whole user’s sender key when its primary device goes cold, so all devices of one broken account can reasonably share a single budget. RetryAdmission is re-exported from the crate root and from the prelude.
A RetryAdmission policy that never admits another retry from a given (chat, requester) pair permanently excludes that device from SKDM distribution — this was already true before #1300. #1300 and #1303 both improve the policy’s odds, not worsen them. Previously, a dropped receipt’s eventual repair depended on a later admitted retry arriving before the original message aged out of the recent-message cache (sent_message_ttl_secs, default 2h). Since #1300, any later admitted retry repairs the sender key regardless of whether that message is still cached. Since #1303, that same admitted retry also installs the device’s session from the receipt’s own key bundle, not just the cold mark. A policy with a slow refill rate now recovers a fully working session more reliably than before, not less.

Opting in

Client::set_retry_admission returns false (and keeps the previously-registered policy) if called more than once — set it once, before connecting. Live tuning belongs inside the policy itself (e.g. atomics), not in re-registration.

Example: token-bucket quarantine

The repository ships examples/retry_quarantine.rs: a per-(chat, requester) token bucket, burst 2 / refill 2 per day, with a bounded keyspace (fails open — admits — once the tracked-pair cap is reached, rather than growing unbounded or blocking a brand-new pair).
Key points from that example:
  • Keyed by user, not device(chat.user, requester.user), matching the “one broken account, one budget” rationale above.
  • Burst 2 repairs a healthy member immediately (one mark is enough — the next send already carries the SKDM); receipts past the burst from the same pair are dropped before any repair work.
  • Refill 2/day keeps genuine recovery possible for a member whose session is intermittently broken, without allowing a sustained storm.
  • burst = 0 disables the policy outright (always admits), useful for a kill switch without unregistering.

Design rationale

This hook is a decoupled, WA Web-compliant alternative to embedding a quarantine directly in the SDK’s default retry-receipt path: the core stays byte-for-byte WA Web, and any volume-based policy — including the exact mechanism above — lives in operator code via this trait instead of being on by default.

See also

  • Inbound Durability Hook — the sibling opt-in hook idiom (OnceLock, zero overhead unset) that this trait follows.