fix(discord): add backoff to scheduler to prevent 403 spam
- SessionSchedulerService now backs off for 15 minutes after any handler failure (confirmation, one-hour reminder, join link), preventing infinite retry loops on Discord 403 Missing Access. - Added per-session ConcurrentDictionary backoff tracking with automatic cleanup on success. - Enhanced DiscordPlatformMessenger logging for SendConfirmation and SendJoinLink to aid permission diagnostics. - Added 3 regression tests for backoff behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -149,6 +149,68 @@ public sealed class SessionSchedulerServiceTests
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_WhenHandlerThrows_BackoffsForDuration()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_clock.UtcNow = now;
|
||||
_store.SessionsNeedingConfirmation = [sessionId];
|
||||
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
|
||||
// Second tick immediately — should be backed off
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_WhenHandlerThrows_AfterBackoffRetriesAgain()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_clock.UtcNow = now;
|
||||
_store.SessionsNeedingConfirmation = [sessionId];
|
||||
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
|
||||
// Advance clock past backoff duration (15 min)
|
||||
_clock.UtcNow = now.AddMinutes(16);
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_WhenHandlerSucceedsAfterBackoff_ClearsBackoff()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
_clock.UtcNow = now;
|
||||
_store.SessionsNeedingConfirmation = [sessionId];
|
||||
_confirmationHandler.ThrowFor.Add(sessionId);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Single(_confirmationHandler.Calls);
|
||||
|
||||
// Remove throw condition, advance past backoff
|
||||
_confirmationHandler.ThrowFor.Remove(sessionId);
|
||||
_clock.UtcNow = now.AddMinutes(16);
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
||||
|
||||
// Next tick should still call because backoff was cleared on success
|
||||
_clock.UtcNow = now.AddMinutes(17);
|
||||
await sut.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(3, _confirmationHandler.Calls.Count);
|
||||
}
|
||||
|
||||
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
|
||||
{
|
||||
public List<Guid> Calls { get; } = [];
|
||||
|
||||
Reference in New Issue
Block a user