From 67afe189b55d0659e1ea378cae7c5fae7145da0a Mon Sep 17 00:00:00 2001 From: omgbeez <251408589+omgbeez@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:57:42 -0500 Subject: [PATCH 1/3] Refactor `TorBoxDebridClientTest` to include `IRateLimitCoordinator` and improve test consistency across methods. Updated logging and error handling for rate-limiting scenarios. --- .../torrent-table.component.html | 2 +- .../Helpers/RateLimitCoordinatorTest.cs | 68 +++ .../Helpers/RateLimitHandlerTest.cs | 50 +- .../TorrentClients/TorBoxDebridClientTest.cs | 264 ++++------ server/RdtClient.Service/DiConfig.cs | 23 +- .../Helpers/IRateLimitCoordinator.cs | 10 + .../Helpers/RateLimitCoordinator.cs | 84 ++++ .../Helpers/RateLimitHandler.cs | 67 ++- .../DebridClients/TorBoxDebridClient.cs | 462 +++++++++--------- .../Services/TorrentRunner.cs | 31 +- .../Controllers/TorrentsControllerNzbTest.cs | 9 +- .../Controllers/TorrentsController.cs | 8 +- server/RdtClient.Web/Program.cs | 3 +- 13 files changed, 611 insertions(+), 470 deletions(-) create mode 100644 server/RdtClient.Service.Test/Helpers/RateLimitCoordinatorTest.cs create mode 100644 server/RdtClient.Service/Helpers/IRateLimitCoordinator.cs create mode 100644 server/RdtClient.Service/Helpers/RateLimitCoordinator.cs diff --git a/client/src/app/torrent-table/torrent-table.component.html b/client/src/app/torrent-table/torrent-table.component.html index d441002..c3b26ee 100644 --- a/client/src/app/torrent-table/torrent-table.component.html +++ b/client/src/app/torrent-table/torrent-table.component.html @@ -17,7 +17,7 @@
Debrid provider rate limit reached
- New torrents will not be added until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }} + Processing paused until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }}
}
diff --git a/server/RdtClient.Service.Test/Helpers/RateLimitCoordinatorTest.cs b/server/RdtClient.Service.Test/Helpers/RateLimitCoordinatorTest.cs new file mode 100644 index 0000000..8798ec5 --- /dev/null +++ b/server/RdtClient.Service.Test/Helpers/RateLimitCoordinatorTest.cs @@ -0,0 +1,68 @@ +using RdtClient.Service.Helpers; + +namespace RdtClient.Service.Test.Helpers; + +public class RateLimitCoordinatorTest +{ + [Fact] + public void UpdateCooldown_SetsCooldown() + { + // Arrange + var coordinator = new RateLimitCoordinator(); + var key = "test.host"; + var delay = TimeSpan.FromMinutes(5); + + // Act + coordinator.UpdateCooldown(key, delay); + var remaining = coordinator.GetRemainingCooldown(key); + + // Assert + Assert.True(remaining > TimeSpan.FromMinutes(4) && remaining <= TimeSpan.FromMinutes(5)); + } + + [Fact] + public void GetRemainingCooldown_ReturnsZero_WhenNoCooldown() + { + // Arrange + var coordinator = new RateLimitCoordinator(); + var key = "test.host"; + + // Act + var remaining = coordinator.GetRemainingCooldown(key); + + // Assert + Assert.Equal(TimeSpan.Zero, remaining); + } + + [Fact] + public void GetMaxNextAllowedAt_ReturnsMax() + { + // Arrange + var coordinator = new RateLimitCoordinator(); + var now = DateTimeOffset.UtcNow; + coordinator.UpdateCooldown("host1", TimeSpan.FromMinutes(10)); + coordinator.UpdateCooldown("host2", TimeSpan.FromMinutes(20)); + + // Act + var maxNext = coordinator.GetMaxNextAllowedAt(); + + // Assert + Assert.NotNull(maxNext); + Assert.True(maxNext > now.AddMinutes(19)); + Assert.True(maxNext < now.AddMinutes(21)); + } + + [Fact] + public void GetMaxNextAllowedAt_ReturnsNull_WhenAllExpired() + { + // Arrange + var coordinator = new RateLimitCoordinator(); + coordinator.UpdateCooldown("host1", TimeSpan.FromSeconds(-10)); + + // Act + var maxNext = coordinator.GetMaxNextAllowedAt(); + + // Assert + Assert.Null(maxNext); + } +} diff --git a/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs index f628fcc..cc0ac2d 100644 --- a/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs +++ b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs @@ -1,4 +1,5 @@ using System.Net; +using Moq; using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; @@ -6,11 +7,13 @@ namespace RdtClient.Service.Test.Helpers; public class RateLimitHandlerTest { + private readonly Mock _coordinatorMock = new(); + [Fact] public async Task SendAsync_ThrowsRateLimitException_On429WithRetryAfter() { // Arrange - var handler = new RateLimitHandler + var handler = new RateLimitHandler(_coordinatorMock.Object) { InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, 3600) }; @@ -20,14 +23,16 @@ public class RateLimitHandlerTest // Act & Assert var ex = await Assert.ThrowsAsync(() => client.GetAsync("http://example.com")); Assert.Equal(TimeSpan.FromSeconds(3600), ex.RetryAfter); - Assert.Equal("TorBox rate limit exceeded", ex.Message); + Assert.Contains("rate limit exceeded", ex.Message); + + _coordinatorMock.Verify(m => m.UpdateCooldown("example.com", TimeSpan.FromSeconds(3600)), Times.Once); } [Fact] public async Task SendAsync_ThrowsRateLimitException_On429WithoutRetryAfter() { // Arrange - var handler = new RateLimitHandler + var handler = new RateLimitHandler(_coordinatorMock.Object) { InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, null) }; @@ -39,6 +44,37 @@ public class RateLimitHandlerTest Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter); } + [Fact] + public async Task SendAsync_ThrowsRateLimitException_WhenCooldownActive() + { + // Arrange + _coordinatorMock.Setup(m => m.GetRemainingCooldown("example.com")).Returns(TimeSpan.FromMinutes(5)); + var handler = new RateLimitHandler(_coordinatorMock.Object) + { + InnerHandler = new MockHttpMessageHandler(HttpStatusCode.OK, null) + }; + var client = new HttpClient(handler); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => client.GetAsync("http://example.com")); + Assert.Equal(TimeSpan.FromMinutes(5), ex.RetryAfter); + Assert.Contains("cooldown active", ex.Message); + } + + [Fact] + public async Task SendAsync_DoesNotCatchTimeoutException() + { + // Arrange + var handler = new RateLimitHandler(_coordinatorMock.Object) + { + InnerHandler = new MockExceptionHandler(new TimeoutException()) + }; + var client = new HttpClient(handler); + + // Act & Assert + await Assert.ThrowsAsync(() => client.GetAsync("http://example.com")); + } + private class MockHttpMessageHandler(HttpStatusCode statusCode, Int32? retryAfterSeconds) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -53,4 +89,12 @@ public class RateLimitHandlerTest return Task.FromResult(response); } } + + private class MockExceptionHandler(Exception ex) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + throw ex; + } + } } diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs index 71d1574..96035a7 100644 --- a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs @@ -6,6 +6,7 @@ using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.Internal; +using RdtClient.Service.Helpers; using RdtClient.Service.Services; using RdtClient.Service.Services.DebridClients; using TorBoxNET; @@ -17,15 +18,17 @@ namespace RdtClient.Service.Test.Services.TorrentClients; public class TorBoxDebridClientTest { private readonly Mock _fileFilterMock; - private readonly Mock _httpClientFactoryMock; private readonly Mock> _loggerMock; + private readonly Mock _httpClientFactoryMock; + private readonly Mock _coordinatorMock; public TorBoxDebridClientTest() { _loggerMock = new(); _httpClientFactoryMock = new(); _fileFilterMock = new(); - + _coordinatorMock = new(); + var httpClient = new HttpClient(); _httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny())).Returns(httpClient); @@ -61,7 +64,7 @@ public class TorBoxDebridClientTest } }; - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); clientMock.Protected().Setup?>>("GetCurrentTorrents").ReturnsAsync(torrents); clientMock.Protected().Setup?>>("GetQueuedTorrents").ReturnsAsync(new List()); clientMock.Protected().Setup?>>("GetCurrentUsenet").ReturnsAsync(nzbs); @@ -111,7 +114,7 @@ public class TorBoxDebridClientTest } }; - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(availability); // Act @@ -154,7 +157,7 @@ public class TorBoxDebridClientTest } }; - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability); clientMock.Protected().Setup>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability); @@ -183,7 +186,7 @@ public class TorBoxDebridClientTest Data = new() }; - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability); clientMock.Protected().Setup>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability); @@ -205,11 +208,11 @@ public class TorBoxDebridClientTest }; var torrentsApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); // Act await clientMock.Object.Delete(torrent); @@ -229,11 +232,11 @@ public class TorBoxDebridClientTest }; var usenetApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); // Act await clientMock.Object.Delete(torrent); @@ -255,12 +258,12 @@ public class TorBoxDebridClientTest var link = "https://torbox.app/d/123/456"; var torrentsApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + torrentsApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny())) .ReturnsAsync(new Response { @@ -288,12 +291,12 @@ public class TorBoxDebridClientTest var link = "https://torbox.app/d/123/456"; var usenetApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + usenetApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny())) .ReturnsAsync(new Response { @@ -319,25 +322,15 @@ public class TorBoxDebridClientTest var name = "test-nzb"; var usenetApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new Response - { - Data = new() - { - Hash = "new-hash" - } - }); + .ReturnsAsync(new Response { Data = new UsenetAddResult { Hash = "new-hash" } }); // Act var result = await clientMock.Object.AddNzbFile(bytes, name); @@ -374,12 +367,12 @@ public class TorBoxDebridClientTest }; var torrentsApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny())) .ReturnsAsync(new TorrentInfoResult { @@ -425,12 +418,12 @@ public class TorBoxDebridClientTest Settings.Get.Provider.PreferZippedDownloads = true; var torrentsApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny())) .ReturnsAsync(new TorrentInfoResult { @@ -461,12 +454,12 @@ public class TorBoxDebridClientTest var link = "https://torbox.app/fakedl/12345/6789"; var torrentsApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 6789, false, It.IsAny())) .ReturnsAsync(new Response { @@ -493,12 +486,12 @@ public class TorBoxDebridClientTest var link = "https://torbox.app/fakedl/12345/zip"; var torrentsApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 0, true, It.IsAny())) .ReturnsAsync(new Response { @@ -535,12 +528,12 @@ public class TorBoxDebridClientTest }; var usenetApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + usenetApiMock.Setup(m => m.GetCurrentAsync(true, It.IsAny())) .ReturnsAsync(new List { @@ -576,12 +569,12 @@ public class TorBoxDebridClientTest var link = "https://torbox.app/fakedl/98765/4321"; var usenetApiMock = new Mock(); - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); var torBoxClientMock = new Mock(); torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + usenetApiMock.Setup(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny())) .ReturnsAsync(new Response { @@ -603,33 +596,20 @@ public class TorBoxDebridClientTest var magnetLink = "magnet:?xt=urn:btih:test"; var torrentsApiMock = new Mock(); var userApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); - + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) - .ReturnsAsync(new Response - { - Data = new() - { - Settings = new() - { - SeedTorrents = 5 - } - } - }); + .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } }); torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny())) .ThrowsAsync(new TorBoxException("active_limit", "Active limit reached")); - + // Act & Assert await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink)); torrentsApiMock.Verify(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny()), Times.Once); @@ -643,32 +623,19 @@ public class TorBoxDebridClientTest var magnetLink = "magnet:?xt=urn:btih:test"; var torrentsApiMock = new Mock(); var userApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); - + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) - .ReturnsAsync(new Response - { - Data = new() - { - Settings = new() - { - SeedTorrents = 5 - } - } - }); + .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } }); torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny())) - .ThrowsAsync(new("slow_down")); + .ThrowsAsync(new Exception("slow_down")); // Act & Assert await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink)); @@ -681,29 +648,16 @@ public class TorBoxDebridClientTest var magnetLink = "magnet:?xt=urn:btih:test"; var torrentsApiMock = new Mock(); var userApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); - + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) - .ReturnsAsync(new Response - { - Data = new() - { - Settings = new() - { - SeedTorrents = 5 - } - } - }); + .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } }); torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny())) .ThrowsAsync(new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60))); @@ -720,32 +674,19 @@ public class TorBoxDebridClientTest var magnetLink = "magnet:?xt=urn:btih:test"; var torrentsApiMock = new Mock(); var userApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); - + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) - .ReturnsAsync(new Response - { - Data = new() - { - Settings = new() - { - SeedTorrents = 5 - } - } - }); + .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } }); torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny())) - .ThrowsAsync(new("Wrapped", new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60)))); + .ThrowsAsync(new Exception("Wrapped", new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60)))); // Act & Assert var ex = await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink)); @@ -756,36 +697,18 @@ public class TorBoxDebridClientTest public async Task AddTorrentFile_ThrowsRateLimitException_OnActiveLimit() { // Arrange - var bytes = new Byte[] - { - 1, 2, 3 - }; - + var bytes = new Byte[] { 1, 2, 3 }; var torrentsApiMock = new Mock(); var userApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); - + torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) - .ReturnsAsync(new Response - { - Data = new() - { - Settings = new() - { - SeedTorrents = 5 - } - } - }); + .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } }); torrentsApiMock.Setup(m => m.AddFileAsync(bytes, 5, It.IsAny(), It.IsAny(), false, It.IsAny())) .ThrowsAsync(new TorBoxException("active_limit", "Active limit reached")); @@ -802,17 +725,12 @@ public class TorBoxDebridClientTest // Arrange var nzbLink = "https://example.com/test.nzb"; var usenetApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); - + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + usenetApiMock.Setup(m => m.AddLinkAsync(nzbLink, It.IsAny(), It.IsAny(), It.IsAny(), false, It.IsAny())) .ThrowsAsync(new TorBoxException("active_limit", "Active limit reached")); @@ -826,24 +744,15 @@ public class TorBoxDebridClientTest public async Task AddNzbFile_ThrowsRateLimitException_OnActiveLimit() { // Arrange - var bytes = new Byte[] - { - 1, 2, 3 - }; - + var bytes = new Byte[] { 1, 2, 3 }; var name = "test.nzb"; var usenetApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) - { - CallBase = true - }; - + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true }; var torBoxClientMock = new Mock(); - + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); - clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - + clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); + usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), false, It.IsAny())) .ThrowsAsync(new TorBoxException("active_limit", "Active limit reached")); @@ -862,14 +771,13 @@ public class TorBoxDebridClientTest RdId = "test-rd-id", RdStatus = TorrentStatus.Downloading }; - var torrentClientTorrent = new DebridClientTorrent { Status = "failed (Aborted, cannot be completed - https://sabnzbd.org/not-complete)", Filename = "test-file" }; - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); // Act var result = await clientMock.Object.UpdateData(torrent, torrentClientTorrent); @@ -895,7 +803,7 @@ public class TorBoxDebridClientTest Filename = "test-torrent" }; - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object); // Act await clientMock.Object.UpdateData(torrent, torrentClientTorrent); diff --git a/server/RdtClient.Service/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs index 29d5a55..9fc21f0 100644 --- a/server/RdtClient.Service/DiConfig.cs +++ b/server/RdtClient.Service/DiConfig.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.DependencyInjection; using Polly; using Polly.Timeout; using RateLimitHeaders.Polly; -using RdtClient.Data.Models.Internal; using RdtClient.Service.BackgroundServices; using RdtClient.Service.Helpers; using RdtClient.Service.Middleware; @@ -20,6 +19,7 @@ public static class DiConfig { public const String RD_CLIENT = "RdClient"; public const String TORBOX_CLIENT = "TorBoxClient"; + public const String TORBOX_CLIENT_SLOW = "TorBoxClientSlow"; public static readonly String UserAgent = $"rdt-client {Assembly.GetEntryAssembly()?.GetName().Version}"; public static void RegisterRdtServices(this IServiceCollection services) @@ -29,6 +29,7 @@ public static class DiConfig services.AddSingleton(); services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -65,7 +66,6 @@ public static class DiConfig public static void RegisterHttpClients(this IServiceCollection services) { services.AddHttpClient(); - services.ConfigureHttpClientDefaults(builder => { builder.ConfigureHttpClient(httpClient => @@ -75,7 +75,7 @@ public static class DiConfig }); services.AddTransient(); - + services.AddHttpClient(RD_CLIENT) .AddHttpMessageHandler() .AddResilienceHandler("rd_client_handler", ConfigureResiliencePipeline); @@ -83,8 +83,11 @@ public static class DiConfig // This likely works for most providers, but should be verified and then the providers changed // to this HTTP client for added resilience. services.AddHttpClient(TORBOX_CLIENT) - .AddHttpMessageHandler() .AddResilienceHandler("torbox_client_handler", ConfigureResiliencePipeline); + + services.AddHttpClient(TORBOX_CLIENT_SLOW) + .AddHttpMessageHandler() + .AddResilienceHandler("torbox_client_handler_slow", ConfigureResiliencePipeline); } private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder builder) @@ -116,17 +119,11 @@ public static class DiConfig { if (args.Outcome.Result is { StatusCode: HttpStatusCode.TooManyRequests } response) { - var retryAfter = response.Headers.RetryAfter; - var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2)); + var delay = RateLimitHandler.GetRetryAfterDelay(response); - if (delay < TimeSpan.Zero) + if (delay >= TimeSpan.FromMinutes(10)) { - delay = TimeSpan.FromMinutes(2); - } - - if (delay >= TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)) - { - throw new RateLimitException("Provider rate limit exceeded", delay); + return new ValueTask((TimeSpan?)null); } return new(delay); diff --git a/server/RdtClient.Service/Helpers/IRateLimitCoordinator.cs b/server/RdtClient.Service/Helpers/IRateLimitCoordinator.cs new file mode 100644 index 0000000..f122da7 --- /dev/null +++ b/server/RdtClient.Service/Helpers/IRateLimitCoordinator.cs @@ -0,0 +1,10 @@ +namespace RdtClient.Service.Helpers; + +public interface IRateLimitCoordinator +{ + TimeSpan GetRemainingCooldown(String key); + TimeSpan GetMaxRemainingCooldown(); + DateTimeOffset? GetMaxNextAllowedAt(); + void UpdateCooldown(String key, TimeSpan retryAfter); + DateTimeOffset? GetNextAllowedAt(String key); +} diff --git a/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs b/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs new file mode 100644 index 0000000..7ea002a --- /dev/null +++ b/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs @@ -0,0 +1,84 @@ +using System.Collections.Concurrent; + +namespace RdtClient.Service.Helpers; + +public class RateLimitCoordinator : IRateLimitCoordinator +{ + private readonly ConcurrentDictionary _cooldowns = new(); + + public TimeSpan GetRemainingCooldown(String key) + { + if (_cooldowns.TryGetValue(key, out var nextAllowedAt)) + { + var remaining = nextAllowedAt - DateTimeOffset.UtcNow; + if (remaining > TimeSpan.Zero) + { + return remaining; + } + _cooldowns.TryRemove(key, out _); + } + return TimeSpan.Zero; + } + + public TimeSpan GetMaxRemainingCooldown() + { + var now = DateTimeOffset.UtcNow; + var max = TimeSpan.Zero; + foreach (var (key, nextAllowedAt) in _cooldowns) + { + var remaining = nextAllowedAt - now; + if (remaining > TimeSpan.Zero) + { + if (remaining > max) + { + max = remaining; + } + } + else + { + _cooldowns.TryRemove(key, out _); + } + } + return max; + } + + public DateTimeOffset? GetMaxNextAllowedAt() + { + var now = DateTimeOffset.UtcNow; + DateTimeOffset? max = null; + foreach (var (key, nextAllowedAt) in _cooldowns) + { + if (nextAllowedAt > now) + { + if (max == null || nextAllowedAt > max) + { + max = nextAllowedAt; + } + } + else + { + _cooldowns.TryRemove(key, out _); + } + } + return max; + } + + public void UpdateCooldown(String key, TimeSpan retryAfter) + { + var nextAllowedAt = DateTimeOffset.UtcNow.Add(retryAfter); + _cooldowns.AddOrUpdate(key, nextAllowedAt, (_, existing) => nextAllowedAt > existing ? nextAllowedAt : existing); + } + + public DateTimeOffset? GetNextAllowedAt(String key) + { + if (_cooldowns.TryGetValue(key, out var nextAllowedAt)) + { + if (nextAllowedAt > DateTimeOffset.UtcNow) + { + return nextAllowedAt; + } + _cooldowns.TryRemove(key, out _); + } + return null; + } +} diff --git a/server/RdtClient.Service/Helpers/RateLimitHandler.cs b/server/RdtClient.Service/Helpers/RateLimitHandler.cs index 4fd243e..ce3511f 100644 --- a/server/RdtClient.Service/Helpers/RateLimitHandler.cs +++ b/server/RdtClient.Service/Helpers/RateLimitHandler.cs @@ -1,37 +1,58 @@ using System.Net; -using Polly.Timeout; +using Polly; using RdtClient.Data.Models.Internal; namespace RdtClient.Service.Helpers; -public class RateLimitHandler : DelegatingHandler +public class RateLimitHandler(IRateLimitCoordinator coordinator) : DelegatingHandler { + public static readonly ResiliencePropertyKey StartTimeKey = new("StartTime"); + + public static TimeSpan GetRetryAfterDelay(HttpResponseMessage response) + { + var retryAfter = response.Headers.RetryAfter; + var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2)); + + if (delay < TimeSpan.Zero) + { + delay = TimeSpan.FromMinutes(2); + } + + return delay; + } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - try + var host = request.RequestUri?.Host ?? "unknown"; + + var cooldown = coordinator.GetRemainingCooldown(host); + if (cooldown > TimeSpan.Zero) { - var response = await base.SendAsync(request, cancellationToken); - - if (response.StatusCode == HttpStatusCode.TooManyRequests) - { - var retryAfter = response.Headers.RetryAfter; - var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2)); - - if (delay < TimeSpan.Zero) - { - delay = TimeSpan.FromMinutes(2); - } - - response.Dispose(); - - throw new RateLimitException("TorBox rate limit exceeded", delay); - } - - return response; + throw new RateLimitException($"Rate limit cooldown active for {host}", cooldown); } - catch (Exception ex) when (ex is TimeoutRejectedException or TaskCanceledException) + + var context = request.GetResilienceContext(); + if (context == null) { - throw new RateLimitException("Provider rate limit exceeded (timeout)", TimeSpan.FromMinutes(2)); + context = ResilienceContextPool.Shared.Get(cancellationToken); + request.SetResilienceContext(context); } + + if (!context.Properties.TryGetValue(StartTimeKey, out _)) + { + context.Properties.Set(StartTimeKey, DateTimeOffset.UtcNow); + } + + var response = await base.SendAsync(request, cancellationToken); + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + var delay = GetRetryAfterDelay(response); + coordinator.UpdateCooldown(host, delay); + response.Dispose(); + throw new RateLimitException($"Provider {host} rate limit exceeded", delay); + } + + return response; } } diff --git a/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs index 295c29a..f97d804 100644 --- a/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs @@ -2,8 +2,8 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RdtClient.Data.Enums; -using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; +using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using TorBoxNET; @@ -11,10 +11,149 @@ using Torrent = RdtClient.Data.Models.Data.Torrent; namespace RdtClient.Service.Services.DebridClients; -public class TorBoxDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient +public class TorBoxDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, IRateLimitCoordinator coordinator) : IDebridClient { + private const String TorBoxApiHost = "api.torbox.app"; + private TimeSpan? _offset; + protected virtual ITorBoxNetClient GetClient(String clientId = DiConfig.TORBOX_CLIENT) + { + try + { + var apiKey = Settings.Get.Provider.ApiKey; + + if (String.IsNullOrWhiteSpace(apiKey)) + { + throw new("TorBox API Key not set in the settings"); + } + + var httpClient = httpClientFactory.CreateClient(clientId); + var torBoxNetClient = new TorBoxNetClient(null, httpClient, 1); + torBoxNetClient.UseApiAuthentication(apiKey); + + // Get the server time to fix up the timezones on results + if (_offset == null) + { + var serverTime = DateTimeOffset.UtcNow; + _offset = serverTime.Offset; + } + + return torBoxNetClient; + } + catch (AggregateException ae) + { + foreach (var inner in ae.InnerExceptions) + { + logger.LogError(inner, $"The connection to TorBox has failed: {inner.Message}"); + } + + throw; + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); + + throw; + } + catch (TaskCanceledException ex) + { + logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); + + throw; + } + } + + protected virtual async Task?> GetCurrentTorrents() + { + return await HandleErrors(() => GetClient().Torrents.GetCurrentAsync(true)); + } + + protected virtual async Task?> GetQueuedTorrents() + { + return await HandleErrors(() => GetClient().Torrents.GetQueuedAsync(true)); + } + + protected virtual async Task?> GetCurrentUsenet() + { + return await HandleErrors(() => GetClient().Usenet.GetCurrentAsync(true)); + } + + protected virtual async Task?> GetQueuedUsenet() + { + return await HandleErrors(() => GetClient().Usenet.GetQueuedAsync(true)); + } + + protected virtual async Task>> GetTorrentAvailability(String hash) + { + return await HandleErrors(() => GetClient().Torrents.GetAvailabilityAsync(hash, listFiles: true)); + } + + protected virtual async Task>> GetUsenetAvailability(String hash) + { + return await HandleErrors(() => GetClient().Usenet.GetAvailabilityAsync(hash, listFiles: true)); + } + + private DebridClientTorrent Map(TorrentInfoResult torrent) + { + return new() + { + Id = torrent.Hash, + Filename = torrent.Name, + OriginalFilename = torrent.Name, + Hash = torrent.Hash, + Bytes = torrent.Size, + OriginalBytes = torrent.Size, + Host = torrent.DownloadPresent.ToString(), + Split = 0, + Progress = (Int64)(torrent.Progress * 100.0), + Status = torrent.DownloadState, + Type = DownloadType.Torrent, + Added = ChangeTimeZone(torrent.CreatedAt)!.Value, + Files = (torrent.Files ?? []).Select(m => new DebridClientFile + { + Path = String.Join("/", m.Name.Split('/').Skip(1)), + Bytes = m.Size, + Id = m.Id, + Selected = true + }).ToList(), + Links = [], + Ended = ChangeTimeZone(torrent.UpdatedAt), + Speed = torrent.DownloadSpeed, + Seeders = torrent.Seeds, + }; + } + + private DebridClientTorrent Map(UsenetInfoResult usenet) + { + return new() + { + Id = usenet.Hash, + Filename = usenet.Name, + OriginalFilename = usenet.Name, + Hash = usenet.Hash, + Bytes = usenet.Size, + OriginalBytes = usenet.Size, + Host = usenet.DownloadPresent.ToString(), + Split = 0, + Progress = (Int64)(usenet.Progress * 100.0), + Status = usenet.DownloadState, + Type = DownloadType.Nzb, + Added = ChangeTimeZone(usenet.CreatedAt)!.Value, + Files = (usenet.Files ?? []).Select(m => new DebridClientFile + { + Path = String.Join("/", m.Name.Split('/').Skip(1)), + Bytes = m.Size, + Id = m.Id, + Selected = true + }).ToList(), + Links = [], + Ended = ChangeTimeZone(usenet.UpdatedAt), + Speed = usenet.DownloadSpeed, + Seeders = 0, + }; + } + public async Task> GetDownloads() { var results = new List(); @@ -52,7 +191,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF public async Task GetUser() { - var user = await GetClient().User.GetAsync(false); + var user = await HandleErrors(() => GetClient().User.GetAsync(false)); return new() { @@ -61,13 +200,74 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF }; } + private async Task HandleErrors(Func> action) + { + try + { + return await action(); + } + catch (RateLimitException) + { + throw; + } + catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) + { + throw rateLimitException; + } + catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + + private async Task HandleErrors(Func action) + { + try + { + await action(); + } + catch (RateLimitException) + { + throw; + } + catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) + { + throw rateLimitException; + } + catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + + private async Task HandleAddTorrentErrors(Func> action) + { + return await HandleErrors(() => action(false)); + } + + private async Task HandleAddUsenetErrors(Func> action) + { + return await HandleErrors(() => action(false)); + } + public async Task AddTorrentMagnet(String magnetLink) { return await HandleAddTorrentErrors(async asQueued => { var user = await GetClient().User.GetAsync(true); - var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); - + var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); return result.Data!.Hash!; }); } @@ -77,8 +277,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF return await HandleAddTorrentErrors(async asQueued => { var user = await GetClient().User.GetAsync(true); - var result = await GetClient().Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); - + var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); return result.Data!.Hash!; }); } @@ -87,8 +286,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF { return await HandleAddUsenetErrors(async asQueued => { - var result = await GetClient().Usenet.AddLinkAsync(nzbLink, as_queued: asQueued); - + var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddLinkAsync(nzbLink, as_queued: asQueued); return result.Data!.Hash!; }); } @@ -97,8 +295,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF { return await HandleAddUsenetErrors(async asQueued => { - var result = await GetClient().Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued); - + var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued); return result.Data!.Hash!; }); } @@ -145,14 +342,17 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF return; } - if (torrent.Type == DownloadType.Nzb) + await HandleErrors(async () => { - await GetClient().Usenet.ControlAsync(torrent.RdId, "delete"); - } - else - { - await GetClient().Torrents.ControlAsync(torrent.RdId, "delete"); - } + if (torrent.Type == DownloadType.Nzb) + { + await GetClient().Usenet.ControlAsync(torrent.RdId, "delete"); + } + else + { + await GetClient().Torrents.ControlAsync(torrent.RdId, "delete"); + } + }); } public async Task Unrestrict(Torrent torrent, String link) @@ -186,11 +386,11 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF if (torrent.Type == DownloadType.Nzb) { - result = await GetClient().Usenet.RequestDownloadAsync(torrentId, fileId, zipped); + result = await HandleErrors(() => GetClient().Usenet.RequestDownloadAsync(torrentId, fileId, zipped)); } else { - result = await GetClient().Torrents.RequestDownloadAsync(torrentId, fileId, zipped); + result = await HandleErrors(() => GetClient().Torrents.RequestDownloadAsync(torrentId, fileId, zipped)); } if (result.Error != null) @@ -304,13 +504,13 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF return null; } - var usenets = await GetClient().Usenet.GetCurrentAsync(true); + var usenets = await HandleErrors(() => GetClient().Usenet.GetCurrentAsync(true)); var usenet = usenets?.FirstOrDefault(m => m.Hash == torrent.RdId); id = (Int32?)usenet?.Id; } else { - var torrentId = await GetClient().Torrents.GetHashInfoAsync(torrent.Hash, true); + var torrentId = await HandleErrors(() => GetClient().Torrents.GetHashInfoAsync(torrent.Hash, skipCache: true)); id = torrentId?.Id; } @@ -354,192 +554,6 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF return Task.FromResult(download.FileName); } - protected virtual ITorBoxNetClient GetClient() - { - try - { - var apiKey = Settings.Get.Provider.ApiKey; - - if (String.IsNullOrWhiteSpace(apiKey)) - { - throw new("TorBox API Key not set in the settings"); - } - - var httpClient = httpClientFactory.CreateClient(DiConfig.TORBOX_CLIENT); - var torBoxNetClient = new TorBoxNetClient(null, httpClient); - torBoxNetClient.UseApiAuthentication(apiKey); - - // Get the server time to fix up the timezones on results - if (_offset == null) - { - var serverTime = DateTimeOffset.UtcNow; - _offset = serverTime.Offset; - } - - return torBoxNetClient; - } - catch (AggregateException ae) - { - foreach (var inner in ae.InnerExceptions) - { - logger.LogError(inner, $"The connection to TorBox has failed: {inner.Message}"); - } - - throw; - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); - - throw; - } - catch (TaskCanceledException ex) - { - logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); - - throw; - } - } - - protected virtual async Task?> GetCurrentTorrents() - { - return await GetClient().Torrents.GetCurrentAsync(true); - } - - protected virtual async Task?> GetQueuedTorrents() - { - return await GetClient().Torrents.GetQueuedAsync(true); - } - - protected virtual async Task?> GetCurrentUsenet() - { - return await GetClient().Usenet.GetCurrentAsync(true); - } - - protected virtual async Task?> GetQueuedUsenet() - { - return await GetClient().Usenet.GetQueuedAsync(true); - } - - protected virtual async Task>> GetTorrentAvailability(String hash) - { - return await GetClient().Torrents.GetAvailabilityAsync(hash, true); - } - - protected virtual async Task>> GetUsenetAvailability(String hash) - { - return await GetClient().Usenet.GetAvailabilityAsync(hash, true); - } - - private DebridClientTorrent Map(TorrentInfoResult torrent) - { - return new() - { - Id = torrent.Hash, - Filename = torrent.Name, - OriginalFilename = torrent.Name, - Hash = torrent.Hash, - Bytes = torrent.Size, - OriginalBytes = torrent.Size, - Host = torrent.DownloadPresent.ToString(), - Split = 0, - Progress = (Int64)(torrent.Progress * 100.0), - Status = torrent.DownloadState, - Type = DownloadType.Torrent, - Added = ChangeTimeZone(torrent.CreatedAt)!.Value, - Files = (torrent.Files ?? []).Select(m => new DebridClientFile - { - Path = String.Join("/", m.Name.Split('/').Skip(1)), - Bytes = m.Size, - Id = m.Id, - Selected = true - }) - .ToList(), - Links = [], - Ended = ChangeTimeZone(torrent.UpdatedAt), - Speed = torrent.DownloadSpeed, - Seeders = torrent.Seeds - }; - } - - private DebridClientTorrent Map(UsenetInfoResult usenet) - { - return new() - { - Id = usenet.Hash, - Filename = usenet.Name, - OriginalFilename = usenet.Name, - Hash = usenet.Hash, - Bytes = usenet.Size, - OriginalBytes = usenet.Size, - Host = usenet.DownloadPresent.ToString(), - Split = 0, - Progress = (Int64)(usenet.Progress * 100.0), - Status = usenet.DownloadState, - Type = DownloadType.Nzb, - Added = ChangeTimeZone(usenet.CreatedAt)!.Value, - Files = (usenet.Files ?? []).Select(m => new DebridClientFile - { - Path = String.Join("/", m.Name.Split('/').Skip(1)), - Bytes = m.Size, - Id = m.Id, - Selected = true - }) - .ToList(), - Links = [], - Ended = ChangeTimeZone(usenet.UpdatedAt), - Speed = usenet.DownloadSpeed, - Seeders = 0 - }; - } - - private async Task HandleAddTorrentErrors(Func> action) - { - try - { - return await action(false); - } - catch (RateLimitException) - { - throw; - } - catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) - { - throw rateLimitException; - } - catch (TorBoxException ex) when (ex.Error.Equals("active_limit", StringComparison.OrdinalIgnoreCase)) - { - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) - { - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - } - - private async Task HandleAddUsenetErrors(Func> action) - { - try - { - return await action(false); - } - catch (RateLimitException) - { - throw; - } - catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) - { - throw rateLimitException; - } - catch (TorBoxException ex) when (ex.Error.Equals("active_limit", StringComparison.OrdinalIgnoreCase)) - { - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) - { - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - } private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset) { @@ -553,26 +567,28 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF private async Task GetInfo(String id, DownloadType type) { - if (type == DownloadType.Nzb) + return await HandleErrors(async () => { - var usenet = await GetClient().Usenet.GetHashInfoAsync(id, true); - - if (usenet != null) + if (type == DownloadType.Nzb) { - return Map(usenet); + var usenet = await GetClient().Usenet.GetHashInfoAsync(id, skipCache: true); + if (usenet != null) + { + return Map(usenet); + } } - } - else - { - var result = await GetClient().Torrents.GetHashInfoAsync(id, true); - - if (result != null) + else { - return Map(result); - } - } + var result = await GetClient().Torrents.GetHashInfoAsync(id, skipCache: true); - return null; + if (result != null) + { + return Map(result); + } + } + + return null; + }); } public static void MoveHashDirContents(String extractPath, Torrent torrent) diff --git a/server/RdtClient.Service/Services/TorrentRunner.cs b/server/RdtClient.Service/Services/TorrentRunner.cs index 990d3e3..2256473 100644 --- a/server/RdtClient.Service/Services/TorrentRunner.cs +++ b/server/RdtClient.Service/Services/TorrentRunner.cs @@ -11,15 +11,11 @@ using RdtClient.Service.Services.Downloaders; namespace RdtClient.Service.Services; -public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads, RemoteService remoteService, IHttpClientFactory httpClientFactory) +public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads, RemoteService remoteService, IHttpClientFactory httpClientFactory, IRateLimitCoordinator coordinator) { public static readonly ConcurrentDictionary ActiveDownloadClients = new(); public static readonly ConcurrentDictionary ActiveUnpackClients = new(); - public static Boolean IsPausedForLowDiskSpace { get; set; } - - public static DateTimeOffset NextDequeueTime { get; private set; } = DateTimeOffset.MinValue; - public static (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId) { if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient)) @@ -35,6 +31,8 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow return (0, 0, 0); } + public static Boolean IsPausedForLowDiskSpace { get; set; } + public async Task Initialize() { Log("Initializing TorrentRunner"); @@ -349,23 +347,13 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow if (torrentsToAddToProvider.Count != 0) { - if (DateTimeOffset.Now < NextDequeueTime) + var nextAllowedAt = coordinator.GetMaxNextAllowedAt(); + if (nextAllowedAt > DateTimeOffset.UtcNow) { - logger.LogDebug($"Dequeuing torrents is paused until {NextDequeueTime}, {NextDequeueTime - DateTimeOffset.Now} remaining"); + logger.LogDebug($"Dequeuing torrents is paused until {nextAllowedAt}, {nextAllowedAt - DateTimeOffset.Now} remaining"); } else { - if (NextDequeueTime != DateTimeOffset.MinValue) - { - NextDequeueTime = DateTimeOffset.MinValue; - - await remoteService.UpdateRateLimitStatus(new() - { - NextDequeueTime = null, - SecondsRemaining = 0 - }); - } - var downloadingTorrentsCount = allTorrents.Count(m => m.RdStatus is not (TorrentStatus.Queued or TorrentStatus.Finished or TorrentStatus.Error)); var maxParallelDownloads = Settings.Get.Provider.MaxParallelDownloads; @@ -752,13 +740,14 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow public async Task SetRateLimit(TimeSpan retryAfter, String message) { - NextDequeueTime = DateTimeOffset.Now.Add(retryAfter); + coordinator.UpdateCooldown("General", retryAfter); + var nextDequeueTime = coordinator.GetMaxNextAllowedAt(); - Log($"Rate-limit reached, pausing dequeuing for {retryAfter.TotalMinutes} minutes (until {NextDequeueTime}): {message}"); + Log($"Rate-limit reached, pausing dequeuing for {retryAfter.TotalMinutes} minutes (until {nextDequeueTime}): {message}"); await remoteService.UpdateRateLimitStatus(new() { - NextDequeueTime = NextDequeueTime, + NextDequeueTime = nextDequeueTime, SecondsRemaining = retryAfter.TotalSeconds }); } diff --git a/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs index d05e290..8c059a2 100644 --- a/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs +++ b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; +using RdtClient.Service.Helpers; using RdtClient.Service.Services; using RdtClient.Web.Controllers; @@ -9,15 +10,17 @@ namespace RdtClient.Web.Test.Controllers; public class TorrentsControllerNzbTest { - private readonly TorrentsController _controller; - private readonly Mock> _loggerMock; private readonly Mock _torrentsMock; + private readonly Mock> _loggerMock; + private readonly Mock _coordinatorMock; + private readonly TorrentsController _controller; public TorrentsControllerNzbTest() { _torrentsMock = new(null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); _loggerMock = new(); - _controller = new(_loggerMock.Object, _torrentsMock.Object, null!); + _coordinatorMock = new(); + _controller = new(_loggerMock.Object, _torrentsMock.Object, null!, _coordinatorMock.Object); } [Fact] diff --git a/server/RdtClient.Web/Controllers/TorrentsController.cs b/server/RdtClient.Web/Controllers/TorrentsController.cs index bc79363..4248b13 100644 --- a/server/RdtClient.Web/Controllers/TorrentsController.cs +++ b/server/RdtClient.Web/Controllers/TorrentsController.cs @@ -13,7 +13,7 @@ namespace RdtClient.Web.Controllers; [Authorize(Policy = "AuthSetting")] [Route("Api/Torrents")] -public class TorrentsController(ILogger logger, Torrents torrents, TorrentRunner torrentRunner) : Controller +public class TorrentsController(ILogger logger, Torrents torrents, TorrentRunner torrentRunner, IRateLimitCoordinator coordinator) : Controller { [HttpGet] [Route("")] @@ -191,9 +191,9 @@ public class TorrentsController(ILogger logger, Torrents tor [Route("RateLimitStatus")] public ActionResult GetRateLimitStatus() { - var nextDequeueTime = TorrentRunner.NextDequeueTime; + var nextDequeueTime = coordinator.GetMaxNextAllowedAt(); - if (nextDequeueTime < DateTimeOffset.Now) + if (nextDequeueTime == null || nextDequeueTime < DateTimeOffset.Now) { return Ok(new RateLimitStatus { @@ -205,7 +205,7 @@ public class TorrentsController(ILogger logger, Torrents tor return Ok(new RateLimitStatus { NextDequeueTime = nextDequeueTime, - SecondsRemaining = (nextDequeueTime - DateTimeOffset.Now).TotalSeconds + SecondsRemaining = (nextDequeueTime.Value - DateTimeOffset.Now).TotalSeconds }); } diff --git a/server/RdtClient.Web/Program.cs b/server/RdtClient.Web/Program.cs index fe9a7b6..fe0cfa5 100644 --- a/server/RdtClient.Web/Program.cs +++ b/server/RdtClient.Web/Program.cs @@ -52,7 +52,8 @@ if (appSettings.Logging?.File?.Path != null) .WriteTo.Console() .MinimumLevel.ControlledBy(Settings.LoggingLevelSwitch) .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) - .MinimumLevel.Override("System.Net.Http", LogEventLevel.Warning)); + .MinimumLevel.Override("System.Net.Http", LogEventLevel.Warning) + .MinimumLevel.Override("Polly", LogEventLevel.Warning)); } SelfLog.Enable(msg => From 41787077dbc482b32e413e6b87c62c796f092f7a Mon Sep 17 00:00:00 2001 From: omgbeez <251408589+omgbeez@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:04:12 -0500 Subject: [PATCH 2/3] Add remaining seconds display during rate-limit and improve cooldown handling --- .../torrent-table.component.html | 3 ++ .../Services/TorrentRunner.cs | 28 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/client/src/app/torrent-table/torrent-table.component.html b/client/src/app/torrent-table/torrent-table.component.html index c3b26ee..7c170d8 100644 --- a/client/src/app/torrent-table/torrent-table.component.html +++ b/client/src/app/torrent-table/torrent-table.component.html @@ -18,6 +18,9 @@ Debrid provider rate limit reached
Processing paused until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }} + @if (rateLimitStatus.secondsRemaining > 0) { + ({{ rateLimitStatus.secondsRemaining | number: '1.0-0' }} seconds remaining) + }
}
diff --git a/server/RdtClient.Service/Services/TorrentRunner.cs b/server/RdtClient.Service/Services/TorrentRunner.cs index 2256473..8be94f2 100644 --- a/server/RdtClient.Service/Services/TorrentRunner.cs +++ b/server/RdtClient.Service/Services/TorrentRunner.cs @@ -13,6 +13,8 @@ namespace RdtClient.Service.Services; public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads, RemoteService remoteService, IHttpClientFactory httpClientFactory, IRateLimitCoordinator coordinator) { + private DateTimeOffset? _lastNextAllowedAt; + public static readonly ConcurrentDictionary ActiveDownloadClients = new(); public static readonly ConcurrentDictionary ActiveUnpackClients = new(); @@ -113,6 +115,26 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow var sw = new Stopwatch(); sw.Start(); + var currentNextAllowedAt = coordinator.GetMaxNextAllowedAt(); + if (currentNextAllowedAt != _lastNextAllowedAt) + { + if (currentNextAllowedAt == null || currentNextAllowedAt <= DateTimeOffset.UtcNow) + { + if (_lastNextAllowedAt > DateTimeOffset.UtcNow) + { + Log("Rate-limit cooldown expired, resuming dequeuing"); + + await remoteService.UpdateRateLimitStatus(new() + { + NextDequeueTime = null, + SecondsRemaining = 0 + }); + } + } + + _lastNextAllowedAt = currentNextAllowedAt; + } + if (!ActiveDownloadClients.IsEmpty || !ActiveUnpackClients.IsEmpty) { Log($"TorrentRunner Tick Start, {ActiveDownloadClients.Count} active downloads, {ActiveUnpackClients.Count} active unpacks"); @@ -742,13 +764,17 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow { coordinator.UpdateCooldown("General", retryAfter); var nextDequeueTime = coordinator.GetMaxNextAllowedAt(); + var now = DateTimeOffset.UtcNow; + var secondsRemaining = nextDequeueTime.HasValue ? (nextDequeueTime.Value - now).TotalSeconds : 0; Log($"Rate-limit reached, pausing dequeuing for {retryAfter.TotalMinutes} minutes (until {nextDequeueTime}): {message}"); + _lastNextAllowedAt = nextDequeueTime; + await remoteService.UpdateRateLimitStatus(new() { NextDequeueTime = nextDequeueTime, - SecondsRemaining = retryAfter.TotalSeconds + SecondsRemaining = secondsRemaining }); } From 76dc3e0b9856296195c0ee5588062fb79d233099 Mon Sep 17 00:00:00 2001 From: omgbeez <251408589+omgbeez@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:32:51 -0400 Subject: [PATCH 3/3] fix: Long retry-after would generate timeout instead of RateLimitException Needed to move the retry handler before the TimeOut handler, and drive the throw from the TimeOut configured by the user for the provider. --- server/RdtClient.Service/DiConfig.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/RdtClient.Service/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs index 9fc21f0..b23fcae 100644 --- a/server/RdtClient.Service/DiConfig.cs +++ b/server/RdtClient.Service/DiConfig.cs @@ -92,11 +92,6 @@ public static class DiConfig private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder builder) { - builder.AddTimeout(new TimeoutStrategyOptions - { - TimeoutGenerator = _ => new(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)) - }); - builder.AddRateLimitHeaders(options => { options.EnableProactiveThrottling = true; @@ -120,8 +115,9 @@ public static class DiConfig if (args.Outcome.Result is { StatusCode: HttpStatusCode.TooManyRequests } response) { var delay = RateLimitHandler.GetRetryAfterDelay(response); + var timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); - if (delay >= TimeSpan.FromMinutes(10)) + if (delay >= timeout) { return new ValueTask((TimeSpan?)null); } @@ -132,5 +128,10 @@ public static class DiConfig return new((TimeSpan?)null); } }); + + builder.AddTimeout(new TimeoutStrategyOptions + { + TimeoutGenerator = _ => new(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)) + }); } }