Add retry handler with polly to intercept 429 rate limit errors with Real-Debrid.

This commit is contained in:
Roger Far 2024-07-13 19:54:36 -06:00
parent 343d2d078c
commit 8dd0eec654
15 changed files with 59 additions and 93 deletions

View file

@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.0.80] - 2024-07-13
### Changed
- Add rate limiter to retry requests that are rate limited from Real-Debrid.
- Optimize calls to Real-Debrid API when torrents are finished and periodic updates.
- Update to .NET 8.0.7
## [2.0.79] - 2024-06-03
### Changed
- Fixed issue with qBittorrent progress sometimes throwing errors.

View file

@ -1,6 +1,6 @@
{
"name": "rdt-client",
"version": "2.0.79",
"version": "2.0.80",
"description": "This is a web interface to manage your torrents on Real-Debrid.",
"main": "index.js",
"dependencies": {

View file

@ -8,15 +8,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.3">
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
</ItemGroup>
</Project>

View file

@ -26,7 +26,7 @@ public class UpdateChecker(ILogger<UpdateChecker> logger) : BackgroundService
return;
}
CurrentVersion = $"v{version[..version.LastIndexOf(".", StringComparison.Ordinal)]}";
CurrentVersion = $"v{version[..version.LastIndexOf('.')]}";
logger.LogInformation("UpdateChecker started, currently on version {CurrentVersion}.", CurrentVersion);

View file

@ -1,8 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Polly;
using Polly.Extensions.Http;
using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Handlers;
using RdtClient.Service.Middleware;
using RdtClient.Service.Services;
using RdtClient.Service.Services.TorrentClients;
@ -38,9 +39,13 @@ public static class DiConfig
public static void RegisterHttpClients(this IServiceCollection services)
{
services.AddHttpClient();
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(retryCount: 5, sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
services.AddHttpClient(RD_CLIENT)
.AddHttpMessageHandler(sp => new RateLimitingHandler(sp.GetRequiredService<ILogger<RateLimitingHandler>>(), 60, TimeSpan.FromSeconds(60)))
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
.AddPolicyHandler(retryPolicy);
}
}

View file

@ -1,54 +0,0 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace RdtClient.Service.Handlers;
public class RateLimitingHandler : DelegatingHandler
{
private readonly ConcurrentQueue<DateTimeOffset> _callQueue = new();
private readonly TimeSpan _requestWindow;
private readonly ILogger<RateLimitingHandler> _logger;
private readonly Int32 _requestsAllowedPerWindow;
/// <summary>
/// Initializes a new instance of the <see cref="RateLimitingHandler"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="requestsAllowedPerWindow">The number of requests allowed per window.</param>
/// <param name="requestWindow">The time to delay when the maximum number of requests has been reached (The window).</param>
public RateLimitingHandler(ILogger<RateLimitingHandler> logger, Int32 requestsAllowedPerWindow, TimeSpan requestWindow)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(requestsAllowedPerWindow);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(requestWindow, TimeSpan.Zero);
_logger = logger;
_requestsAllowedPerWindow = requestsAllowedPerWindow;
_requestWindow = requestWindow;
}
/// <inheritdoc/>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
_callQueue.Enqueue(now);
while (_callQueue.TryPeek(out var oldCall) && now - oldCall > _requestWindow)
{
_logger.LogDebug("Removing oldest request from queue");
_callQueue.TryDequeue(out _);
}
if (_callQueue.Count > _requestsAllowedPerWindow)
{
_logger.LogDebug("Delaying request");
var earliestAllowedRequestTime = now.Add(-_requestWindow);
var delayDuration = _callQueue.ElementAtOrDefault(_requestsAllowedPerWindow - 1) -
earliestAllowedRequestTime;
if (delayDuration > TimeSpan.Zero)
{
_logger.LogDebug("Delaying request for {DelayDuration}", delayDuration);
await Task.Delay(delayDuration, cancellationToken);
}
}
_logger.LogDebug("Sending request");
return await base.SendAsync(request, cancellationToken);
}
}

View file

