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 @@
+
+
+
+
+
+
+
+
+
+
-
+
@@ -603,6 +617,7 @@
+
@@ -1801,7 +1816,7 @@
-
diff --git a/src/DLSSUpdater/MainWindow.xaml.cs b/src/DLSSUpdater/MainWindow.xaml.cs
index 3868212..9361e1d 100644
--- a/src/DLSSUpdater/MainWindow.xaml.cs
+++ b/src/DLSSUpdater/MainWindow.xaml.cs
@@ -16,6 +16,7 @@ public partial class MainWindow : Window
private double _settingsOffset;
// Inside the deferred settings and about pages; set once each page is first shown.
private ScrollViewer? _settingsScroll;
+ private FrameworkElement? _settingsRoot;
private ScrollViewer? _aboutScroll;
private ItemsControl? _guideList;
@@ -37,7 +38,18 @@ public MainWindow()
Closed += (_, _) => Core.HashCache.Save();
PreviewKeyDown += OnPreviewKeyDown;
KeyDown += OnKeyDown;
- SettingsHost.Realized += root => _settingsScroll = Deferred.Find(root, "SettingsScroll");
+ SettingsHost.Realized += root =>
+ {
+ _settingsRoot = root;
+ _settingsScroll = Deferred.Find(root, "SettingsScroll");
+ };
+ _vm.FocusGitHubToken += () => Dispatcher.BeginInvoke(() =>
+ {
+ if (_settingsRoot is null) return;
+ var box = Deferred.Find(_settingsRoot, "GitHubTokenBox");
+ box.BringIntoView();
+ box.Focus();
+ }, System.Windows.Threading.DispatcherPriority.Loaded);
AboutHost.Realized += root =>
{
_aboutScroll = Deferred.Find(root, "AboutScroll");
diff --git a/src/DLSSUpdater/ViewModels/MainViewModel.cs b/src/DLSSUpdater/ViewModels/MainViewModel.cs
index d0e8421..7d10610 100644
--- a/src/DLSSUpdater/ViewModels/MainViewModel.cs
+++ b/src/DLSSUpdater/ViewModels/MainViewModel.cs
@@ -38,6 +38,11 @@ public MainViewModel()
FrameLimit = new FrameLimitService(store, installer, new CatalogDownloader(gh), NvidiaProfile.Real),
};
Gh = gh;
+ gh.RateLimitChanged += () =>
+ {
+ if (_ui.CheckAccess()) RateLimit = gh.RateLimit;
+ else _ui.BeginInvoke(() => RateLimit = gh.RateLimit);
+ };
S.Extras = new ReShadeExtras(S.Installer, settings, new CatalogDownloader(gh), ct => new CatalogClient(gh).LoadAsync(ct));
S.RunJob = RunJob;
S.Progress = TransferProgress;
@@ -182,6 +187,30 @@ internal static void AppendLogLine(IList lines, string line)
[ObservableProperty] private bool _dlssNrOk;
[ObservableProperty] private bool _reShadeOk;
[ObservableProperty] private bool _online = true;
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowRateLimit))] private bool _rateLimitDismissed;
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowRateLimit), nameof(RateLimitText), nameof(CanAddGitHubToken))] private GitHubRateLimit? _rateLimit;
+
+ public bool ShowRateLimit => RateLimit is not null && !RateLimitDismissed;
+ public string RateLimitText => RateLimit?.Describe() ?? "";
+ public bool CanAddGitHubToken => RateLimit is { TokenUsed: false };
+
+ /// The Settings page is open on the General tab; the window puts the cursor in the GitHub token box.
+ public event Action? FocusGitHubToken;
+
+ [RelayCommand]
+ private void AddGitHubToken()
+ {
+ AboutOpen = false;
+ SettingsVm.Tab = "General";
+ SettingsOpen = true;
+ FocusGitHubToken?.Invoke();
+ }
+
+ [RelayCommand]
+ private void OpenGitHubTokenPage() => OpenUrl("https://github.com/settings/personal-access-tokens/new");
+
+ [RelayCommand]
+ private void DismissRateLimit() => RateLimitDismissed = true;
partial void OnSearchChanged(string value) => RefreshGames();
diff --git a/tests/DLSSUpdater.Tests/GitHubRateLimitTests.cs b/tests/DLSSUpdater.Tests/GitHubRateLimitTests.cs
new file mode 100644
index 0000000..77247aa
--- /dev/null
+++ b/tests/DLSSUpdater.Tests/GitHubRateLimitTests.cs
@@ -0,0 +1,126 @@
+using System.Net;
+using DLSSUpdater.Core;
+
+namespace DLSSUpdater.Tests;
+
+/// What the rate-limit notice is built on: GitHubClient.RateLimit follows the answers of a fake handler.
+public sealed class GitHubRateLimitTests : IDisposable
+{
+ private const string Repo = "someone/tool";
+ private const string ReleasesJson = """[{"tag_name":"v2.0","assets":[]}]""";
+
+ private sealed class FakeApi(Func respond) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) =>
+ Task.FromResult(respond());
+ }
+
+ public GitHubRateLimitTests()
+ {
+ AppPaths.Root = Path.Combine(Path.GetTempPath(), "dlssu-ghrl-" + Guid.NewGuid().ToString("N")[..8]);
+ AppPaths.Ensure();
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(AppPaths.Root, true); } catch (IOException) { }
+ }
+
+ private static HttpResponseMessage Ok(string etag = "\"v1\"")
+ {
+ var res = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(ReleasesJson) };
+ res.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue(etag);
+ return res;
+ }
+
+ private static async Task SeedCache() => await new GitHubClient(new FakeApi(() => Ok())).GetReleasesAsync(Repo, 10, default);
+
+ [Fact]
+ public async Task Forbidden_WithNoRequestsLeft_IsALimit_ResettingAtTheHeaderTime()
+ {
+ await SeedCache();
+ var reset = DateTimeOffset.UtcNow.AddMinutes(20);
+ var gh = new GitHubClient(new FakeApi(() =>
+ {
+ var res = new HttpResponseMessage(HttpStatusCode.Forbidden);
+ res.Headers.Add("X-RateLimit-Remaining", "0");
+ res.Headers.Add("X-RateLimit-Reset", reset.ToUnixTimeSeconds().ToString());
+ return res;
+ }));
+ var raised = 0;
+ gh.RateLimitChanged += () => raised++;
+
+ var releases = await gh.GetReleasesAsync(Repo, 10, default);
+
+ Assert.True(releases.Single().Stale);
+ Assert.Equal(new GitHubRateLimit(DateTimeOffset.FromUnixTimeSeconds(reset.ToUnixTimeSeconds()), false), gh.RateLimit);
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public async Task TooManyRequests_WithRetryAfter_ResetsThatManySecondsFromNow_AndNotesTheToken()
+ {
+ await SeedCache();
+ var gh = new GitHubClient(new FakeApi(() =>
+ {
+ var res = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
+ res.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(120));
+ return res;
+ })) { Token = "ghp_test" };
+ var before = DateTimeOffset.UtcNow;
+
+ await gh.GetReleasesAsync(Repo, 10, default);
+
+ var limit = gh.RateLimit!.Value;
+ Assert.True(limit.TokenUsed);
+ Assert.InRange(limit.ResetAt!.Value, before.AddSeconds(120), DateTimeOffset.UtcNow.AddSeconds(120));
+ }
+
+ [Fact]
+ public async Task ForbiddenThatIsNotARateLimit_ShowsNoNotice()
+ {
+ await SeedCache();
+ var gh = new GitHubClient(new FakeApi(() => new HttpResponseMessage(HttpStatusCode.Forbidden)
+ {
+ Content = new StringContent("""{"message":"Resource not accessible by personal access token"}"""),
+ }));
+
+ await gh.GetReleasesAsync(Repo, 10, default);
+
+ Assert.Null(gh.RateLimit);
+ }
+
+ [Fact]
+ public async Task ASuccessfulCall_ClearsTheLimit()
+ {
+ await SeedCache();
+ var limited = true;
+ var gh = new GitHubClient(new FakeApi(() =>
+ {
+ if (!limited) return Ok("\"v2\"");
+ var res = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
+ res.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(60));
+ return res;
+ }));
+ await gh.GetReleasesAsync(Repo, 10, default);
+ Assert.NotNull(gh.RateLimit);
+ var raised = 0;
+ gh.RateLimitChanged += () => raised++;
+
+ limited = false;
+ await gh.GetReleasesAsync(Repo, 10, default);
+
+ Assert.Null(gh.RateLimit);
+ Assert.Equal(1, raised);
+ }
+
+ [Fact]
+ public void Describe_MentionsTheResetAndAnExistingToken()
+ {
+ var at = new DateTimeOffset(2026, 10, 21, 12, 32, 0, TimeSpan.Zero);
+
+ Assert.Equal($"GitHub is limiting requests until {at.ToLocalTime():t}, showing cached versions.", new GitHubRateLimit(at, false).Describe());
+ Assert.Contains("even with your token", new GitHubRateLimit(at, true).Describe());
+ Assert.Equal("GitHub is limiting requests, showing cached versions.", new GitHubRateLimit(null, false).Describe());
+ }
+}