Skip to content

feat(client): report a 429 stream error as a StreamError event - #1263

Merged
jlucaso1 merged 2 commits into
mainfrom
claude/whatsapp-rate-limit-behavior-1o5mc1
Aug 10, 2026
Merged

feat(client): report a 429 stream error as a StreamError event#1263
jlucaso1 merged 2 commits into
mainfrom
claude/whatsapp-rate-limit-behavior-1o5mc1

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Summary

Two behaviours were flagged as possible defects. Ground truth settles them differently.

Changes: the 429 arm of handle_stream_error now dispatches Event::StreamError, like every other coded arm. No internal behaviour moves — is_logged_in, the +5, and the reset suppression are untouched.

Does not change: the pre-key retry loop. WA Web's loop has no attempt limit either, and for the same reasons. Recorded in a doc comment so the next investigation does not reopen it.

Not changed but worth a decision: the 429 backoff ladder diverges hugely from WA Web. Evidence below, no code touched — see Unchanged, and why.

Ground truth

Bundle: waVersion 2.3000.1044659339, the 497 files pinned in whatspec's generated/bundles.lock.json, each fetched from static.whatsapp.net and sha256-verified against the lockfile. docs/captured-js/ was not present locally, so the pinned set is the bundle. whatspec IR was queried for the WAM catalogue; every control-flow answer below comes from the bundle, because the IR models shape and not sequencing.

Observation A — <stream:error code="429">

WAWebHandleStreamError parses the stanza into conflict / code / ack / xml-not-well-formed / other, then:

  • type === "code" && code >= 500 && code < 600: 515 restarts login, 516 forces logout, everything else calls WAComms.onStreamErrorReceived().
  • otherwise: arms for device_removed, replaced, xml-not-well-formed.
  • fallthrough: return "CLOSE_SOCKET".

429 is not in 500..600. It matches no arm at all and reaches the fallthrough. What WA Web makes observable for a rate-limited session, in full:

  • no log in the handler (the WARN "Unrecognized stream:error" belongs to the parser's other branch, which a coded stanza never reaches),
  • [comms] job response is CLOSE_SOCKET and [comms] Socket N closed in WAComms — emitted for every socket close,
  • onConnectionChange, whose vocabulary is disconnected / in_handshake / connected (WANotifyConnectionChangeFactory) and carries no reason,
  • no WAM event: the catalogue has no stream-error event, and WebcSocketConnect's only reason field is webcSocketConnectReason, written on connect by WAWebOpenChatSocket.

So: nothing distinguishes "rate limited" from "disconnected" in WA Web. The missing event is not a fidelity defect. It is a gap in our contract, since Event::StreamError is the channel every other coded branch reports through and 429/503 are the two silent exclusions — hence the change, marked in the code as choice rather than fidelity.

Two further answers to the questions asked:

  • Backoff scaling. WA Web applies none for 429. The reconnect ladder lives entirely in WAComms.socketLoop, a PromiseRetryLoop over socketLoopIteration. onStreamErrorReceived() — the only per-code backoff lever, reached by 5xx and never by 429 — is socketLoop.cancelReset(), which nulls the early-reset deadline armed by resetTimeoutAfter(1e4) when a socket connects. It does not touch resetDelay: 3e4. There is no per-code counter increment anywhere.
  • Surviving a reload. No. The ladder's state is the closure inside WAPromiseBackoffs.createTimer, held by a PromiseRetryLoop instance created at comms construction. A page reload starts at zero.

The production ladder config, WAWebCommsConfigBase, confirms our constants exactly:

var s = 9e5;
function u(){return {jitter:.1, max:s, algo:{type:"fibonacci", first:1e3, second:1e3}, relativeDelay:!1}}
maxSocketLoopWaitTime: s, socketReconnectBackoffAlgo: u(),

fibonacci_backoff's doc comment states this verbatim, and the sequence matches to the millisecond (createTimer's first call returns 0 and is consumed priming the loop, so the retry delays are 1s, 1s, 2s, 3s, 5s, 8s…, capped at 900s). One cosmetic deviation: WA Web's jitter is Math.ceil(i*(1+0.1*Math.random())), i.e. 0..+10%; ours is ±10%. Left alone.

Observation B — the pre-key upload retry loop

WAWebUploadPreKeysJob, in full:

var g = 812;
var v = {algo:{type:"fibonacci", first:1e3, second:2e3}, max:61e4};
new PromiseRetryLoop({name:"uploadPreKeys", timer:v, code: async (end) => {
  var n = await _uploadPreKeys();
  (n?.success === true) ? (LOG("uploadPreKeys: done"), end()) : LOG("uploadPreKeys: retrying (after delay)");
}});

