From 6938635ee42e3f9946d363e3cb4ed060d6236731 Mon Sep 17 00:00:00 2001 From: Ryan Macham Date: Mon, 6 Apr 2026 20:47:13 +0100 Subject: [PATCH] Added Retry Policy and Launcher Error. Included ErrorHelper for mapping. --- AlayaCore.sln.DotSettings.user | 1 + .../Interfaces/Clients/IHttpClient.cs | 2 - .../Interfaces/Policies/IRetryPolicy.cs | 16 +- AlayaCore/Clients/DefaultHttpClient.cs | 2 +- AlayaCore/Errors/LauncherError.cs | 16 +- .../LauncherUpdateServiceOptions.cs | 6 + .../Configuration/ManifestServiceOptions.cs | 34 +-- .../ModrinthConnectionOptions.cs | 3 + .../Configuration/RetryPolicyOptions.cs | 7 +- AlayaCore/Policies/RetryPolicy.cs | 77 ++++- AlayaCore/Services/HttpDownloadService.cs | 275 ++++++++++-------- AlayaCore/Services/ManifestService.cs | 90 +++--- AlayaCore/Services/ModService.cs | 268 +++++++++-------- .../Utilities/Enums/LauncherErrorType.cs | 11 +- AlayaCore/Utilities/Helpers/ErrorHelper.cs | 55 +++- AlayaCore/Utilities/Helpers/OptionsHelper.cs | 18 +- 16 files changed, 539 insertions(+), 342 deletions(-) diff --git a/AlayaCore.sln.DotSettings.user b/AlayaCore.sln.DotSettings.user index 6be7083..2a3f04b 100644 --- a/AlayaCore.sln.DotSettings.user +++ b/AlayaCore.sln.DotSettings.user @@ -21,5 +21,6 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded \ No newline at end of file diff --git a/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs b/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs index 42d7bd0..b34bb34 100644 --- a/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs +++ b/AlayaCore/Abstractions/Interfaces/Clients/IHttpClient.cs @@ -11,7 +11,5 @@ namespace AlayaCore.Abstractions.Interfaces.Clients Uri uri, HttpCompletionOption completionOption, CancellationToken cancellationToken); - - HttpClient HttpClient { get; } } } \ No newline at end of file diff --git a/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs b/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs index bc792f1..efdbb2a 100644 --- a/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs +++ b/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs @@ -1,7 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + namespace AlayaCore.Abstractions.Interfaces.Policies { - public class IRetryPolicy + public interface IRetryPolicy { - + Task ExecuteAsync( + Func operation, + string operationName, + CancellationToken cancellationToken = default); + + Task ExecuteAsync( + Func> operation, + string operationName, + CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/AlayaCore/Clients/DefaultHttpClient.cs b/AlayaCore/Clients/DefaultHttpClient.cs index c7d762e..121f943 100644 --- a/AlayaCore/Clients/DefaultHttpClient.cs +++ b/AlayaCore/Clients/DefaultHttpClient.cs @@ -6,7 +6,7 @@ using AlayaCore.Abstractions.Interfaces.Clients; namespace AlayaCore.Clients { - public sealed class DefaultHttpClient : IHttpClient, IDisposable + public sealed class DefaultHttpClient : IHttpClient { private readonly HttpClient _httpClient; private bool _disposed; diff --git a/AlayaCore/Errors/LauncherError.cs b/AlayaCore/Errors/LauncherError.cs index 22235f2..fd2397c 100644 --- a/AlayaCore/Errors/LauncherError.cs +++ b/AlayaCore/Errors/LauncherError.cs @@ -1,7 +1,19 @@ +using System; +using AlayaCore.Utilities.Enums; + namespace AlayaCore.Errors { - public class LauncherError + public sealed class LauncherError { - + public LauncherErrorType Type { get; } + public string Message { get; } + public Exception Exception { get; } + + public LauncherError(LauncherErrorType type, string message, Exception exception) + { + Type = type; + Message = message; + Exception = exception; + } } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs b/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs index 3e942b1..047f966 100644 --- a/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs +++ b/AlayaCore/Models/Configuration/LauncherUpdateServiceOptions.cs @@ -1,3 +1,6 @@ +using System; +using System.IO; + namespace AlayaCore.Models.Configuration { public sealed class LauncherUpdateServiceOptions @@ -10,5 +13,8 @@ namespace AlayaCore.Models.Configuration public string AlayaUpdaterPath { get; set; } public bool ForceUpdate { get; set; } + + public static LauncherUpdateServiceOptions Default { get; } = + new LauncherUpdateServiceOptions(Path.Combine(AppContext.BaseDirectory, "Data", "Updater"), false); } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/ManifestServiceOptions.cs b/AlayaCore/Models/Configuration/ManifestServiceOptions.cs index 3fb79df..79f38b6 100644 --- a/AlayaCore/Models/Configuration/ManifestServiceOptions.cs +++ b/AlayaCore/Models/Configuration/ManifestServiceOptions.cs @@ -4,25 +4,23 @@ namespace AlayaCore.Models.Configuration { public sealed class ManifestServiceOptions { - public Uri CoreManifestUri { get; } + public Uri AlayaManifestUri { get; } public Uri LauncherManifestUri { get; } - public string CoreManifestSha512Hash { get; } + public string AlayaManifestSha512Hash { get; } public string LauncherManifestSha512Hash { get; } - public string ManifestDirectoryPath { get; } public ManifestServiceOptions( - Uri coreManifestUri, + Uri alayaManifestUri, Uri launcherManifestUri, - string coreManifestSha512Hash, - string launcherManifestSha512Hash, - string manifestDirectoryPath) + string alayaManifestSha512Hash, + string launcherManifestSha512Hash) { - CoreManifestUri = coreManifestUri ?? throw new ArgumentNullException(nameof(coreManifestUri)); + AlayaManifestUri = alayaManifestUri ?? throw new ArgumentNullException(nameof(alayaManifestUri)); LauncherManifestUri = launcherManifestUri ?? throw new ArgumentNullException(nameof(launcherManifestUri)); - if (!CoreManifestUri.IsAbsoluteUri) + if (!AlayaManifestUri.IsAbsoluteUri) { - throw new ArgumentException("Core manifest URI must be absolute.", nameof(coreManifestUri)); + throw new ArgumentException("Core manifest URI must be absolute.", nameof(alayaManifestUri)); } if (!LauncherManifestUri.IsAbsoluteUri) @@ -30,9 +28,9 @@ namespace AlayaCore.Models.Configuration throw new ArgumentException("Launcher manifest URI must be absolute.", nameof(launcherManifestUri)); } - if (string.IsNullOrWhiteSpace(coreManifestSha512Hash)) + if (string.IsNullOrWhiteSpace(alayaManifestSha512Hash)) { - throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(coreManifestSha512Hash)); + throw new ArgumentException("Core manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(alayaManifestSha512Hash)); } if (string.IsNullOrWhiteSpace(launcherManifestSha512Hash)) @@ -40,14 +38,12 @@ namespace AlayaCore.Models.Configuration throw new ArgumentException("Launcher manifest SHA-512 hash cannot be null, empty, or whitespace.", nameof(launcherManifestSha512Hash)); } - if (string.IsNullOrWhiteSpace(manifestDirectoryPath)) - { - throw new ArgumentException("Manifest directory path cannot be null, empty, or whitespace.", nameof(manifestDirectoryPath)); - } - - CoreManifestSha512Hash = coreManifestSha512Hash; + AlayaManifestSha512Hash = alayaManifestSha512Hash; LauncherManifestSha512Hash = launcherManifestSha512Hash; - ManifestDirectoryPath = manifestDirectoryPath; } + + public static ManifestServiceOptions Default { get; } = new ManifestServiceOptions( + new Uri("INSERT-ALAYA-URL", UriKind.Absolute), + new Uri("INSERT-LAUNCHER-URL", UriKind.Absolute), "INSERT-ALAYA-HASH", "INSERT-LAUNCHER-HASH"); } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs b/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs index 075fe0d..d128566 100644 --- a/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs +++ b/AlayaCore/Models/Configuration/ModrinthConnectionOptions.cs @@ -8,5 +8,8 @@ namespace AlayaCore.Models.Configuration } public string BaseApiUrl { get; } + + public static ModrinthConnectionOptions Default { get; } = + new ModrinthConnectionOptions("https://api.modrinth.com/v2/"); } } \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/RetryPolicyOptions.cs b/AlayaCore/Models/Configuration/RetryPolicyOptions.cs index d751583..a970967 100644 --- a/AlayaCore/Models/Configuration/RetryPolicyOptions.cs +++ b/AlayaCore/Models/Configuration/RetryPolicyOptions.cs @@ -1,7 +1,12 @@ namespace AlayaCore.Models.Configuration { - public class RetryPolicyOptions + public sealed class RetryPolicyOptions { + public int MaxAttempts { get; set; } = 3; + public int BaseDelayMilliseconds { get; set; } = 500; + public double BackoffMultiplier { get; set; } = 2.0; + public int MaxDelayMilliseconds { get; set; } = 3000; + public static RetryPolicyOptions Default { get; } = new(); } } \ No newline at end of file diff --git a/AlayaCore/Policies/RetryPolicy.cs b/AlayaCore/Policies/RetryPolicy.cs index 8a7654d..f581a68 100644 --- a/AlayaCore/Policies/RetryPolicy.cs +++ b/AlayaCore/Policies/RetryPolicy.cs @@ -7,13 +7,14 @@ using AlayaCore.Abstractions.Interfaces.Policies; using AlayaCore.Models.Configuration; using Microsoft.Extensions.Logging; -namespace AlayaCore.Models.Policies +namespace AlayaCore.Services { public sealed class RetryPolicy : IRetryPolicy { private readonly RetryPolicyOptions _options; private readonly ILogger _logger; - private readonly Random _random; + + private static readonly Random _random = new Random(); public RetryPolicy( RetryPolicyOptions options, @@ -21,7 +22,6 @@ namespace AlayaCore.Models.Policies { _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _random = new Random(); } public async Task ExecuteAsync( @@ -64,10 +64,7 @@ namespace AlayaCore.Models.Policies throw new ArgumentException("Operation name cannot be null, empty, or whitespace.", nameof(operationName)); } - if (_options.MaxAttempts <= 0) - { - throw new InvalidOperationException("RetryPolicyOptions.MaxAttempts must be greater than zero."); - } + ValidateOptions(); Exception? lastException = null; @@ -88,12 +85,38 @@ namespace AlayaCore.Models.Policies return await operation(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested && IsRetryable(ex)) + { + lastException = ex; + + if (attempt == _options.MaxAttempts) + { + _logger.LogError( + ex, + "Operation {OperationName} failed after {MaxAttempts} attempts due to repeated timeout or cancellation-like transient failures.", + operationName, + _options.MaxAttempts); + + throw; + } + + TimeSpan delay = CalculateDelay(attempt); + + _logger.LogWarning( + ex, + "Operation {OperationName} timed out or was transiently cancelled on attempt {Attempt} of {MaxAttempts}. Retrying after {DelayMs}ms.", + operationName, + attempt, + _options.MaxAttempts, + (int)delay.TotalMilliseconds); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } catch (OperationCanceledException) { _logger.LogInformation( - "Operation {OperationName} was cancelled during attempt {Attempt}.", - operationName, - attempt); + "Operation {OperationName} was cancelled by the caller.", + operationName); throw; } @@ -141,7 +164,30 @@ namespace AlayaCore.Models.Policies lastException); } - private bool IsRetryable(Exception exception) + private void ValidateOptions() + { + if (_options.MaxAttempts <= 0) + { + throw new InvalidOperationException("RetryPolicyOptions.MaxAttempts must be greater than zero."); + } + + if (_options.BaseDelayMilliseconds < 0) + { + throw new InvalidOperationException("RetryPolicyOptions.BaseDelayMilliseconds cannot be negative."); + } + + if (_options.BackoffMultiplier < 1d) + { + throw new InvalidOperationException("RetryPolicyOptions.BackoffMultiplier must be greater than or equal to 1."); + } + + if (_options.MaxDelayMilliseconds < 0) + { + throw new InvalidOperationException("RetryPolicyOptions.MaxDelayMilliseconds cannot be negative."); + } + } + + private static bool IsRetryable(Exception exception) { return exception switch { @@ -157,11 +203,14 @@ namespace AlayaCore.Models.Policies private TimeSpan CalculateDelay(int attempt) { - double exponentialDelay = _options.BaseDelayMilliseconds * Math.Pow(_options.BackoffMultiplier, attempt - 1); + double exponentialDelay = _options.BaseDelayMilliseconds * + Math.Pow(_options.BackoffMultiplier, attempt - 1); + double cappedDelay = Math.Min(exponentialDelay, _options.MaxDelayMilliseconds); - int jitter = _random.Next(0, 150); - return TimeSpan.FromMilliseconds(cappedDelay + jitter); + int jitterMilliseconds = _random.Next(0, 150); + + return TimeSpan.FromMilliseconds(cappedDelay + jitterMilliseconds); } } } \ No newline at end of file diff --git a/AlayaCore/Services/HttpDownloadService.cs b/AlayaCore/Services/HttpDownloadService.cs index d88d090..ea3a466 100644 --- a/AlayaCore/Services/HttpDownloadService.cs +++ b/AlayaCore/Services/HttpDownloadService.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using AlayaCore.Abstractions.Interfaces.Clients; +using AlayaCore.Abstractions.Interfaces.Policies; using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Models.Progress; using AlayaCore.Models.Results; @@ -18,13 +19,16 @@ namespace AlayaCore.Services private const int BUFFER_SIZE = 81920; private readonly IHttpClient _httpClient; + private readonly IRetryPolicy _retryPolicy; private readonly ILogger _logger; public HttpDownloadService( IHttpClient httpClient, + IRetryPolicy retryPolicy, ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -104,137 +108,150 @@ namespace AlayaCore.Services try { - using HttpResponseMessage response = await _httpClient.GetAsync( - sourceUri, - HttpCompletionOption.ResponseHeadersRead, + DownloadResult result = await _retryPolicy.ExecuteAsync( + async token => + { + DeleteFileIfExists(tempFilePath); + + using HttpResponseMessage response = await _httpClient.GetAsync( + sourceUri, + HttpCompletionOption.ResponseHeadersRead, + token).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + long? totalBytes = response.Content.Headers.ContentLength; + long bytesDownloaded = 0; + + _logger.LogInformation( + "Download response received for {FileName}. Content-Length: {TotalBytes}.", + fileName, + totalBytes); + + progress?.Report(new DownloadProgress( + fileName: fileName, + destinationPath: destinationPath, + bytesDownloaded: 0, + totalBytes: totalBytes, + bytesPerSecond: null, + statusMessage: "Starting download...")); + + await using Stream responseStream = await response.Content + .ReadAsStreamAsync() + .ConfigureAwait(false); + + await using FileStream fileStream = new FileStream( + tempFilePath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + BUFFER_SIZE, + useAsync: true); + + using SHA512 sha512 = SHA512.Create(); + + byte[] buffer = new byte[BUFFER_SIZE]; + Stopwatch stopwatch = Stopwatch.StartNew(); + + _logger.LogDebug( + "Streaming download content for {FileName} into temporary file {TempFilePath}.", + fileName, + tempFilePath); + + while (true) + { + int bytesRead = await responseStream.ReadAsync( + buffer, + 0, + buffer.Length, + token).ConfigureAwait(false); + + if (bytesRead == 0) + { + break; + } + + await fileStream.WriteAsync( + buffer, + 0, + bytesRead, + token).ConfigureAwait(false); + + sha512.TransformBlock(buffer, 0, bytesRead, null, 0); + + bytesDownloaded += bytesRead; + + double? bytesPerSecond = null; + if (stopwatch.Elapsed.TotalSeconds > 0d) + { + bytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds; + } + + progress?.Report(new DownloadProgress( + fileName: fileName, + destinationPath: destinationPath, + bytesDownloaded: bytesDownloaded, + totalBytes: totalBytes, + bytesPerSecond: bytesPerSecond, + statusMessage: "Downloading file...")); + } + + sha512.TransformFinalBlock(Array.Empty(), 0, 0); + await fileStream.FlushAsync(token).ConfigureAwait(false); + + string actualHash = ConvertToLowerHex(sha512.Hash); + if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Hash verification failed for downloaded file {FileName}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.", + fileName, + normalizedExpectedHash, + actualHash); + + throw new InvalidDataException( + $"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'."); + } + + _logger.LogInformation( + "Hash verification succeeded for {FileName}. Replacing destination file with downloaded content.", + fileName); + + ReplaceDestinationFile(tempFilePath, destinationPath); + + double? finalBytesPerSecond = null; + if (stopwatch.Elapsed.TotalSeconds > 0d) + { + finalBytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds; + } + + progress?.Report(new DownloadProgress( + fileName: fileName, + destinationPath: destinationPath, + bytesDownloaded: bytesDownloaded, + totalBytes: totalBytes ?? bytesDownloaded, + bytesPerSecond: finalBytesPerSecond, + statusMessage: "Download complete.")); + + DownloadOutcome outcome = destinationExisted + ? DownloadOutcome.ReplacedInvalid + : DownloadOutcome.Downloaded; + + _logger.LogInformation( + "Download completed successfully for {FileName}. Outcome: {Outcome}. Bytes downloaded: {BytesDownloaded}.", + fileName, + outcome, + bytesDownloaded); + + return new DownloadResult( + destinationPath, + outcome, + hashVerified: true, + bytesDownloaded: bytesDownloaded); + }, + $"download:{fileName}", cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - long? totalBytes = response.Content.Headers.ContentLength; - long bytesDownloaded = 0; - - _logger.LogInformation( - "Download response received for {FileName}. Content-Length: {TotalBytes}.", - fileName, - totalBytes); - - progress?.Report(new DownloadProgress( - fileName: fileName, - destinationPath: destinationPath, - bytesDownloaded: 0, - totalBytes: totalBytes, - bytesPerSecond: null, - statusMessage: "Starting download...")); - - await using Stream responseStream = await response.Content - .ReadAsStreamAsync() - .ConfigureAwait(false); - - await using FileStream fileStream = new FileStream( - tempFilePath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - BUFFER_SIZE, - useAsync: true); - - using SHA512 sha512 = SHA512.Create(); - - byte[] buffer = new byte[BUFFER_SIZE]; - Stopwatch stopwatch = Stopwatch.StartNew(); - - _logger.LogDebug("Streaming download content for {FileName} into temporary file {TempFilePath}.", fileName, tempFilePath); - - while (true) - { - int bytesRead = await responseStream.ReadAsync( - buffer, - 0, - buffer.Length, - cancellationToken).ConfigureAwait(false); - - if (bytesRead == 0) - { - break; - } - - await fileStream.WriteAsync( - buffer, - 0, - bytesRead, - cancellationToken).ConfigureAwait(false); - - sha512.TransformBlock(buffer, 0, bytesRead, null, 0); - - bytesDownloaded += bytesRead; - - double? bytesPerSecond = null; - if (stopwatch.Elapsed.TotalSeconds > 0d) - { - bytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds; - } - - progress?.Report(new DownloadProgress( - fileName: fileName, - destinationPath: destinationPath, - bytesDownloaded: bytesDownloaded, - totalBytes: totalBytes, - bytesPerSecond: bytesPerSecond, - statusMessage: "Downloading file...")); - } - - sha512.TransformFinalBlock(Array.Empty(), 0, 0); - await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false); - - string actualHash = ConvertToLowerHex(sha512.Hash); - if (!string.Equals(actualHash, normalizedExpectedHash, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError( - "Hash verification failed for downloaded file {FileName}. Expected SHA-512: {ExpectedHash}. Actual SHA-512: {ActualHash}.", - fileName, - normalizedExpectedHash, - actualHash); - - throw new InvalidDataException( - $"Downloaded file hash mismatch. Expected '{normalizedExpectedHash}', got '{actualHash}'."); - } - - _logger.LogInformation( - "Hash verification succeeded for {FileName}. Replacing destination file with downloaded content.", - fileName); - - ReplaceDestinationFile(tempFilePath, destinationPath); - - double? finalBytesPerSecond = null; - if (stopwatch.Elapsed.TotalSeconds > 0d) - { - finalBytesPerSecond = bytesDownloaded / stopwatch.Elapsed.TotalSeconds; - } - - progress?.Report(new DownloadProgress( - fileName: fileName, - destinationPath: destinationPath, - bytesDownloaded: bytesDownloaded, - totalBytes: totalBytes ?? bytesDownloaded, - bytesPerSecond: finalBytesPerSecond, - statusMessage: "Download complete.")); - - DownloadOutcome outcome = destinationExisted - ? DownloadOutcome.ReplacedInvalid - : DownloadOutcome.Downloaded; - - _logger.LogInformation( - "Download completed successfully for {FileName}. Outcome: {Outcome}. Bytes downloaded: {BytesDownloaded}.", - fileName, - outcome, - bytesDownloaded); - - return new DownloadResult( - destinationPath, - outcome, - hashVerified: true, - bytesDownloaded: bytesDownloaded); + return result; } catch (OperationCanceledException) { diff --git a/AlayaCore/Services/ManifestService.cs b/AlayaCore/Services/ManifestService.cs index 8ff7332..365b89c 100644 --- a/AlayaCore/Services/ManifestService.cs +++ b/AlayaCore/Services/ManifestService.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using AlayaCore.Abstractions.Interfaces; using AlayaCore.Abstractions.Interfaces.Clients; +using AlayaCore.Abstractions.Interfaces.Policies; using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Models.Configuration; using AlayaCore.Models.Manifests; @@ -26,6 +27,7 @@ namespace AlayaCore.Services private readonly IHttpClient _httpClient; private readonly IFileStore _fileStore; private readonly ManifestServiceOptions _options; + private readonly IRetryPolicy _retryPolicy; private readonly ILogger _logger; public ManifestService( @@ -33,12 +35,14 @@ namespace AlayaCore.Services IHttpClient httpClient, IFileStore fileStore, ManifestServiceOptions options, + IRetryPolicy retryPolicy, ILogger logger) { _downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); _options = options ?? throw new ArgumentNullException(nameof(options)); + _retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -48,13 +52,13 @@ namespace AlayaCore.Services _logger.LogInformation( "Downloading and loading Alaya manifest from {ManifestUri} to {DestinationPath}.", - _options.CoreManifestUri, + _options.AlayaManifestUri, destinationPath); return DownloadAndLoadManifestAsync( - _options.CoreManifestUri, + _options.AlayaManifestUri, destinationPath, - _options.CoreManifestSha512Hash, + _options.AlayaManifestSha512Hash, static dto => dto.ToModel(), cancellationToken); } @@ -144,24 +148,24 @@ namespace AlayaCore.Services public async Task GetRemoteCoreManifestVersionAsync(CancellationToken cancellationToken = default) { - _logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.CoreManifestUri); + _logger.LogDebug("Fetching remote Alaya manifest version from {ManifestUri}.", _options.AlayaManifestUri); ManifestModel remoteManifest = await GetRemoteManifestAsync( - _options.CoreManifestUri, + _options.AlayaManifestUri, static dto => dto.ToModel(), cancellationToken).ConfigureAwait(false); if (remoteManifest.AlayaVersion == null) { - _logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.CoreManifestUri); + _logger.LogError("Remote Alaya manifest from {ManifestUri} did not contain a valid version.", _options.AlayaManifestUri); throw new InvalidDataException( - $"Remote core manifest from '{_options.CoreManifestUri}' does not contain a valid version."); + $"Remote core manifest from '{_options.AlayaManifestUri}' does not contain a valid version."); } _logger.LogInformation( "Fetched remote Alaya manifest version {RemoteVersion} from {ManifestUri}.", remoteManifest.AlayaVersion, - _options.CoreManifestUri); + _options.AlayaManifestUri); return remoteManifest.AlayaVersion; } @@ -309,11 +313,17 @@ namespace AlayaCore.Services manifestUri, destinationPath); - await _downloadService.DownloadFileAsync( - manifestUri, - destinationPath, - sha512Hash, - cancellationToken: cancellationToken).ConfigureAwait(false); + await _retryPolicy.ExecuteAsync( + async token => + { + await _downloadService.DownloadFileAsync( + manifestUri, + destinationPath, + sha512Hash, + cancellationToken: token).ConfigureAwait(false); + }, + $"manifest-download:{Path.GetFileName(destinationPath)}", + cancellationToken).ConfigureAwait(false); if (!File.Exists(destinationPath)) { @@ -368,31 +378,37 @@ namespace AlayaCore.Services _logger.LogDebug("Fetching remote manifest from {ManifestUri}.", manifestUri); - using HttpResponseMessage response = await _httpClient.GetAsync( - manifestUri, - HttpCompletionOption.ResponseContentRead, + return await _retryPolicy.ExecuteAsync( + async token => + { + using HttpResponseMessage response = await _httpClient.GetAsync( + manifestUri, + HttpCompletionOption.ResponseContentRead, + token).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(json)) + { + _logger.LogError("Remote manifest response from {ManifestUri} was empty.", manifestUri); + throw new InvalidDataException( + $"Remote manifest response from '{manifestUri}' was empty."); + } + + TModel model = DeserializeAndMapManifest( + json, + manifestUri.ToString(), + map, + swallowDeserializationErrors: false)!; + + _logger.LogDebug("Successfully fetched and mapped remote manifest from {ManifestUri}.", manifestUri); + + return model; + }, + $"manifest-fetch:{manifestUri.AbsolutePath}", cancellationToken).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - - string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(json)) - { - _logger.LogError("Remote manifest response from {ManifestUri} was empty.", manifestUri); - throw new InvalidDataException( - $"Remote manifest response from '{manifestUri}' was empty."); - } - - TModel model = DeserializeAndMapManifest( - json, - manifestUri.ToString(), - map, - swallowDeserializationErrors: false)!; - - _logger.LogDebug("Successfully fetched and mapped remote manifest from {ManifestUri}.", manifestUri); - - return model; } private TModel? DeserializeAndMapManifest( diff --git a/AlayaCore/Services/ModService.cs b/AlayaCore/Services/ModService.cs index 20e7713..97657e0 100644 --- a/AlayaCore/Services/ModService.cs +++ b/AlayaCore/Services/ModService.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using AlayaCore.Abstractions.Interfaces; using AlayaCore.Abstractions.Interfaces.Clients; +using AlayaCore.Abstractions.Interfaces.Policies; using AlayaCore.Abstractions.Interfaces.Services; using AlayaCore.Installation; using AlayaCore.Models; @@ -30,6 +31,7 @@ namespace AlayaCore.Services private readonly ModrinthConnectionOptions _options; private readonly IHttpClient _httpClient; private readonly IFileStore _fileStore; + private readonly IRetryPolicy _retryPolicy; private readonly ILogger _logger; public ModService( @@ -37,12 +39,14 @@ namespace AlayaCore.Services ModrinthConnectionOptions options, IHttpClient httpClient, IFileStore fileStore, + IRetryPolicy retryPolicy, ILogger logger) { _downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService)); _options = options ?? throw new ArgumentNullException(nameof(options)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore)); + _retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -213,136 +217,142 @@ namespace AlayaCore.Services fileEntry.FileName, versionEndpoint); - using HttpResponseMessage response = await _httpClient.GetAsync( - new Uri(versionEndpoint, UriKind.Absolute), - HttpCompletionOption.ResponseContentRead, + return await _retryPolicy.ExecuteAsync( + async token => + { + using HttpResponseMessage response = await _httpClient.GetAsync( + new Uri(versionEndpoint, UriKind.Absolute), + HttpCompletionOption.ResponseContentRead, + token).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(json)) + { + _logger.LogError( + "Mod metadata response for {FileName} from {VersionEndpoint} was empty.", + fileEntry.FileName, + versionEndpoint); + + throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty."); + } + + JObject jsonObject = JObject.Parse(json); + + JArray? filesArray = jsonObject["files"] as JArray; + if (filesArray == null || filesArray.Count == 0) + { + _logger.LogError( + "Mod metadata response for {FileName} did not contain any files.", + fileEntry.FileName); + + throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files."); + } + + JObject? selectedFile = filesArray + .OfType() + .FirstOrDefault(file => file.Value("primary") == true) + ?? filesArray.OfType().FirstOrDefault(); + + if (selectedFile == null) + { + _logger.LogError( + "Mod metadata response for {FileName} did not contain a usable file entry.", + fileEntry.FileName); + + throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry."); + } + + JObject? hashesObject = selectedFile["hashes"] as JObject; + if (hashesObject == null) + { + _logger.LogError( + "Mod metadata response for {FileName} did not contain a hashes object.", + fileEntry.FileName); + + throw new InvalidDataException( + $"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object."); + } + + string? remoteSha512Hash = hashesObject.Value("sha512"); + if (string.IsNullOrWhiteSpace(remoteSha512Hash)) + { + _logger.LogError( + "Mod metadata response for {FileName} did not contain a valid SHA-512 hash.", + fileEntry.FileName); + + throw new InvalidDataException( + $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash."); + } + + if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}", + fileEntry.FileName, + remoteSha512Hash, + fileEntry.Sha512Hash); + + throw new InvalidDataException( + $"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash."); + } + + long? size = selectedFile.Value("size"); + if (!size.HasValue || size.Value <= 0) + { + _logger.LogError( + "Mod metadata response for {FileName} did not contain a valid file size.", + fileEntry.FileName); + + throw new InvalidDataException( + $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size."); + } + + if (size.Value != fileEntry.Size) + { + _logger.LogError( + "Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}", + fileEntry.FileName, + size.Value, + fileEntry.Size); + + throw new InvalidDataException( + $"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size."); + } + + string? modUrl = selectedFile.Value("url"); + if (string.IsNullOrWhiteSpace(modUrl)) + { + _logger.LogError( + "Mod metadata response for {FileName} did not contain a valid file URL.", + fileEntry.FileName); + + throw new InvalidDataException( + $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL."); + } + + if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result)) + { + _logger.LogError( + "Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}", + fileEntry.FileName, + modUrl); + + throw new InvalidDataException( + $"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL."); + } + + _logger.LogDebug( + "Resolved download URL for mod {FileName} to {ModUrl}.", + fileEntry.FileName, + result); + + return result; + }, + $"mod-metadata:{fileEntry.FileName}", cancellationToken).ConfigureAwait(false); - - response.EnsureSuccessStatusCode(); - - string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(json)) - { - _logger.LogError( - "Mod metadata response for {FileName} from {VersionEndpoint} was empty.", - fileEntry.FileName, - versionEndpoint); - - throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty."); - } - - JObject jsonObject = JObject.Parse(json); - - JArray? filesArray = jsonObject["files"] as JArray; - if (filesArray == null || filesArray.Count == 0) - { - _logger.LogError( - "Mod metadata response for {FileName} did not contain any files.", - fileEntry.FileName); - - throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files."); - } - - JObject? selectedFile = filesArray - .OfType() - .FirstOrDefault(file => file.Value("primary") == true) - ?? filesArray.OfType().FirstOrDefault(); - - if (selectedFile == null) - { - _logger.LogError( - "Mod metadata response for {FileName} did not contain a usable file entry.", - fileEntry.FileName); - - throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry."); - } - - JObject? hashesObject = selectedFile["hashes"] as JObject; - if (hashesObject == null) - { - _logger.LogError( - "Mod metadata response for {FileName} did not contain a hashes object.", - fileEntry.FileName); - - throw new InvalidDataException( - $"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object."); - } - - string? remoteSha512Hash = hashesObject.Value("sha512"); - if (string.IsNullOrWhiteSpace(remoteSha512Hash)) - { - _logger.LogError( - "Mod metadata response for {FileName} did not contain a valid SHA-512 hash.", - fileEntry.FileName); - - throw new InvalidDataException( - $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash."); - } - - if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError( - "Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}", - fileEntry.FileName, - remoteSha512Hash, - fileEntry.Sha512Hash); - - throw new InvalidDataException( - $"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash."); - } - - long? size = selectedFile.Value("size"); - if (!size.HasValue || size.Value <= 0) - { - _logger.LogError( - "Mod metadata response for {FileName} did not contain a valid file size.", - fileEntry.FileName); - - throw new InvalidDataException( - $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size."); - } - - if (size.Value != fileEntry.Size) - { - _logger.LogError( - "Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}", - fileEntry.FileName, - size.Value, - fileEntry.Size); - - throw new InvalidDataException( - $"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size."); - } - - string? modUrl = selectedFile.Value("url"); - if (string.IsNullOrWhiteSpace(modUrl)) - { - _logger.LogError( - "Mod metadata response for {FileName} did not contain a valid file URL.", - fileEntry.FileName); - - throw new InvalidDataException( - $"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL."); - } - - if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result)) - { - _logger.LogError( - "Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}", - fileEntry.FileName, - modUrl); - - throw new InvalidDataException( - $"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL."); - } - - _logger.LogDebug( - "Resolved download URL for mod {FileName} to {ModUrl}.", - fileEntry.FileName, - result); - - return result; } private string BuildVersionEndpoint(string sha512Hash) diff --git a/AlayaCore/Utilities/Enums/LauncherErrorType.cs b/AlayaCore/Utilities/Enums/LauncherErrorType.cs index dffa93c..c901cee 100644 --- a/AlayaCore/Utilities/Enums/LauncherErrorType.cs +++ b/AlayaCore/Utilities/Enums/LauncherErrorType.cs @@ -2,6 +2,15 @@ namespace AlayaCore.Utilities.Enums { public enum LauncherErrorType { - + Unknown, + Network, + Manifest, + Authentication, + Download, + Installation, + Update, + Launch, + Configuration, + Cancelled } } \ No newline at end of file diff --git a/AlayaCore/Utilities/Helpers/ErrorHelper.cs b/AlayaCore/Utilities/Helpers/ErrorHelper.cs index 8ba56dd..2a4c941 100644 --- a/AlayaCore/Utilities/Helpers/ErrorHelper.cs +++ b/AlayaCore/Utilities/Helpers/ErrorHelper.cs @@ -1,7 +1,56 @@ -namespace AlayaCore.Utilities.Helpers +using System; +using System.IO; +using System.Net.Http; +using AlayaCore.Utilities.Enums; + +namespace AlayaCore.Errors { - public class ErrorHelper + public static class ErrorHelper { - + public static LauncherError Map(Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception)); + } + + return exception switch + { + OperationCanceledException => new LauncherError( + LauncherErrorType.Cancelled, + "The operation was cancelled.", + exception), + + HttpRequestException => new LauncherError( + LauncherErrorType.Network, + "A network error occurred.", + exception), + + IOException => new LauncherError( + LauncherErrorType.Network, + "A file or network I/O error occurred.", + exception), + + InvalidDataException => new LauncherError( + LauncherErrorType.Manifest, + "Invalid or corrupt data was encountered.", + exception), + + ArgumentException => new LauncherError( + LauncherErrorType.Configuration, + "Invalid configuration or input.", + exception), + + InvalidOperationException => new LauncherError( + LauncherErrorType.Launch, + "The operation could not be completed due to an invalid state.", + exception), + + _ => new LauncherError( + LauncherErrorType.Unknown, + "An unexpected error occurred.", + exception) + }; + } } } \ No newline at end of file diff --git a/AlayaCore/Utilities/Helpers/OptionsHelper.cs b/AlayaCore/Utilities/Helpers/OptionsHelper.cs index bdff83a..ac796b8 100644 --- a/AlayaCore/Utilities/Helpers/OptionsHelper.cs +++ b/AlayaCore/Utilities/Helpers/OptionsHelper.cs @@ -1,7 +1,21 @@ +using AlayaCore.Models.Configuration; + namespace AlayaCore.Utilities.Helpers { - public class OptionsHelper + public static class OptionsHelper { - + public static (LauncherUpdateServiceOptions LaunchUpdater, LauncherOptions Launcher, + GameOptions Game, ManifestServiceOptions Manifest, ModrinthConnectionOptions Modrinth, + RetryPolicyOptions RetryPolicy) GetDefaultOptions() + { + LauncherUpdateServiceOptions lso = LauncherUpdateServiceOptions.Default; + LauncherOptions lo = LauncherOptions.Default; + GameOptions go = GameOptions.Default; + ManifestServiceOptions mso = ManifestServiceOptions.Default; + ModrinthConnectionOptions mco = ModrinthConnectionOptions.Default; + RetryPolicyOptions rpo = RetryPolicyOptions.Default; + + return (lso, lo, go, mso, mco, rpo); + } } } \ No newline at end of file