From 720ec806a559c7600158eb288d099c153ddcf1cd Mon Sep 17 00:00:00 2001 From: ApolloF <98666317+ApolloF@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:51:49 +0200 Subject: [PATCH] Notice when GitHub rate-limits the app, with a shortcut to the token setting --- src/DLSSUpdater/Core/GitHubClient.cs | 43 ++++++ src/DLSSUpdater/MainWindow.xaml | 19 ++- src/DLSSUpdater/MainWindow.xaml.cs | 14 +- src/DLSSUpdater/ViewModels/MainViewModel.cs | 29 ++++ .../DLSSUpdater.Tests/GitHubRateLimitTests.cs | 126 ++++++++++++++++++ 5 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 tests/DLSSUpdater.Tests/GitHubRateLimitTests.cs diff --git a/src/DLSSUpdater/Core/GitHubClient.cs b/src/DLSSUpdater/Core/GitHubClient.cs index d16ffb0..a0d123f 100644 --- a/src/DLSSUpdater/Core/GitHubClient.cs +++ b/src/DLSSUpdater/Core/GitHubClient.cs @@ -39,6 +39,18 @@ public sealed class ApiCacheEntry public readonly record struct TransferProgress(string Text, double? Fraction); +/// GitHub is refusing API requests until (null when it didn't say); says the refused request carried an access token. +public readonly record struct GitHubRateLimit(DateTimeOffset? ResetAt, bool TokenUsed) +{ + /// One line for the main window; the reset is shown in local time. + public string Describe() + { + var until = ResetAt is { } at ? $" until {at.ToLocalTime():t}" : ""; + var with = TokenUsed ? " even with your token" : ""; + return $"GitHub is limiting requests{with}{until}, showing cached versions."; + } +} + /// Minimal GitHub REST client with ETag caching (304s don't count against the rate limit). public sealed class GitHubClient { @@ -57,6 +69,19 @@ public sealed class GitHubClient public string? Token { get; set; } + /// Set while GitHub is rate-limiting the app (cached data is shown meanwhile); cleared by the next successful API call. + public GitHubRateLimit? RateLimit { get; private set; } + + /// Raised when changes, on whatever thread made the request. + public event Action? RateLimitChanged; + + private void SetRateLimit(GitHubRateLimit? value) + { + if (Equals(RateLimit, value)) return; + RateLimit = value; + RateLimitChanged?.Invoke(); + } + private static HttpClient CreateHttp(HttpMessageHandler handler) { var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; @@ -147,7 +172,10 @@ public async Task GetStringAsync(string url, CancellationToken ct) "Checking without it for now. Replace or clear it in Settings."); res.Dispose(); res = await SendApiAsync(url, null, cached, cts.Token, accept); + token = null; } + if (res.IsSuccessStatusCode || res.StatusCode == HttpStatusCode.NotModified) SetRateLimit(null); + else if (DetectRateLimit(res, token is not null, DateTimeOffset.UtcNow) is { } limit) SetRateLimit(limit); if (res.StatusCode == HttpStatusCode.NotModified && cached is not null) return (cached.Body, false); if (!res.IsSuccessStatusCode && cached is not null) { @@ -179,6 +207,21 @@ private async Task SendApiAsync(string url, string? token, return await _http.SendAsync(req, ct); } + /// A 429, or a 403 with no requests left or a Retry-After; the reset comes from Retry-After, else X-RateLimit-Reset. + internal static GitHubRateLimit? DetectRateLimit(HttpResponseMessage res, bool tokenUsed, DateTimeOffset now) + { + var remainingZero = res.Headers.TryGetValues("X-RateLimit-Remaining", out var left) && left.FirstOrDefault() == "0"; + var limited = res.StatusCode == HttpStatusCode.TooManyRequests + || (res.StatusCode == HttpStatusCode.Forbidden && (res.Headers.RetryAfter is not null || remainingZero)); + if (!limited) return null; + DateTimeOffset? reset = null; + if (res.Headers.RetryAfter is { } retry) reset = retry.Date ?? (retry.Delta is { } d ? now + d : null); + if (reset is null && res.Headers.TryGetValues("X-RateLimit-Reset", out var stamp) + && long.TryParse(stamp.FirstOrDefault(), out var unix)) + reset = DateTimeOffset.FromUnixTimeSeconds(unix); + return new GitHubRateLimit(reset, tokenUsed); + } + /// /// 401, or a 403 that isn't a rate limit. GitHub's rate-limit 403s carry no requests left, a Retry-After, or (the /// secondary limit, which may have neither) a message about the rate limit or abuse detection. diff --git a/src/DLSSUpdater/MainWindow.xaml b/src/DLSSUpdater/MainWindow.xaml index 124401f..f5f9e8d 100644 --- a/src/DLSSUpdater/MainWindow.xaml +++ b/src/DLSSUpdater/MainWindow.xaml @@ -497,8 +497,22 @@ + + + + +