#3576·nanoclaw

Rate-limited turns flood the channel with duplicate error notices — no backoff/dedup on deliverErrorResult

Author: DawoudIOCreated Aug 27, 2026Updated Sep 11, 2026

Summary

A turn that ends rate-limited is delivered to the user channel via deliverErrorResult (container/agent-runner/src/poll-loop.ts) with no backoff, cooldown, or dedup — every retried turn that re-hits the same rate limit produces its own delivered notice. On a real install this produced thousands of duplicate "session limit · resets HH:MM" messages to the same channel over roughly an hour.

Root cause (confirmed against source)

  1. providers/claude.ts classifies a rejected rate_limit_event and yields a provider error event carrying classification: 'rate_limit' | 'quota' (lines ~647-660).
  2. That error event is never consumed. poll-loop.ts's main for await (const event of query.events) loop (~line 545) has branches for init / text / result only — no else if (event.type === 'error') branch exists. The classification is discarded.
  3. The SDK's stream then emits a terminal result event with isError: true and the rate-limit text in event.text (per the comment at claude.ts ~625-631: error subtypes carry their message in errors[] rather than result). The ProviderEvent result variant (providers/types.ts ~165) has no classification field at all, so by the time delivery happens the rate-limit vs. quota distinction is structurally unreachable.
  4. poll-loop.ts ~602-607 unconditionally calls deliverErrorResult(event.text, routing) whenever resultBlocks === 0 && event.isError === true && !routing.taskRun — a direct writeMessageOut to the channel, every single time, with no memory of having just sent the identical notice.
  5. Nothing else in the stack fills the gap: src/host-sweep.ts has real exponential backoff (BACKOFF_BASE_MS * 2^tries, MAX_TRIES=5), but only for stuck/crashed containers — a turn that completes (even as an error) is marked completed immediately (markCompleted(initialBatchIds) fires before dispatch, line 578), so that backoff path never engages. wakeContainer (src/container-runner.ts ~134-155) only dedupes concurrent spawns, not repeated rate-limit hits over time.

Impact

Any account that stays rate-limited while messages keep arriving (or the agent keeps retrying/nudging) gets one delivered "error" message per completed turn — which can be many per minute — instead of one notice per incident. Confirmed in production: "thousands of duplicate 'session limit · resets 6:40pm' notifications" delivered to one channel over about an hour.

Suggested fix

At the result/isError delivery site in poll-loop.ts, when the underlying error was rate-limit-classified, suppress delivery of a notice whose text exactly matches the last one already delivered this session (or apply a cooldown). Two ways to make the classification reach that site:

  • Add the missing else if (event.type === 'error') branch to stash event.classification in a session-scoped local, read by the subsequent result branch; or
  • Thread classification through onto the result ProviderEvent variant directly.

A very similar pattern already exists, unmerged, for a different failure mode: PR #3566 (src/wake-failure-notify.ts) implements a per-session failure-threshold + cooldown notifier for wake failures (silent below N consecutive failures, at most one notice per interval, streak clears on success). The same shape — dedupe identical rate-limit notices, one per distinct incident — would fix this.

Confirmed not already covered

Searched open/closed issues and PRs for "rate limit", "backoff", "duplicate notification", deliverErrorResult — nothing files this exact gap. Related but distinct:

  • #3077 (merged) — added the rate_limit/quota split that this bug shows is never consulted downstream.
  • #2759 (merged) — introduced deliverErrorResult itself (to stop dropping error turns), predating this dedup gap.
  • #3251 (open) — fixes a heartbeat-stall symptom during rate-limiting; unrelated to delivery flooding.
  • #3566 (open) — dedup/cooldown pattern for a different failure mode (wake failures), good prior art for the fix here.

Environment

Observed on a self-hosted install running a fork of main (DawoudIO/nanoclaw, branch community-fixes-2026-08-24); the deliverErrorResult code path and lack of an error-event branch are present unchanged on current main.