Skip to content

Commit ac000ee

Browse files
HandyS11claude
andauthored
fix(discord): pace #info edits to stop per-channel rate-limit warnings (#66)
* fix(discord): pace #info edits to stop per-channel rate-limit warnings 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> * fix(discord): reject a negative edit-pacing gap 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 492e18b commit ac000ee

6 files changed

Lines changed: 226 additions & 8 deletions

File tree

src/RustPlusBot.Discord/DiscordServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public static IServiceCollection AddDiscordBot(this IServiceCollection services)
3333
}));
3434
services.AddSingleton<IUserDmSender, DiscordUserDmSender>();
3535
services.AddSingleton<RenderGate>();
36+
services.AddSingleton<IChannelEditPacer, ChannelEditPacer>();
3637
services.AddSingleton<DiscordChannelMessenger>();
3738
services.AddHostedService<DiscordBotService>();
3839

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System.Collections.Concurrent;
2+
using RustPlusBot.Abstractions.Time;
3+
4+
namespace RustPlusBot.Discord.Posting;
5+
6+
/// <summary>
7+
/// Per-channel edit pacer. Discord meters message edits per channel; the workspace refreshes several
8+
/// #info messages back-to-back on one tick, and a zero-gap burst exhausts that bucket, so Discord.Net
9+
/// preemptively waits on the tail edits and logs a rate-limit warning each time. This spreads the
10+
/// burst: successive edits to the same channel are held at least <c>minGap</c> apart. An edit to an
11+
/// idle channel (the common steady-state case) is not delayed at all.
12+
/// </summary>
13+
public sealed class ChannelEditPacer : IChannelEditPacer
14+
{
15+
private readonly IClock _clock;
16+
private readonly TimeSpan _minGap;
17+
18+
/// <summary>The earliest instant the next edit to each channel may be sent.</summary>
19+
private readonly ConcurrentDictionary<ulong, DateTimeOffset> _nextAllowed = new();
20+
21+
/// <summary>Uses the default one-second spacing, comfortably under Discord's per-channel edit limit.</summary>
22+
/// <param name="clock">Supplies the current time.</param>
23+
public ChannelEditPacer(IClock clock) : this(clock, TimeSpan.FromSeconds(1))
24+
{
25+
}
26+
27+
/// <summary>Creates a pacer with an explicit minimum spacing between edits to a channel.</summary>
28+
/// <param name="clock">Supplies the current time.</param>
29+
/// <param name="minGap">The minimum spacing between successive edits to one channel; zero disables pacing.</param>
30+
/// <exception cref="ArgumentOutOfRangeException">The gap is negative.</exception>
31+
public ChannelEditPacer(IClock clock, TimeSpan minGap)
32+
{
33+
ArgumentNullException.ThrowIfNull(clock);
34+
ArgumentOutOfRangeException.ThrowIfLessThan(minGap, TimeSpan.Zero);
35+
_clock = clock;
36+
_minGap = minGap;
37+
}
38+
39+
/// <inheritdoc />
40+
public async Task PaceAsync(ulong channelId, CancellationToken cancellationToken)
41+
{
42+
var wait = Reserve(channelId);
43+
if (wait > TimeSpan.Zero)
44+
{
45+
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
46+
}
47+
}
48+
49+
/// <summary>
50+
/// Atomically reserves this call's edit slot for the channel and returns how long the caller must
51+
/// wait before sending it. Pure with respect to the clock, so the pacing decision is testable
52+
/// without real delays.
53+
/// </summary>
54+
/// <param name="channelId">The Discord channel the edit targets.</param>
55+
/// <returns>The wait before the reserved slot; <see cref="TimeSpan.Zero" /> when the channel is idle.</returns>
56+
public TimeSpan Reserve(ulong channelId)
57+
{
58+
var now = _clock.UtcNow;
59+
60+
// Reserve the slot after this one, starting from whichever is later: now, or the slot a
61+
// concurrent/prior edit already claimed. The value returned is that next slot.
62+
var nextAllowed = _nextAllowed.AddOrUpdate(
63+
channelId,
64+
now + _minGap,
65+
(_, reserved) => (reserved > now ? reserved : now) + _minGap);
66+
67+
// This call's own slot is one gap before the slot it just reserved for the following edit.
68+
var plannedSend = nextAllowed - _minGap;
69+
var wait = plannedSend - now;
70+
return wait > TimeSpan.Zero ? wait : TimeSpan.Zero;
71+
}
72+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace RustPlusBot.Discord.Posting;
2+
3+
/// <summary>
4+
/// Spaces out our own message edits to a channel so a burst never exhausts Discord's per-channel
5+
/// edit bucket (which otherwise makes Discord.Net preemptively throttle the tail of the burst and
6+
/// log a rate-limit warning). Shared process-wide, so every writer to a channel is paced against
7+
/// the same clock.
8+
/// </summary>
9+
public interface IChannelEditPacer
10+
{
11+
/// <summary>
12+
/// Reserves the next edit slot for the channel and, if that slot is in the future, waits until it
13+
/// is due. Call immediately before editing; the first edit to an idle channel returns at once.
14+
/// </summary>
15+
/// <param name="channelId">The Discord channel the edit targets.</param>
16+
/// <param name="cancellationToken">A cancellation token.</param>
17+
/// <returns>A task that completes when the caller may send its edit.</returns>
18+
Task PaceAsync(ulong channelId, CancellationToken cancellationToken);
19+
}

src/RustPlusBot.Features.Workspace/Reconciler/ServerInfoRefresher.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ namespace RustPlusBot.Features.Workspace.Reconciler;
1111
/// <param name="renderers">All registered message renderers.</param>
1212
/// <param name="gate">Suppresses PATCHes for renders that did not change.</param>
1313
/// <param name="reconciler">The full reconcile, used to self-heal a missing or stale message.</param>
14+
/// <param name="pacer">Spaces this tick's edits so the burst does not exhaust the channel's edit bucket.</param>
1415
internal sealed class ServerInfoRefresher(
1516
IWorkspaceStore store,
1617
IWorkspaceGateway gateway,
1718
IEnumerable<IMessageRenderer> renderers,
1819
RenderGate gate,
19-
IWorkspaceReconciler reconciler) : IServerInfoRefresher
20+
IWorkspaceReconciler reconciler,
21+
IChannelEditPacer pacer) : IServerInfoRefresher
2022
{
2123
/// <summary>The #info message keys this path refreshes, in declaration order.</summary>
2224
private static readonly string[] Keys =
@@ -69,6 +71,9 @@ public async Task RefreshAsync(ulong guildId, Guid serverId, CancellationToken c
6971

7072
try
7173
{
74+
// Space this edit from the previous one to the same channel so the tick's burst of #info
75+
// edits does not exhaust Discord's per-channel edit bucket (an idle channel is not delayed).
76+
await pacer.PaceAsync(record.DiscordChannelId, cancellationToken).ConfigureAwait(false);
7277
await gateway
7378
.EditMessageAsync(guildId, record.DiscordChannelId, record.DiscordMessageId, payload,
7479
cancellationToken)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using RustPlusBot.Abstractions.Time;
2+
using RustPlusBot.Discord.Posting;
3+
4+
namespace RustPlusBot.Discord.Tests.Posting;
5+
6+
public sealed class ChannelEditPacerTests
7+
{
8+
private const ulong ChannelA = 500;
9+
private const ulong ChannelB = 501;
10+
private static readonly TimeSpan Gap = TimeSpan.FromSeconds(1);
11+
12+
[Fact]
13+
public void A_negative_gap_is_rejected()
14+
{
15+
Assert.Throws<ArgumentOutOfRangeException>(() =>
16+
new ChannelEditPacer(new TestClock(), TimeSpan.FromSeconds(-1)));
17+
}
18+
19+
[Fact]
20+
public void A_zero_gap_disables_pacing_without_throwing()
21+
{
22+
var pacer = new ChannelEditPacer(new TestClock(), TimeSpan.Zero);
23+
24+
Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
25+
Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
26+
}
27+
28+
[Fact]
29+
public void First_edit_to_a_channel_waits_zero()
30+
{
31+
var pacer = new ChannelEditPacer(new TestClock(), Gap);
32+
33+
Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
34+
}
35+
36+
[Fact]
37+
public void Successive_edits_in_the_same_instant_are_staggered_by_the_gap()
38+
{
39+
var pacer = new ChannelEditPacer(new TestClock(), Gap);
40+
41+
Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
42+
Assert.Equal(Gap, pacer.Reserve(ChannelA));
43+
Assert.Equal(Gap * 2, pacer.Reserve(ChannelA));
44+
}
45+
46+
[Fact]
47+
public void An_edit_after_the_gap_has_elapsed_waits_zero()
48+
{
49+
var clock = new TestClock();
50+
var pacer = new ChannelEditPacer(clock, Gap);
51+
52+
pacer.Reserve(ChannelA);
53+
clock.UtcNow = clock.UtcNow.Add(Gap);
54+
55+
Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelA));
56+
}
57+
58+
[Fact]
59+
public void An_edit_partway_through_the_gap_waits_only_the_remainder()
60+
{
61+
var clock = new TestClock();
62+
var pacer = new ChannelEditPacer(clock, Gap);
63+
64+
pacer.Reserve(ChannelA);
65+
clock.UtcNow = clock.UtcNow.Add(TimeSpan.FromMilliseconds(400));
66+
67+
Assert.Equal(TimeSpan.FromMilliseconds(600), pacer.Reserve(ChannelA));
68+
}
69+
70+
[Fact]
71+
public void Channels_are_paced_independently()
72+
{
73+
var pacer = new ChannelEditPacer(new TestClock(), Gap);
74+
75+
pacer.Reserve(ChannelA);
76+
77+
Assert.Equal(TimeSpan.Zero, pacer.Reserve(ChannelB));
78+
}
79+
80+
private sealed class TestClock : IClock
81+
{
82+
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
83+
}
84+
}

tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ServerInfoRefresherTests.cs

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public async Task First_refresh_edits_the_message()
4343
var gateway = Substitute.For<IWorkspaceGateway>();
4444
var reconciler = Substitute.For<IWorkspaceReconciler>();
4545
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
46-
new RenderGate(), reconciler);
46+
new RenderGate(), reconciler, Pacer());
4747

