Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions api/bot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,37 @@ Leave this at the default unless you have a specific reason to change it. Embedd
The floor of 5 prevents an empty-but-flagged pool and a re-upload loop (the count guard never clears below the trigger threshold). The ceiling of 65,535 is the wire-format limit — the upload IQ encodes the pre-key list length as a `u16`, so a larger batch would generate and store keys locally and then fail to encode.
</Warning>

### with_resend_rate_limit

```rust
pub fn with_resend_rate_limit(self, burst: u32, refill_per_min: u32) -> Self
```

Tunes the per-chat outbound resend rate limiter at build time.

Outbound retry resends to a group are bounded by a token bucket keyed on the chat JID. `burst` is the instantaneous allowance; `refill_per_min` is the sustained per-chat ceiling. This caps the aggregate resend rate WhatsApp's anti-abuse penalizes during a PN-to-LID migration fan-out, where many distinct devices retry the same messages concurrently. A `burst` of `0` disables the limiter.

Defaults apply without calling this method (burst 20, refill 10/min). Can also be retuned live after build via [`Client::set_resend_rate_limit`](/api/client#set_resend_rate_limit).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

<ParamField path="burst" type="u32" required>
Instantaneous token allowance per chat. Set to `0` to disable the limiter.
</ParamField>

<ParamField path="refill_per_min" type="u32" required>
Tokens replenished per minute per chat (sustained ceiling).
</ParamField>

**Example:**
```rust
Bot::builder()
.with_resend_rate_limit(30, 15) // larger burst for busy groups
// ...
```

<Tip>
The conservative defaults (burst 20, refill 10/min) are safe for most deployments. Only tune upward if legitimate retry traffic exceeds the default budget, or downward if you see `AccountLocked` errors from an active fan-out storm.
</Tip>

---

## Building and Running
Expand Down Expand Up @@ -860,6 +891,7 @@ The `recent_messages` cache is disabled by default (capacity 0), meaning sent me
|---------|---------|-------------|
| `session_locks_capacity` | 10,000 | Per-device Signal session lock capacity |
| `chat_lanes_capacity` | 5,000 | Per-chat lane capacity (combined enqueue lock + message queue) |
| `resend_rate_limiter_capacity` | 4,096 | Per-chat resend rate-limiter bucket capacity. Eviction is fail-open (evicted bucket recreates full). Keep above the count of groups concurrently driving retry storms. |

#### Sent message DB cleanup

Expand Down
52 changes: 52 additions & 0 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,56 @@ pub fn wanted_pre_key_count(&self) -> usize

Returns the currently configured pre-key upload batch size.

### set_resend_rate_limit

```rust
pub fn set_resend_rate_limit(&self, burst: u32, refill_per_min: u32)
```

Retunes the per-chat outbound resend rate limiter live, without reconnecting.

Outbound retry resends to a group are bounded by a token bucket keyed on the chat JID. `burst` is the instantaneous allowance; `refill_per_min` is the sustained per-chat ceiling. This caps the aggregate resend rate WhatsApp's anti-abuse penalizes during a PN-to-LID migration fan-out. Takes effect on each chat's next retry; a lowered `burst` clamps a live bucket on its next access. A `burst` of `0` disables the limiter entirely.

<ParamField path="burst" type="u32" required>
Instantaneous token allowance per chat. Set to `0` to disable.
</ParamField>

<ParamField path="refill_per_min" type="u32" required>
Tokens replenished per minute per chat.
</ParamField>

**Example:**
```rust
// Tighten during an observed fan-out storm
client.set_resend_rate_limit(5, 3);

// Restore defaults
client.set_resend_rate_limit(20, 10);

// Disable entirely
client.set_resend_rate_limit(0, 0);
```

<Tip>
Configure at build time via [`BotBuilder::with_resend_rate_limit`](/api/bot#with-resend-rate-limit). Use this method only to tune live without restarting.

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 Fix the BotBuilder anchor link

This new cross-link points to #with-resend-rate-limit, but the target section added in api/bot.mdx is headed ### with_resend_rate_limit, matching the repo's existing snake_case anchor style for Rust methods. When readers click this tip, the fragment will not jump to the new builder method and link checks for this internal anchor will fail; point it at /api/bot#with_resend_rate_limit instead.

Useful? React with 👍 / 👎.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
</Tip>

### resends_throttled_total

```rust
pub fn resends_throttled_total(&self) -> u64
```

Returns the total number of outbound resends dropped by the per-chat rate limiter since the client started. A rising value indicates an active retry storm is being throttled. Available without the `debug-diagnostics` feature.

**Example:**
```rust
let dropped = client.resends_throttled_total();
if dropped > 0 {
tracing::warn!("Resend storm: {} resends throttled by rate limiter", dropped);
}
```

### set_force_active_delivery_receipts

```rust
Expand Down Expand Up @@ -1825,6 +1875,8 @@ Returns a snapshot of all internal collection sizes for memory leak detection. M
| `sender_key_device_cache` | `u64` | Per-group sender key device tracking cache |
| `session_locks` | `u64` | Per-device session locks |
| `chat_lanes` | `u64` | Per-chat lanes (combined enqueue lock + message queue) |
| `resend_rate_limiter_chats` | `u64` | Active per-chat resend rate-limiter token buckets |
| `resends_throttled_total` | `u64` | Total outbound resends dropped by the rate limiter since start |
| `response_waiters` | `usize` | Active IQ response waiters |
| `node_waiters` | `usize` | Active node waiters |
| `pending_retries` | `usize` | Pending message retries |
Expand Down
79 changes: 79 additions & 0 deletions changelog/2026-06-15-per-chat-resend-rate-limit.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
title: "June 15, 2026 — Per-chat outbound resend rate limiter"
description: "Adds a token-bucket rate limiter on group retry resends to prevent AccountLocked during PN-to-LID migration fan-outs. Tunable via BotBuilder::with_resend_rate_limit and Client::set_resend_rate_limit."
---

PR [#871](https://github.com/oxidezap/whatsapp-rust/pull/871) adds a per-chat token-bucket rate limiter on outbound retry resends to protect against the `AccountLocked` error produced by high-volume PN-to-LID migration fan-outs.

## Problem

During a mass PN-to-LID migration, hundreds of distinct devices each fail to decrypt the same group messages and each send a retry receipt. The library pairwise-resends to every device, and WhatsApp's anti-abuse system penalizes the **aggregate per-chat resend rate** — not any single device's depth. The existing `MAX_RETRY_COUNT=5` guard trusts the peer-echoed `count` field, which stays at `1` during a fan-out, so it never fires. The result is an `AccountLocked` error under sustained fan-out conditions.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Fix

A per-chat token-bucket rate limiter applied to every outbound group retry resend:

- **Keyed by chat JID**: bounds the aggregate resend rate regardless of how many distinct devices drive it.
- **Drop, don't queue**: throttled devices are already marked for fresh SKDM earlier in the retry path — they recover on the next normal group send without any timer or queued work. The hot path stays allocation-free.
- **Lazy refill off the monotonic clock**: correct over long sessions, immune to wall-clock jumps, no background task required.
- **Capacity-only cache**: bounds memory; eviction is fail-open (an evicted bucket recreates full), so undersizing only forgives rate, never over-throttles.
- **Group-only**: DMs have no SKDM fallback, so they keep the unconditional resend (bounded by `MAX_RETRY_COUNT`) to avoid dropped deliveries.

## Defaults

| Parameter | Default | Description |
|-----------|---------|-------------|
| Burst | 20 | Instantaneous allowance per chat |
| Refill rate | 10 / min | Sustained per-chat ceiling |

The defaults are conservative: well under the rate observed to trip `AccountLocked`, yet above any healthy chat's steady resend need. A burst of `0` disables the limiter entirely.

## New API

### `BotBuilder::with_resend_rate_limit`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Configure the limiter at build time:

```rust
Bot::builder()
// ...
.with_resend_rate_limit(30, 15) // burst 30, refill 15/min
.build()
.await?
```

### `Client::set_resend_rate_limit`

Retune live without reconnecting:

```rust
// Tighten during an observed fan-out storm
client.set_resend_rate_limit(5, 3);

// Disable entirely
client.set_resend_rate_limit(0, 0);
```

### `Client::resends_throttled_total`

Surfaces storm activity without the `debug-diagnostics` feature:

```rust
let dropped = client.resends_throttled_total();
if dropped > 0 {
tracing::warn!("Resend storm: {} resends dropped by rate limiter", dropped);
}
```

### `CacheConfig::resend_rate_limiter_capacity`

Controls the per-chat bucket cache capacity (default: 4096). Keep this above the count of groups concurrently driving retry storms. Eviction is fail-open.

### `MemoryDiagnostics` new fields

Two new fields are available in the snapshot returned by `client.memory_diagnostics()`:

| Field | Description |
|-------|-------------|
| `resend_rate_limiter_chats` | Active per-chat token buckets |
| `resends_throttled_total` | Total resends dropped since start |
1 change: 1 addition & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"group": "Changelog",
"pages": [
"changelog/overview",
"changelog/2026-06-15-per-chat-resend-rate-limit",
"changelog/2026-06-14-drop-moka-portable-cache",
"changelog/2026-06-12-binary-size-ci",
"changelog/2026-06-11-bot-api-overhaul",
Expand Down