Answering each question:

  • Attempt limit: none. max is a delay clamp, and provably so — WAPromiseBackoffs's d() reads it as o != null && i > o && (i = o), and nothing else reads the field. PromiseRetryLoop carries no attempt counter; its only terminating exit is endWithValue, called on success. Our MAX_DELAY_SECS = 610 is the correct reading of max: 61e4.
  • Rate-limit special case: none. _uploadPreKeys branches the error three ways for logging only — >=500 "server requested backoff", 406 "uploaded invalid keys", anything else (where 429 lands) "unrecognized error" — and all three return {errorCode, errorText}, which is not success, so all three retry identically. A disconnect mid-upload is caught, logged "disconnected, server state unknown", and returns undefined, which also retries.
  • Key count: 812. g = 812, exported as UPLOAD_KEYS_COUNT. Our DEFAULT_WANTED_PRE_KEY_COUNT is exact. Baileys independently agrees (INITIAL_PREKEY_COUNT = 812).

Two places where we already differ, both in the safer direction, both left alone: our !is_logged_in bail is an exit WA Web does not have (it awaits WAComms.waitForConnection() and keeps going), and WA Web's IQ send is deprecatedSendIqWithoutRetry, so the loop is its only retry — as here.

A related suspicion also checked and cleared: the window is abandoned on failure, so every retry ships 812 fresh keys rather than re-offering the same ones. That is WA Web's behaviour too — markKeyAsUploaded(lastKeyId) runs in the promise chain before deprecatedSendIqWithoutRetry, moving the watermark past the batch whatever the IQ answers.

Measurements

The 429 reconnect ladder (handle_stream_error + fibonacci_backoff), reproducing the numbers this task was filed with:

429 auto_reconnect_errors next attempt cumulative offline
#1 5 8.4s 8.4s
#2 11 146.2s 154.6s
#3 17 813.9s 968.5s
#4 23 903.5s 1872.0s

WA Web, from the same event and no penalty: 1s, 1s, 2s, 3s — about 7s of cumulative downtime over four consecutive 429s, against our 1872s. Its ladder still escalates on repeated failures, because each short-lived socket is one un-reset loop iteration; what it lacks is the +5 jump-start.

The pre-key loop against a permanent 429, which the earlier investigation could not exercise. Harness: CapturingMockTransport + a test noise socket, with a responder subscribed to Event::SentFrame that answers every <iq xmlns="encrypt"> with <iq type="error"><error code="429" text="rate-overlimit"/></iq>, on tokio::time paused so an hour runs in milliseconds.

  • 18 uploads in one virtual hour, at t = 0, 1, 3, 6, 11, 19, 32, 53, 87, 142, 231, 375, 608, 985, 1595, 2205, 2815, 3425 s — 12 of them inside the first 10 minutes, then steady state at one per 610s.
  • 40,800 bytes on the wire per attempt, ≈490 KB over the first 10 minutes and ≈734 KB per hour.
  • The loop never exits. The 3600s timeout is what ended the measurement.
  • Each attempt's first key id advances by 812 (1, 813, 1625, 2437, …), confirming the abandon-and-regenerate behaviour described above.
  • An IQ-level 429 does not clear is_logged_in, so the disconnect bail never fires — as suspected, and as WA Web behaves.

These numbers are WA Web's numbers too: same count, same schedule, same 812 keys.

Second opinions

429 stream error pre-key upload retry
WA Web no arm; CLOSE_SOCKET, no reason surfaced, no penalty; proactively closes the socket unbounded, no rate-limit case, 812 keys
whatsmeow default: arm → ERROR "Unknown stream error" + dispatchEvent(events.StreamError{Code, Raw}); clears isLoggedIn for every code; no penalty n/a
Baileys ERROR "stream errored out" + end(Boom(statusCode)), surfaced to the consumer via connection.update; no per-code arm retryCount < 3, min(1000·2^n, 10000) backoff, raced against UPLOAD_TIMEOUT; INITIAL_PREKEY_COUNT = 812