4848
await refresher.RefreshAsync(GuildId, ServerId, default);
4949

@@ -58,7 +58,7 @@ public async Task Unchanged_render_is_not_sent_twice()
5858
var gateway = Substitute.For<IWorkspaceGateway>();
5959
var reconciler = Substitute.For<IWorkspaceReconciler>();
6060
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
61-
new RenderGate(), reconciler);
61+
new RenderGate(), reconciler, Pacer());
6262

6363
await refresher.RefreshAsync(GuildId, ServerId, default);
6464
await refresher.RefreshAsync(GuildId, ServerId, default);
@@ -75,7 +75,7 @@ public async Task Changed_render_is_sent_again()
7575
var gateway = Substitute.For<IWorkspaceGateway>();
7676
var reconciler = Substitute.For<IWorkspaceReconciler>();
7777
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
78-
new RenderGate(), reconciler);
78+
new RenderGate(), reconciler, Pacer());
7979

8080
await refresher.RefreshAsync(GuildId, ServerId, default);
8181
renderer.Title = "v2";
@@ -95,7 +95,7 @@ public async Task Missing_message_row_escalates_to_full_reconcile()
9595
.Returns((ProvisionedMessage?)null);
9696
var gateway = Substitute.For<IWorkspaceGateway>();
9797
var reconciler = Substitute.For<IWorkspaceReconciler>();
98-
var refresher = new ServerInfoRefresher(store, gateway, [renderer], new RenderGate(), reconciler);
98+
var refresher = new ServerInfoRefresher(store, gateway, [renderer], new RenderGate(), reconciler, Pacer());
9999

