rdt-client/server/RdtClient.Service/BackgroundServices/UpdateChecker.cs
omgbeez a023d33d90 Add resilience handler, predictive rate limit handling
- Applied to TorBox, can be extended easily to other providers
- Reads response headers commonplace for pre-emptive rate limit throttling and retry-after
- When a rate limit is reached, displays a warning in the UI
- Fix socket leak from direct allocation of HttpClient, replaced with factory which handles pooling and re-use of sockets.

While HttpClient is Disposable, it doesn't gaurantee (and does not) directly release underlying sockets for queries at the time the client is disposed. These sockets will go into a TCP WAIT state often for a very long time. The expected pattern in C# is to always use the HttClientFactory which will correctly handle re-use of the OS sockets in suqsequent queries reducing resource and memory leaks.
2026-02-20 09:47:13 -05:00

102 lines
3.4 KiB
C#

using System.Reflection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace RdtClient.Service.BackgroundServices;
public class UpdateChecker(ILogger<UpdateChecker> logger, IHttpClientFactory httpClientFactory) : BackgroundService
{
private static readonly List<String> KnownGhsaIds = [];
public static String? CurrentVersion { get; private set; }
public static String? LatestVersion { get; private set; }
public static Boolean? IsInsecure { get; private set; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!Startup.Ready)
{
await Task.Delay(1000, stoppingToken);
}
var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
if (String.IsNullOrWhiteSpace(version))
{
CurrentVersion = "";
return;
}
CurrentVersion = $"v{version[..version.LastIndexOf('.')]}";
logger.LogInformation("UpdateChecker started, currently on version {CurrentVersion}.", CurrentVersion);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var gitHubReleases = await GitHubRequest<List<GitHubReleasesResponse>>("/repos/rogerfar/rdt-client/tags?per_page=1", stoppingToken);
var latestRelease = gitHubReleases?.FirstOrDefault(m => m.Name != null)?.Name;
if (latestRelease == null)
{
logger.LogWarning($"Unable to find latest version on GitHub");
return;
}
if (latestRelease != CurrentVersion)
{
logger.LogInformation("New version found on GitHub: {latestRelease}", latestRelease);
}
LatestVersion = latestRelease;
var gitHubSecurityAdvisories = await GitHubRequest<List<GitHubSecurityAdvisoriesResponse>>("/repos/rogerfar/rdt-client/security-advisories", stoppingToken);
var unseenGhsaIds = gitHubSecurityAdvisories?.Where(advisory => !KnownGhsaIds.Contains(advisory.GhsaId));
if (unseenGhsaIds == null)
{
logger.LogWarning($"Unable to find security advisories on GitHub");
return;
}
IsInsecure = unseenGhsaIds.Any();
}
catch (Exception ex)
{
logger.LogDebug(ex, "Unexpected error occurred while checking for updates. This error is safe to ignore.");
}
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
logger.LogInformation("UpdateChecker stopped.");
}
private async Task<T?> GitHubRequest<T>(String endpoint, CancellationToken cancellationToken)
{
var httpClient = httpClientFactory.CreateClient();
httpClient.DefaultRequestHeaders.UserAgent.Add(new("RdtClient", CurrentVersion));
var response = await httpClient.GetStringAsync($"https://api.github.com{endpoint}", cancellationToken);
return JsonConvert.DeserializeObject<T>(response);
}
}
public class GitHubReleasesResponse
{
[JsonProperty("name")]
public String? Name { get; set; }
}
public class GitHubSecurityAdvisoriesResponse
{
[JsonProperty("ghsa_id")]
public required String GhsaId { get; set; }
}