From 7b475aa54a7c6ebacb12d39a00f8d9331b90a8ca Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:13:05 +0200 Subject: [PATCH 01/10] download count impl --- .../Services/Forgejo/ForgejoVersionCache.cs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 93c458e..8cf1c4b 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -11,6 +11,8 @@ public class ForgejoVersionCache : SafeDictionary, IV private readonly ForgejoService _fj; private readonly ILogger _logger; private readonly PeriodicTimer? _refreshTimer; + private int _downloadLimit; + private int _downloadCount; private Repository? _cachedProject; @@ -42,6 +44,31 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, _logger = logger; _forgejoEndpoint = config["Forgejo:Endpoint"]!; + + if (config["Forgejo:DownloadLimitPerInterval"] is not { } downloadLimitStr) + { + _downloadLimit = 5000; + } + else + { + if (int.TryParse(downloadLimitStr, out var downloadLimit)) + { + if (downloadLimit < 0) + { + logger.LogInformation( + "Config value 'Forgejo:DownloadLimitPerInterval' is a negative value. Disabling download limit."); + _downloadLimit = int.MaxValue; + } + else + _downloadLimit = downloadLimit; + } + else + { + logger.LogWarning( + "Config value 'Forgejo:DownloadLimitPerInterval' was not a valid integer. Defaulting 5000 downloads per interval."); + _downloadLimit = 5000; + } + } if (config["Forgejo:RefreshIntervalMinutes"] is not { } refreshIntervalStr) { @@ -100,12 +127,12 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi : "restarting the server. Set an admin access token in appsettings.json to enable an endpoint to do this."; _logger.LogInformation( - "Periodic version cache refreshing is disabled for {project}. It can be refreshed by {means}", + "Periodic version cache and download limit refreshing is disabled for {project}. It can be refreshed by {means}", ProjectName, howToRefresh); return; } - _logger.LogInformation("Refreshing version cache for {project} every {timePeriod} minutes.", + _logger.LogInformation("Refreshing version cache and download limit for {project} every {timePeriod} minutes.", ProjectName, _refreshTimer.Period.TotalMinutes); while (await _refreshTimer.WaitForNextTickAsync()) @@ -134,13 +161,20 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi { using (await TakeLockAsync()) { + if (_downloadCount > _downloadLimit) + { + return null; + } + + _downloadCount++; + return getter(this); } } public async Task RefreshAsync() { - _logger.LogInformation("Reloading version cache for {project}", ProjectName); + _logger.LogInformation("Reloading version cache and download limit for {project}", ProjectName); var sw = Stopwatch.StartNew(); @@ -222,6 +256,10 @@ public async Task RefreshAsync() _logger.LogInformation("Loaded {entryCount} version cache entries for {project}; took {time}ms.", Count, ProjectName, sw.ElapsedMilliseconds); + + _downloadCount = 0; + + _logger.LogInformation("Download limit reset for {project}. Download limit for interval is: {limit}", ProjectName, _downloadLimit); } public static void InitializeVersionCaches(WebApplication app) From 9f06e3029768e3bfcea20a368b7539847aa959ce Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:54:57 +0200 Subject: [PATCH 02/10] fix limit --- src/Server/Services/Forgejo/ForgejoVersionCache.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 8cf1c4b..82c302b 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -47,7 +47,7 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, if (config["Forgejo:DownloadLimitPerInterval"] is not { } downloadLimitStr) { - _downloadLimit = 5000; + _downloadLimit = 500; } else { @@ -65,8 +65,8 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, else { logger.LogWarning( - "Config value 'Forgejo:DownloadLimitPerInterval' was not a valid integer. Defaulting 5000 downloads per interval."); - _downloadLimit = 5000; + "Config value 'Forgejo:DownloadLimitPerInterval' was not a valid integer. Defaulting 500 downloads per interval."); + _downloadLimit = 500; } } From 53d61d4bb37695464d1e89192ca821e92102d417 Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:55:30 +0200 Subject: [PATCH 03/10] readonly --- src/Server/Services/Forgejo/ForgejoVersionCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 82c302b..a782a45 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -11,7 +11,7 @@ public class ForgejoVersionCache : SafeDictionary, IV private readonly ForgejoService _fj; private readonly ILogger _logger; private readonly PeriodicTimer? _refreshTimer; - private int _downloadLimit; + private readonly int _downloadLimit; private int _downloadCount; private Repository? _cachedProject; From 296937e7ebe9974a37ec4a696e34cafa80b50409 Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:14:54 +0200 Subject: [PATCH 04/10] split download reset into separate timer --- .../Services/Forgejo/ForgejoVersionCache.cs | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index a782a45..6a5a26b 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -11,6 +11,7 @@ public class ForgejoVersionCache : SafeDictionary, IV private readonly ForgejoService _fj; private readonly ILogger _logger; private readonly PeriodicTimer? _refreshTimer; + private readonly PeriodicTimer? _downloadIntervalTimer; private readonly int _downloadLimit; private int _downloadCount; @@ -45,9 +46,11 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, _forgejoEndpoint = config["Forgejo:Endpoint"]!; + _downloadIntervalTimer = new(TimeSpan.FromMinutes(1)); + if (config["Forgejo:DownloadLimitPerInterval"] is not { } downloadLimitStr) { - _downloadLimit = 500; + _downloadLimit = 100; } else { @@ -58,6 +61,7 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, logger.LogInformation( "Config value 'Forgejo:DownloadLimitPerInterval' is a negative value. Disabling download limit."); _downloadLimit = int.MaxValue; + _downloadIntervalTimer = null; } else _downloadLimit = downloadLimit; @@ -66,7 +70,7 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, { logger.LogWarning( "Config value 'Forgejo:DownloadLimitPerInterval' was not a valid integer. Defaulting 500 downloads per interval."); - _downloadLimit = 500; + _downloadLimit = 100; } } @@ -97,7 +101,8 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, public string ReleaseUrlFormat => $"{_forgejoEndpoint.TrimEnd('/')}/{ProjectPath}/releases/tag/{{0}}"; - public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersions pinnedVersions) => + public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersions pinnedVersions) + { Executor.ExecuteBackgroundAsync(async () => { _deriveLatestManually = deriveLatestVersionManually; @@ -127,12 +132,12 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi : "restarting the server. Set an admin access token in appsettings.json to enable an endpoint to do this."; _logger.LogInformation( - "Periodic version cache and download limit refreshing is disabled for {project}. It can be refreshed by {means}", + "Periodic version cache refreshing is disabled for {project}. It can be refreshed by {means}", ProjectName, howToRefresh); return; } - _logger.LogInformation("Refreshing version cache and download limit for {project} every {timePeriod} minutes.", + _logger.LogInformation("Refreshing version cache for {project} every {timePeriod} minutes.", ProjectName, _refreshTimer.Period.TotalMinutes); while (await _refreshTimer.WaitForNextTickAsync()) @@ -140,6 +145,20 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi await RefreshAsync(); } }); + + Executor.ExecuteBackgroundAsync(async () => + { + if (_downloadIntervalTimer is null) + { + return; + } + + while (await _downloadIntervalTimer.WaitForNextTickAsync()) + { + ResetDownloadLimit(); + } + }); + } public Task TakeLockAsync() => _semaphore.TakeAsync(); @@ -161,20 +180,25 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi { using (await TakeLockAsync()) { - if (_downloadCount > _downloadLimit) + if (_downloadIntervalTimer is null || _downloadCount++ < _downloadLimit) { - return null; + return getter(this); } - _downloadCount++; - - return getter(this); + return null; } } + + public void ResetDownloadLimit() + { + _downloadCount = 0; + + _logger.LogInformation("Download limit reset for {project}. Download limit for interval is: {limit}", ProjectName, _downloadLimit); + } public async Task RefreshAsync() { - _logger.LogInformation("Reloading version cache and download limit for {project}", ProjectName); + _logger.LogInformation("Reloading version cache for {project}", ProjectName); var sw = Stopwatch.StartNew(); @@ -256,10 +280,6 @@ public async Task RefreshAsync() _logger.LogInformation("Loaded {entryCount} version cache entries for {project}; took {time}ms.", Count, ProjectName, sw.ElapsedMilliseconds); - - _downloadCount = 0; - - _logger.LogInformation("Download limit reset for {project}. Download limit for interval is: {limit}", ProjectName, _downloadLimit); } public static void InitializeVersionCaches(WebApplication app) From e1826c05eb0f4fb0dfa28161c67f50ea88d73617 Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:31:28 +0200 Subject: [PATCH 05/10] cleanup --- src/Server/Services/Forgejo/ForgejoVersionCache.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 6a5a26b..19fdc68 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -60,7 +60,7 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, { logger.LogInformation( "Config value 'Forgejo:DownloadLimitPerInterval' is a negative value. Disabling download limit."); - _downloadLimit = int.MaxValue; + _downloadLimit = 0; _downloadIntervalTimer = null; } else @@ -188,8 +188,8 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi return null; } } - - public void ResetDownloadLimit() + + private void ResetDownloadLimit() { _downloadCount = 0; From 826926bba8d9adf85b2e149d4358a69f4312d5f7 Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:02:29 +0200 Subject: [PATCH 06/10] add better error handling --- .../Controllers/Api/v1/VersionController.cs | 93 ++++++++++++++----- src/Server/Controllers/DownloadController.cs | 57 ++++++++++-- src/Server/Controllers/LatestController.cs | 74 +++++++++++---- src/Server/Helpers/Results/ReleaseError.cs | 16 ++++ .../Services/Forgejo/ForgejoVersionCache.cs | 9 +- 5 files changed, 194 insertions(+), 55 deletions(-) create mode 100644 src/Server/Helpers/Results/ReleaseError.cs diff --git a/src/Server/Controllers/Api/v1/VersionController.cs b/src/Server/Controllers/Api/v1/VersionController.cs index a9d42c9..1a4ad2a 100644 --- a/src/Server/Controllers/Api/v1/VersionController.cs +++ b/src/Server/Controllers/Api/v1/VersionController.cs @@ -1,5 +1,8 @@ +using System.ComponentModel; +using Gommon; using Microsoft.AspNetCore.Mvc; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; using Ryujinx.Systems.Update.Server.Services.Forgejo; namespace Ryujinx.Systems.Update.Server.Controllers; @@ -25,25 +28,39 @@ public async Task> GetLatestStable( if (!arch.TryParseAsSupportedArchitecture(out var supportedArch)) return BadRequest($"Unknown architecture '{arch}'"); - if (await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is { } latest) + { + if (!Config.EnabledEndpoints.LatestQuery) + return Ok(new VersionResponse + { + Version = latest.Tag, + ArtifactUrl = "", + MaxConcurrency = Config.MaxConcurrentDownloads, + ReleaseUrlFormat = vcache.ReleaseUrlFormat + }); - if (!Config.EnabledEndpoints.LatestQuery) return Ok(new VersionResponse { Version = latest.Tag, - ArtifactUrl = "", + ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), MaxConcurrency = Config.MaxConcurrentDownloads, ReleaseUrlFormat = vcache.ReleaseUrlFormat }); - - return Ok(new VersionResponse - { - Version = latest.Tag, - ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), - MaxConcurrency = Config.MaxConcurrentDownloads, - ReleaseUrlFormat = vcache.ReleaseUrlFormat - }); + } + + return NotFound(); } [HttpGet($"{Constants.CanaryRoute}/{Constants.RouteName_Latest}")] @@ -62,26 +79,40 @@ public async Task> GetLatestCanary( if (!arch.TryParseAsSupportedArchitecture(out var supportedArch)) return BadRequest($"Unknown architecture '{arch}'"); + + Return result = await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } - if (await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)) is not { } latest) - return NotFound(); + if (result.IsSuccess && result.Unwrap() is { } latest) + { + if (!Config.EnabledEndpoints.LatestQuery) + return Ok(new VersionResponse + { + Version = latest.Tag, + ArtifactUrl = "", + MaxConcurrency = Config.MaxConcurrentDownloads, + ReleaseUrlFormat = vcache.ReleaseUrlFormat + }); - if (!Config.EnabledEndpoints.LatestQuery) return Ok(new VersionResponse { Version = latest.Tag, - ArtifactUrl = "", + ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), MaxConcurrency = Config.MaxConcurrentDownloads, ReleaseUrlFormat = vcache.ReleaseUrlFormat }); - - return Ok(new VersionResponse - { - Version = latest.Tag, - ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), - MaxConcurrency = Config.MaxConcurrentDownloads, - ReleaseUrlFormat = vcache.ReleaseUrlFormat - }); + } + + return NotFound(); } [HttpGet($"{Constants.StableRoute}/{{version}}")] @@ -110,7 +141,19 @@ public async Task> GetSpecificCanary( string version ) { - if (await vcache.GetReleaseAsync(c => c[version]) is { } cacheEntry) + Return result = await vcache.GetReleaseAsync(c => c[version]); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} cacheEntry) return Ok(cacheEntry); return NotFound(); diff --git a/src/Server/Controllers/DownloadController.cs b/src/Server/Controllers/DownloadController.cs index 2ca189d..6e6f3cf 100644 --- a/src/Server/Controllers/DownloadController.cs +++ b/src/Server/Controllers/DownloadController.cs @@ -1,5 +1,8 @@ +using System.ComponentModel; +using Gommon; using Microsoft.AspNetCore.Mvc; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; using Ryujinx.Systems.Update.Server.Services.Forgejo; namespace Ryujinx.Systems.Update.Server.Controllers; @@ -43,16 +46,26 @@ public async Task DownloadCustom( return BadRequest( $"Unknown release channel '{rc}'; valid are '{Constants.StableRoute}' and '{Constants.CanaryRoute}'"); - var release = await HttpContext.RequestServices + var result = await HttpContext.RequestServices .GetCacheFor(releaseChannel) .GetReleaseAsync(c => version is Constants.RouteName_Latest ? c.GetLatest(supportedPlatform, supportedArch) : c[version] ); - if (release is null) - return NotFound(); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} release) + return Redirect(release.GetUrlFor(supportedPlatform, supportedArch)); - return Redirect(release.GetUrlFor(supportedPlatform, supportedArch)); + return NotFound(); } [HttpGet] @@ -69,10 +82,22 @@ [FromServices] ILogger logger return Problem("This instance of Ryubing UpdateServer is not configured to support this endpoint.", statusCode: 418); - if (await vcache.GetReleaseAsync(c => c.Latest) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.Latest); - return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) + return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + + return NotFound(); } [HttpGet(Constants.CanaryRoute)] @@ -88,10 +113,22 @@ [FromServices] ILogger logger return Problem("This instance of Ryubing UpdateServer is not configured to support this endpoint.", statusCode: 418); - if (await vcache.GetReleaseAsync(c => c.Latest) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.Latest); - return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) + return RedirectOrProblem(latest, logger, HttpContext.Request.Headers.UserAgent.ToString()); + + return NotFound(); } private ActionResult RedirectOrProblem(VersionCacheEntry cacheEntry, ILogger logger, diff --git a/src/Server/Controllers/LatestController.cs b/src/Server/Controllers/LatestController.cs index e8cc2b6..e6cfb5f 100644 --- a/src/Server/Controllers/LatestController.cs +++ b/src/Server/Controllers/LatestController.cs @@ -1,5 +1,8 @@ -using Microsoft.AspNetCore.Mvc; +using System.ComponentModel; +using Gommon; +using Microsoft.AspNetCore.Mvc; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; using Ryujinx.Systems.Update.Server.Services.Forgejo; namespace Ryujinx.Systems.Update.Server.Controllers; @@ -40,37 +43,64 @@ public async Task> GetLatestCustom( var vcache = HttpContext.RequestServices.GetCacheFor(releaseChannel); - if (await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)) is not { } latest) - return NotFound(); + Return result = await vcache.GetReleaseAsync(c => c.GetLatest(supportedPlatform, supportedArch)); - if (!Config.EnabledEndpoints.LatestQuery) + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is { } latest) + { + if (!Config.EnabledEndpoints.LatestQuery) + return Ok(new VersionResponse + { + Version = latest.Tag, + ArtifactUrl = "", + MaxConcurrency = Config.MaxConcurrentDownloads, + ReleaseUrlFormat = vcache.ReleaseUrlFormat + }); + return Ok(new VersionResponse { Version = latest.Tag, - ArtifactUrl = "", + ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), MaxConcurrency = Config.MaxConcurrentDownloads, ReleaseUrlFormat = vcache.ReleaseUrlFormat }); - - return Ok(new VersionResponse - { - Version = latest.Tag, - ArtifactUrl = latest.GetUrlFor(supportedPlatform, supportedArch), - MaxConcurrency = Config.MaxConcurrentDownloads, - ReleaseUrlFormat = vcache.ReleaseUrlFormat - }); + } + + return NotFound(); } [HttpGet(Constants.StableRoute), HttpGet] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] [EndpointDescription("Redirect to the Forgejo release URL of the latest Stable Ryubing release.")] public async Task RedirectLatestStable( [FromKeyedServices("stableCache")] ForgejoVersionCache vcache) { - if (await vcache.GetReleaseAsync(c => c.Latest) is { } latest) - return Redirect(latest.ReleaseUrl); + Return result = await vcache.GetReleaseAsync(c => c.Latest); + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) + return Redirect(latest.ReleaseUrl); + return NotFound(); } @@ -81,7 +111,19 @@ public async Task RedirectLatestStable( public async Task RedirectLatestCanary( [FromKeyedServices("canaryCache")] ForgejoVersionCache vcache) { - if (await vcache.GetReleaseAsync(c => c.Latest) is { } latest) + Return result = await vcache.GetReleaseAsync(c => c.Latest); + + if (result.IsOf(out ReleaseError error)) + { + return error.Error switch + { + ReleaseError.ReleaseErrorType.NotInitialized => NotFound(), + ReleaseError.ReleaseErrorType.RateLimited => Problem("The version cache has hit the request rate limit.", statusCode: StatusCodes.Status429TooManyRequests), + _ => throw new InvalidEnumArgumentException() + }; + } + + if (result.IsSuccess && result.Unwrap() is {} latest) return Redirect(latest.ReleaseUrl); return NotFound(); diff --git a/src/Server/Helpers/Results/ReleaseError.cs b/src/Server/Helpers/Results/ReleaseError.cs new file mode 100644 index 0000000..ed55424 --- /dev/null +++ b/src/Server/Helpers/Results/ReleaseError.cs @@ -0,0 +1,16 @@ +using Gommon; + +namespace Ryujinx.Systems.Update.Server.Helpers.Results; + +public readonly struct ReleaseError(ReleaseError.ReleaseErrorType error) : IErrorState +{ + public ReleaseErrorType Error { get; } = error; + + public override string ToString() => Error.ToString(); + + public enum ReleaseErrorType + { + NotInitialized, + RateLimited, + } +} \ No newline at end of file diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 19fdc68..7d9c982 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -3,6 +3,7 @@ using ForgejoApiClient.Api; using Gommon; using Ryujinx.Systems.Update.Common; +using Ryujinx.Systems.Update.Server.Helpers.Results; namespace Ryujinx.Systems.Update.Server.Services.Forgejo; @@ -164,10 +165,10 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi public VersionCacheEntry? Latest => this[_latestTag ?? string.Empty]; - public VersionCacheEntry? GetLatest(SupportedPlatform platform, SupportedArchitecture arch) + public Return GetLatest(SupportedPlatform platform, SupportedArchitecture arch) { if (!HasProjectInfo) - return null; + return Return.Failure(new ReleaseError(ReleaseError.ReleaseErrorType.NotInitialized)); if (_pinnedVersions.Find(platform, arch) is { } pinnedVersion && TryGetValue(pinnedVersion, out var pinnedLatest)) @@ -176,7 +177,7 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi return Latest; } - public async Task GetReleaseAsync(Func getter) + public async Task> GetReleaseAsync(Func> getter) { using (await TakeLockAsync()) { @@ -185,7 +186,7 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi return getter(this); } - return null; + return Return.Failure(new ReleaseError(ReleaseError.ReleaseErrorType.RateLimited)); } } From c235ca387b7695b7dee6722023ae44a253779f7c Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:53:41 +0200 Subject: [PATCH 07/10] correct info log --- src/Server/Services/Forgejo/ForgejoVersionCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 7d9c982..51c17ef 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -70,7 +70,7 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, else { logger.LogWarning( - "Config value 'Forgejo:DownloadLimitPerInterval' was not a valid integer. Defaulting 500 downloads per interval."); + "Config value 'Forgejo:DownloadLimitPerInterval' was not a valid integer. Defaulting 100 downloads per interval."); _downloadLimit = 100; } } From 8eaa1aa4c12c2d25ba007831c02d22e847d2465d Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:03:37 +0200 Subject: [PATCH 08/10] add attempted downloads to log --- src/Server/Services/Forgejo/ForgejoVersionCache.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 51c17ef..cfbd822 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -14,7 +14,7 @@ public class ForgejoVersionCache : SafeDictionary, IV private readonly PeriodicTimer? _refreshTimer; private readonly PeriodicTimer? _downloadIntervalTimer; private readonly int _downloadLimit; - private int _downloadCount; + private int _downloadAttempts; private Repository? _cachedProject; @@ -181,7 +181,7 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi { using (await TakeLockAsync()) { - if (_downloadIntervalTimer is null || _downloadCount++ < _downloadLimit) + if (_downloadAttempts++ < _downloadLimit || _downloadIntervalTimer is null) { return getter(this); } @@ -192,9 +192,11 @@ public void Init(string projectId, bool deriveLatestVersionManually, PinnedVersi private void ResetDownloadLimit() { - _downloadCount = 0; + int attempts = _downloadAttempts; - _logger.LogInformation("Download limit reset for {project}. Download limit for interval is: {limit}", ProjectName, _downloadLimit); + _downloadAttempts = 0; + + _logger.LogInformation("Download limit reset for {project}. {attempts} download attempts in the last interval. Download limit for interval is: {limit}", ProjectName, attempts, _downloadLimit); } public async Task RefreshAsync() From f499a35d315a383eb244184bcac59a234ab39f79 Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:04:57 +0200 Subject: [PATCH 09/10] fix logs --- src/Server/Services/Forgejo/ForgejoVersionCache.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index cfbd822..82676a0 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -51,6 +51,8 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, if (config["Forgejo:DownloadLimitPerInterval"] is not { } downloadLimitStr) { + logger.LogWarning( + "Config value 'Forgejo:DownloadLimitPerInterval' is missing. Defaulting 100 downloads per interval."); _downloadLimit = 100; } else @@ -70,13 +72,15 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, else { logger.LogWarning( - "Config value 'Forgejo:DownloadLimitPerInterval' was not a valid integer. Defaulting 100 downloads per interval."); + "Config value 'Forgejo:DownloadLimitPerInterval' is not a valid integer. Defaulting 100 downloads per interval."); _downloadLimit = 100; } } if (config["Forgejo:RefreshIntervalMinutes"] is not { } refreshIntervalStr) { + logger.LogWarning( + "Config value 'Forgejo:RefreshIntervalSeconds' is missing. Defaulting to 5 minutes."); _refreshTimer = new(TimeSpan.FromMinutes(5)); return; } @@ -95,7 +99,7 @@ public ForgejoVersionCache(IConfiguration config, ForgejoService forgejoService, else { logger.LogWarning( - "Config value 'Forgejo:RefreshIntervalSeconds' was not a valid integer. Defaulting to 5 minutes."); + "Config value 'Forgejo:RefreshIntervalSeconds' is not a valid integer. Defaulting to 5 minutes."); _refreshTimer = new(TimeSpan.FromMinutes(5)); } } From 1abe77f2e306808eceb9392d8de4fc6ad2e333a8 Mon Sep 17 00:00:00 2001 From: LotP1 <68976644+LotP1@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:11:45 +0200 Subject: [PATCH 10/10] log text --- src/Server/Services/Forgejo/ForgejoVersionCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Services/Forgejo/ForgejoVersionCache.cs b/src/Server/Services/Forgejo/ForgejoVersionCache.cs index 82676a0..80f3465 100644 --- a/src/Server/Services/Forgejo/ForgejoVersionCache.cs +++ b/src/Server/Services/Forgejo/ForgejoVersionCache.cs @@ -200,7 +200,7 @@ private void ResetDownloadLimit() _downloadAttempts = 0; - _logger.LogInformation("Download limit reset for {project}. {attempts} download attempts in the last interval. Download limit for interval is: {limit}", ProjectName, attempts, _downloadLimit); + _logger.LogInformation("Download attempt count reset for {project}. {attempts} download attempts in the last interval. Download limit for interval is: {limit}", ProjectName, attempts, _downloadLimit); } public async Task RefreshAsync()