Where they disagree: on observability, both second opinions hand the embedder something the moment a 429 arrives, and WA Web hands its UI nothing — which is the split this PR resolves in the embedder's favour. On the retry loop they disagree with each other, and WA Web breaks the tie: Baileys' 3-attempt cap has no counterpart in the official client (the task's note that Baileys has no retry at all is out of date — it gained one, just a bounded one).

Unchanged, and why

  • The pre-key retry loop. Unbounded is what WA Web does, for every error class including 429, at the same 812 keys. Evidence recorded in the doc comment on upload_pre_keys_with_retry. No test, since nothing changed.
  • The 429 backoff ladder — flagged, not touched. This is the one place ground truth says we diverge, and it diverges by 1872s against 7s over four errors. WA Web has no +5 and no cancelReset for 429; cancelReset is reached only by 5xx. Left alone because the task scoped ladder changes out and because aligning it properly means touching the shared reconnect path and the 503 arm together, not the 429 arm alone. It is the highest-value follow-up here, and I'd suggest it get its own task.
  • The 503 arm. Also silent, and the conclusion for 429 does apply to it. It is worse than a mirror image: WA Web's 5xx arm does call onStreamErrorReceived() while ours applies no penalty at all, so the two arms are inverted relative to ground truth. Deliberately left out of this diff — its own task.
  • Jitter in fibonacci_backoff. WA Web's is one-sided upward, ours is symmetric. Cosmetic at this amplitude.

Validation

cargo fmt --all
cargo test -p whatsapp-rust --lib     # 1583 passed
cargo test -p wacore --lib            # 1413 passed
cargo clippy -p whatsapp-rust -p wacore --all-targets -- -D warnings

cargo nextest is not installed in this environment, so the same tests ran under cargo test. cargo clippy --workspace cannot complete here: alsa-sys fails to build for want of alsa.pc, unrelated to this diff — the two touched crates are clean. E2E was not run (no mock server).

Both new tests were verified to fail with the dispatch removed and pass with it restored.


Generated by Claude Code

A `<stream:error code="429">` was the one coded branch that adjusted
connection state without telling anyone: it clears `is_logged_in`, adds 5
to the reconnect error count and suppresses the stability reset, which
parks the client for minutes with nothing but an absent connection to
explain it. Dispatch `Event::StreamError` there, as every other coded
branch already does.

This is not fidelity to WA Web. `Handle/StreamError.js` special-cases only
500..600 (515 and 516 by hand, the rest via `onStreamErrorReceived`), so
429 falls through to a bare `CLOSE_SOCKET` and is indistinguishable from
any other reconnect — workable when a human is watching the UI, not when
an embedder is the only observer. whatsmeow reaches the same event through
its default arm.

Also record, at the pre-key retry loop, that its missing attempt limit is
the mirror and not a gap: `WAWebUploadPreKeysJob` ends only on success,
and its >=500 / 406 / unrecognized error arms all fall through to the same
retry, so a rate-limited upload retries there exactly as it does here.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2d77bc62-2c08-420b-a233-ba561c706d74

📥 Commits

Reviewing files that changed from the base of the PR and between e6e05cf and ea9a650.

📒 Files selected for processing (2)
  • src/client/node_io.rs
  • src/prekeys.rs

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of rate-limit stream errors to provide clearer error events and more reliable reconnection behavior.
    • Preserved existing logout and stream-replacement behavior for other connection errors.
    • Capped pre-key retry delays to prevent excessive backoff after repeated failures.
  • Documentation

    • Clarified pre-key retry behavior, including the maximum delay and unlimited retries until success or disconnection.

Walkthrough

The client now emits Event::StreamError for code 429 after updating session and reconnect state. Tests cover this behavior and preserve handling for codes 516, 401, and 409. Pre-key retry delays now saturate at 610 seconds while retries remain unlimited.

Changes

Stream-error event handling

Layer / File(s) Summary
429 event dispatch and regression coverage
src/client/node_io.rs, src/client/tests.rs
Code 429 emits one Event::StreamError with the code and raw stanza. Tests verify stanza preservation and existing neighboring-code behavior.

Pre-key retry backoff

Layer / File(s) Summary
Capped pre-key retry backoff
src/prekeys.rs
Pre-key retries remain unlimited until success or disconnection. Fibonacci delay progression uses saturating addition and caps at 610 seconds.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description directly explains the 429 event change, retry behavior, scope decisions, evidence, and validation results.
Title check ✅ Passed The title clearly and concisely identifies the primary change: dispatching a StreamError event for code 429.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/whatsapp-rate-limit-behavior-1o5mc1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Greptile Summary

The PR surfaces rate-limit stream errors to embedders and prevents the pre-key retry sequence’s internal arithmetic from overflowing.

  • Dispatches Event::StreamError with the original stanza for code 429 after applying rate-limit state.
  • Adds coverage for the new event and neighboring stream-error branches.
  • Clamps the pre-key retry sequence’s internal Fibonacci state at the existing 610-second delay ceiling.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/client/node_io.rs Adds the 429 StreamError dispatch after the relevant session and backoff state updates.
src/client/tests.rs Verifies the 429 event payload and confirms neighboring error codes retain their existing event behavior.
src/prekeys.rs Documents the intentional unbounded retry policy and safely clamps internal backoff arithmetic without changing sleep timing.

Reviews (2): Last reviewed commit: "fix(prekeys): saturate the retry loop's ..." | Re-trigger Greptile

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/prekeys.rs`:
- Around line 727-733: Update the retry loop’s Fibonacci delay-state calculation
in the surrounding prekey upload logic to use saturating addition before storing
delay_a + delay_b in u64. Keep the existing MAX_DELAY_SECS cap and
unlimited-retry behavior unchanged, while ensuring overflow cannot panic in
debug builds or wrap in release builds.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 204469a2-89fa-406c-91e2-330f5c6594d0

📥 Commits

Reviewing files that changed from the base of the PR and between c705cd1 and e6e05cf.

📒 Files selected for processing (3)
  • src/client/node_io.rs
  • src/client/tests.rs
  • src/prekeys.rs

Comment thread src/prekeys.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files

You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/client/node_io.rs Outdated
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.04 MiB 10.04 MiB +128 B (+0.00%) 🔺
bin .text 8.04 MiB 8.04 MiB +128 B (+0.00%) 🔺
bin allocated (text+data+bss) 10.04 MiB 10.04 MiB 0
llvm-lines wacore 533,462 533,462 0
llvm-lines wacore copies 17,415 17,415 0
llvm-lines whatsapp-rust lib 761,116 761,151 +35 (+0.00%) 🔺
llvm-lines whatsapp-rust lib copies 23,749 23,749 0
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.84 MiB 1.84 MiB +152 B (+0.01%) 🔺
.text wacore 692.69 KiB 692.69 KiB 0
.text wacore_binary 81.61 KiB 81.61 KiB 0
.text wacore_libsignal 178.98 KiB 178.98 KiB 0
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 20.94 KiB 20.94 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 540.56 KiB 540.56 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 12.68 KiB 12.68 KiB 0
.text std 995.16 KiB 995.16 KiB 0
.text other deps 1.90 MiB 1.90 MiB 0

Baseline: c705cd1ac (latest main run) · Head: 1d02f9af6 · Graphs

The sleep was capped at MAX_DELAY_SECS but the state behind it was not, so
`delay_a + delay_b` overflows u64 after about 90 consecutive failures —
a panic in debug builds, a wrap back to a one-second retry in release.
Clamp the stored value the way `fibonacci_backoff` already does; past the
cap the state only exists to overflow, so nothing observable changes.

Also drop an inaccurate clause from the 429 comment: the neighbouring arms
dispatch their own specialized events rather than `StreamError`, so there
is no general convention to appeal to — only the reason 429 needs one.
@greptile-apps
greptile-apps Bot dismissed their stale review August 10, 2026 02:08

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

Copy link
Copy Markdown
Collaborator Author

Both review points were valid; fixed in ea9a650.

  • Fibonacci overflow — real. Starting from (1, 2), delay_a + delay_b exceeds u64::MAX at iteration 90, and since the loop is unbounded that is reachable (roughly 15 hours of continuous failure without a disconnect). Clamped with saturating_add(...).min(MAX_DELAY_SECS), matching fibonacci_backoff in client.rs. Past the cap the state only exists to overflow, so the retry schedule is unchanged.
  • Comment accuracy — also right. The neighbouring arms dispatch specialized events (LoggedOut, StreamReplaced), not StreamError, so there is no general convention to appeal to. Dropped that clause and kept only the reason 429 needs an event.

On the failing Semver Checks (informational) job: it runs -p wacore -p wacore-binary -p waproto, none of which this PR touches — the diff is confined to src/ of whatsapp-rust. The reported breakages (struct_missing, module_missing, enum_variant_added on BinaryError::UnexpectedFormatByte, …) all predate this branch.


Generated by Claude Code

@coderabbitai coderabbitai Bot removed the api-design label Aug 10, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Requires human review: The 429 change intentionally adds a consumer-visible StreamError event, altering the public event contract/behavior embedders observe; that product/API decision should be human-approved. The prekeys clamp is separate, but the PR takes the restrictive path.

Re-trigger cubic

@jlucaso1
jlucaso1 merged commit 5797257 into main Aug 10, 2026
25 of 26 checks passed
@jlucaso1
jlucaso1 deleted the claude/whatsapp-rate-limit-behavior-1o5mc1 branch August 10, 2026 02:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants