fix(discord): pace #info edits to stop per-channel rate-limit warnings - #66
Conversation
The #info refresh tick re-renders several messages and PATCHes every changed one back-to-back into the same channel. ServerInfo carries a live in-game clock, transition countdown and wipe age, so at least one edit fires every tick and the render gate can never suppress it. That sub-second burst exhausts Discord's per-channel edit bucket, so Discord.Net preemptively throttles the tail and logs a "[Rest] Rate limit triggered ... Remaining: 4s" warning almost every tick (scaling with player activity). Add a process-wide, per-channel ChannelEditPacer that holds successive edits to the same channel at least one second apart; an edit to an idle channel (the steady state) is not delayed. Wire it into ServerInfoRefresher immediately before each edit, after the render-gate check so suppressed renders are not paced. Per-channel keying means different servers' #info channels do not inflate each other's waits. The pacing decision lives in a pure Reserve method so it is tested without real timers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a per-channel edit pacing mechanism to reduce Discord per-channel rate-limit warnings caused by bursty #info message edits during the periodic workspace refresh tick.
Changes:
- Add a process-wide
IChannelEditPacer+ChannelEditPacerimplementation to enforce a minimum spacing between edits to the same channel. - Wire pacing into
ServerInfoRefresherimmediately before sending edits (after render-gate suppression). - Add unit tests covering pacer timing math and ensuring
ServerInfoRefresherpaces edits but does not pace suppressed renders.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs | Updates constructor usage and adds tests to assert pacing happens before edits and not for gate-suppressed renders. |
| tests/RustPlusBot.Discord.Tests/Posting/ChannelEditPacerTests.cs | Adds tests validating ChannelEditPacer.Reserve spacing behavior and per-channel independence. |
| src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs | Injects and calls IChannelEditPacer before message edits to spread per-tick bursts. |
| src/RustPlusBot.Discord/Posting/IChannelEditPacer.cs | Adds the pacing interface to formalize per-channel edit spacing. |
| src/RustPlusBot.Discord/Posting/ChannelEditPacer.cs | Implements per-channel pacing using a concurrent per-channel “next allowed” timestamp map. |
| src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs | Registers IChannelEditPacer as a singleton so pacing is process-wide. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public TimeSpan Reserve(ulong channelId) | ||
| { | ||
| var now = clock.UtcNow; | ||
|
|
There was a problem hiding this comment.
Fixed in 8172922. The constructor now validates the gap with ArgumentOutOfRangeException.ThrowIfLessThan(minGap, TimeSpan.Zero), so a negative gap fails fast instead of silently disabling pacing. Zero stays legal as an explicit "disable". Covered by two new tests (negative rejected, zero disables without throwing).
A negative minGap made ChannelEditPacer.Reserve compute plannedSend == now on every call, silently disabling pacing — the exact failure this change guards against. Validate in the constructor (zero stays legal as an explicit "disable"). Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
The bot logs are full of Discord rate-limit warnings — 554 in a single day's log, ~1 per minute during activity:
All of them are on one channel (a server's #info panel), and the cadence is locked to the 60s
InfoRefreshIntervaltick.Root cause
Each tick,
ServerInfoRefresherre-renders the #info messages and PATCHes every changed one back-to-back with zero spacing into the same channel.ServerInfoMessageRendererprints the live in-game clock, the day/night transition countdown and the real-time wipe age — all change every minute, so at least one edit fires every tick and theRenderGatecan never suppress it.That sub-second burst exhausts Discord's per-channel message-edit bucket, so Discord.Net preemptively throttles the tail of the burst (
Remaining: 4s) and logs a warning. The edits still land a few seconds late — it's benign but noisy, and the noise scales with player activity.Fix
A process-wide, per-channel
ChannelEditPacerthat holds successive edits to the same channel at least 1s apart. An edit to an idle channel (the steady state) is not delayed at all. It's wired intoServerInfoRefresherimmediately before each edit, after the render-gate check so suppressed renders aren't paced. Per-channel keying means different servers' #info channels don't inflate each other's waits.Spreading a ~3-edit burst over ~2s keeps the channel comfortably under the bucket, so the tail edits never queue — the warning disappears at its source. Panel freshness is unaffected (a 60s tick has ample headroom).
The gap is a constructor parameter (default 1s), easy to tune or wire to config later.
Tests (TDD, red→green)
ChannelEditPacerTests(5) — pureReservemath: first edit immediate, bursts stagger by the gap, partial-gap waits only the remainder, post-gap immediate, channels independent.ServerInfoRefresherTests(2 new) — each edit is paced before it's sent; a gate-suppressed unchanged render is not paced.Scope / follow-up
Deliberately targets the periodic tick (the evidenced source — warnings track its cadence). The event-driven full-reconcile loop (
WorkspaceReconciler) bursts the same way but only on discrete events; the same shared pacer drops straight into that loop if any residual warnings show up.🤖 Generated with Claude Code