100100
await refresher.RefreshAsync(GuildId, ServerId, default);
101101

@@ -122,7 +122,7 @@ public async Task Edit_failure_escalates_and_invalidates_the_gate()
122122
var reconciler = Substitute.For<IWorkspaceReconciler>();
123123
var gate = new RenderGate();
124124
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer], gate,
125-
reconciler);
125+
reconciler, Pacer());
126126

127127
await refresher.RefreshAsync(GuildId, ServerId, default);
128128

@@ -141,7 +141,7 @@ public async Task Empty_payload_is_skipped_without_editing()
141141
var gateway = Substitute.For<IWorkspaceGateway>();
142142
var reconciler = Substitute.For<IWorkspaceReconciler>();
143143
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
144-
new RenderGate(), reconciler);
144+
new RenderGate(), reconciler, Pacer());
145145

146146
await refresher.RefreshAsync(GuildId, ServerId, default);
147147

@@ -164,13 +164,50 @@ public async Task Refreshes_all_three_info_messages()
164164
WorkspaceMessageKeys.ServerTeam);
165165
var gateway = Substitute.For<IWorkspaceGateway>();
166166
var refresher = new ServerInfoRefresher(store, gateway, renderers, new RenderGate(),
167-
Substitute.For<IWorkspaceReconciler>());
167+
Substitute.For<IWorkspaceReconciler>(), Pacer());
168168