@ -27,7 +27,7 @@ public static class DownloadHelper
var matchingTorrentFiles = torrent.Files.Where(m => m.Path.EndsWith(fileName)).Where(m => !String.IsNullOrWhiteSpace(m.Path)).ToList();
if (matchingTorrentFiles.Any())
if (matchingTorrentFiles.Count > 0)
{
var matchingTorrentFile = matchingTorrentFiles[0];
@ -71,7 +71,7 @@ public static class DownloadHelper
var matchingTorrentFiles = torrent.Files.Where(m => m.Path.EndsWith(fileName)).Where(m => !String.IsNullOrWhiteSpace(m.Path)).ToList();
if (matchingTorrentFiles.Any())
if (matchingTorrentFiles.Count > 0)
{
var matchingTorrentFile = matchingTorrentFiles[0];

View file

@ -6,10 +6,7 @@ public class JsonModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
ArgumentNullException.ThrowIfNull(bindingContext);
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult != ValueProviderResult.None)

View file

@ -3,8 +3,17 @@ using Microsoft.AspNetCore.Http;
namespace RdtClient.Service.Middleware;
public class BaseHrefMiddleware(RequestDelegate next, String basePath)
public partial class BaseHrefMiddleware(RequestDelegate next, String basePath)
{
[GeneratedRegex(@"<base href=""/""")]
private partial Regex BodyRegex();
[GeneratedRegex("(<script.*?src=\")(.*?)(\".*?</script>)")]
private partial Regex ScriptRegex();
[GeneratedRegex("(<link.*?href=\")(.*?)(\".*?>)")]
private partial Regex LinkRegex();
private readonly String _basePath = $"/{basePath.TrimStart('/').TrimEnd('/')}/";
public async Task InvokeAsync(HttpContext context)
@ -27,10 +36,10 @@ public class BaseHrefMiddleware(RequestDelegate next, String basePath)
{
if (context.Response.ContentType?.Contains("text/html") == true)
{
responseBody = Regex.Replace(responseBody, @"<base href=""/""", @$"<base href=""{_basePath}""");
responseBody = BodyRegex().Replace(responseBody, @$"<base href=""{_basePath}""");
responseBody = Regex.Replace(responseBody, "(<script.*?src=\")(.*?)(\".*?</script>)", $"$1{_basePath}$2$3");
responseBody = Regex.Replace(responseBody, "(<link.*?href=\")(.*?)(\".*?>)", $"$1{_basePath}$2$3");
responseBody = ScriptRegex().Replace(responseBody, $"$1{_basePath}$2$3");
responseBody = LinkRegex().Replace(responseBody, $"$1{_basePath}$2$3");
context.Response.Headers.Remove("Content-Length");
await context.Response.WriteAsync(responseBody);

View file

@ -11,14 +11,17 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="AllDebrid.NET" Version="1.0.12" />
<PackageReference Include="Aria2.NET" Version="1.0.5" />
<PackageReference Include="Downloader" Version="3.0.6" />
<PackageReference Include="Downloader" Version="3.1.2" />
<PackageReference Include="Downloader.NET" Version="1.0.12" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.7.0" />
<PackageReference Include="MonoTorrent" Version="2.0.7" />
<PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="Premiumize.NET" Version="1.0.4" />
<PackageReference Include="RD.NET" Version="2.1.4" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="SharpCompress" Version="0.36.0" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="SharpCompress" Version="0.37.2" />
</ItemGroup>
<ItemGroup>

View file

@ -23,7 +23,7 @@ public class SymlinkDownloader(String uri, String destinationPath, String path)
var filePath = new FileInfo(path);
var rcloneMountPath = Settings.Get.DownloadClient.RcloneMountPath.TrimEnd(['\\', '/']);
var searchSubDirectories = rcloneMountPath.EndsWith("*");
var searchSubDirectories = rcloneMountPath.EndsWith('*');
rcloneMountPath = rcloneMountPath.TrimEnd('*').TrimEnd(['\\', '/']);
if (!Directory.Exists(rcloneMountPath))

View file

@ -7,7 +7,7 @@ public class RdtHub : Hub
{
private static readonly ConcurrentDictionary<String, String> Users = new();
public static Boolean HasConnections => Users.Any();
public static Boolean HasConnections => !Users.IsEmpty;
public override async Task OnConnectedAsync()
{

View file

@ -131,7 +131,7 @@ public class UnpackClient(Download download, String destinationPath)
var entries = archive.Entries
.Where(entry => !entry.IsDirectory)
.Select(m => m.Key)
.Select(m => m.Key!)
.ToList();
archive.Dispose();

View file

@ -282,7 +282,7 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
return BadRequest();
}
logger.LogDebug($"Delete {request.Hashes}");
logger.LogDebug("Delete {Hashes}", request.Hashes);
var hashes = request.Hashes.Split("|");

View file

@ -29,18 +29,18 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.3">
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.3" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.7" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
<ItemGroup>