Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/DLSSUpdater/Core/GitHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ public sealed class ApiCacheEntry

public readonly record struct TransferProgress(string Text, double? Fraction);

/// <summary>GitHub is refusing API requests until <see cref="ResetAt"/> (null when it didn't say); <see cref="TokenUsed"/> says the refused request carried an access token.</summary>
public readonly record struct GitHubRateLimit(DateTimeOffset? ResetAt, bool TokenUsed)
{
/// <summary>One line for the main window; the reset is shown in local time.</summary>
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.";
}
}

/// <summary>Minimal GitHub REST client with ETag caching (304s don't count against the rate limit).</summary>
public sealed class GitHubClient
{
Expand All @@ -57,6 +69,19 @@ public sealed class GitHubClient

public string? Token { get; set; }

/// <summary>Set while GitHub is rate-limiting the app (cached data is shown meanwhile); cleared by the next successful API call.</summary>
public GitHubRateLimit? RateLimit { get; private set; }

/// <summary>Raised when <see cref="RateLimit"/> changes, on whatever thread made the request.</summary>
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 };
Expand Down Expand Up @@ -147,7 +172,10 @@ public async Task<string> 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)
{
Expand Down Expand Up @@ -179,6 +207,21 @@ private async Task<HttpResponseMessage> SendApiAsync(string url, string? token,
return await _http.SendAsync(req, ct);
}

/// <summary>A 429, or a 403 with no requests left or a Retry-After; the reset comes from Retry-After, else X-RateLimit-Reset.</summary>
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);
}

/// <summary>
/// 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.
Expand Down
19 changes: 17 additions & 2 deletions src/DLSSUpdater/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -497,8 +497,22 @@
</StackPanel>
</Grid>

<StackPanel Grid.Row="1">
<!-- GitHub is rate-limiting the app; the cached versions are shown -->
<Border Background="{StaticResource WarnSoft}" BorderBrush="{StaticResource Warn}" BorderThickness="2,0,0,0" Padding="18,8"
Visibility="{Binding ShowRateLimit, Converter={StaticResource BoolToVis}}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="&#xE711;" Style="{StaticResource BtnGhost}" FontFamily="Segoe MDL2 Assets" Margin="8,0,0,0"
Command="{Binding DismissRateLimitCommand}" AutomationProperties.Name="Dismiss" ToolTip="Hide this for now" />
<Button DockPanel.Dock="Right" Content="How to make a token" Style="{StaticResource BtnGhost}" Margin="8,0,0,0"
Command="{Binding OpenGitHubTokenPageCommand}" Visibility="{Binding CanAddGitHubToken, Converter={StaticResource BoolToVis}}" ToolTip="Opens GitHub's page for new fine-grained tokens; public read only needs no permissions" />
<Button DockPanel.Dock="Right" Content="Add a GitHub token" Command="{Binding AddGitHubTokenCommand}" Margin="8,0,0,0"
Visibility="{Binding CanAddGitHubToken, Converter={StaticResource BoolToVis}}" />
<TextBlock Text="{Binding RateLimitText}" VerticalAlignment="Center" TextWrapping="Wrap" />
</DockPanel>
</Border>
<!-- ============ Component strip ============ -->
<Border Grid.Row="1" BorderBrush="{StaticResource Line}" BorderThickness="0,1,0,1" Background="{StaticResource Surface}">
<Border BorderBrush="{StaticResource Line}" BorderThickness="0,1,0,1" Background="{StaticResource Surface}">
<Grid Margin="18,12,18,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
Expand Down Expand Up @@ -603,6 +617,7 @@
</StackPanel>
</Grid>
</Border>
</StackPanel>

<!-- ============ Content ============ -->
<Grid Grid.Row="2">
Expand Down Expand Up @@ -1801,7 +1816,7 @@
</Border>

<TextBlock Text="GITHUB TOKEN (OPTIONAL)" Style="{StaticResource SectionTitle}" />
<TextBox Text="{Binding GitHubToken, UpdateSourceTrigger=LostFocus}" Tag="Only needed if update checks hit GitHub's request limit"
<TextBox x:Name="GitHubTokenBox" Text="{Binding GitHubToken, UpdateSourceTrigger=LostFocus}" Tag="Only needed if update checks hit GitHub's request limit"
AutomationProperties.Name="GitHub token" />

<StackPanel Orientation="Horizontal" Margin="0,28,0,0">
Expand Down
14 changes: 13 additions & 1 deletion src/DLSSUpdater/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,7 +38,18 @@ public MainWindow()
Closed += (_, _) => Core.HashCache.Save();
PreviewKeyDown += OnPreviewKeyDown;
KeyDown += OnKeyDown;
SettingsHost.Realized += root => _settingsScroll = Deferred.Find<ScrollViewer>(root, "SettingsScroll");
SettingsHost.Realized += root =>
{
_settingsRoot = root;
_settingsScroll = Deferred.Find<ScrollViewer>(root, "SettingsScroll");
};
_vm.FocusGitHubToken += () => Dispatcher.BeginInvoke(() =>
{
if (_settingsRoot is null) return;
var box = Deferred.Find<TextBox>(_settingsRoot, "GitHubTokenBox");
box.BringIntoView();
box.Focus();
}, System.Windows.Threading.DispatcherPriority.Loaded);
AboutHost.Realized += root =>
{
_aboutScroll = Deferred.Find<ScrollViewer>(root, "AboutScroll");
Expand Down
29 changes: 29 additions & 0 deletions src/DLSSUpdater/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -182,6 +187,30 @@ internal static void AppendLogLine(IList<string> 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 };

/// <summary>The Settings page is open on the General tab; the window puts the cursor in the GitHub token box.</summary>
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();

Expand Down
126 changes: 126 additions & 0 deletions tests/DLSSUpdater.Tests/GitHubRateLimitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Net;
using DLSSUpdater.Core;

namespace DLSSUpdater.Tests;

/// <summary>What the rate-limit notice is built on: GitHubClient.RateLimit follows the answers of a fake handler.</summary>
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<HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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());
}
}