169169
await refresher.RefreshAsync(GuildId, ServerId, default);
170170

171171
Assert.All(renderers.Cast<StubRenderer>(), r => Assert.Equal(1, r.Calls));
172172
}
173173

174+
[Fact]
175+
public async Task Each_edit_is_paced_before_it_is_sent()
176+
{
177+
var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1");
178+
var gateway = Substitute.For<IWorkspaceGateway>();
179+
var pacer = Pacer();
180+
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
181+
new RenderGate(), Substitute.For<IWorkspaceReconciler>(), pacer);
182+
183+
await refresher.RefreshAsync(GuildId, ServerId, default);
184+
185+
Received.InOrder(() =>
186+
{
187+
_ = pacer.PaceAsync(ChannelId, Arg.Any<CancellationToken>());
188+
_ = gateway.EditMessageAsync(GuildId, ChannelId, MessageId, Arg.Any<MessagePayload>(),
189+
Arg.Any<CancellationToken>());
190+
});
191+
}
192+
193+
[Fact]
194+
public async Task A_skipped_unchanged_render_is_not_paced()
195+
{
196+
var renderer = new StubRenderer(WorkspaceMessageKeys.ServerInfo, "v1");
197+
var gateway = Substitute.For<IWorkspaceGateway>();
198+
var pacer = Pacer();
199+
var refresher = new ServerInfoRefresher(StoreWith(WorkspaceMessageKeys.ServerInfo), gateway, [renderer],
200+
new RenderGate(), Substitute.For<IWorkspaceReconciler>(), pacer);
201+
202+
await refresher.RefreshAsync(GuildId, ServerId, default);
203+
await refresher.RefreshAsync(GuildId, ServerId, default);
204+
205+
// Second render is identical, so the gate suppresses the edit — and the pace with it.
206+
await pacer.Received(1).PaceAsync(ChannelId, Arg.Any<CancellationToken>());
207+
}
208+
209+
private static IChannelEditPacer Pacer() => Substitute.For<IChannelEditPacer>();
210+
174211
private sealed class StubRenderer(string key, string title) : IMessageRenderer
175212
{
176213
public int Calls { get; private set; }

0 commit comments

Comments
 (0)