From 491a3d420dee1ea7b3e005a3a197aca3efbf0f79 Mon Sep 17 00:00:00 2001 From: Ryan Macham Date: Mon, 6 Apr 2026 20:47:00 +0100 Subject: [PATCH] Added Retry Policy and Launcher Error. Included ErrorHelper for mapping. --- .../Interfaces/Policies/IRetryPolicy.cs | 7 + AlayaCore/Clients/DefaultHttpClient.cs | 58 ++++++ AlayaCore/Errors/LauncherError.cs | 7 + .../Configuration/RetryPolicyOptions.cs | 7 + AlayaCore/Policies/RetryPolicy.cs | 167 ++++++++++++++++++ .../Utilities/Enums/LauncherErrorType.cs | 7 + AlayaCore/Utilities/Helpers/ErrorHelper.cs | 7 + AlayaCore/Utilities/Helpers/OptionsHelper.cs | 7 + 8 files changed, 267 insertions(+) create mode 100644 AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs create mode 100644 AlayaCore/Clients/DefaultHttpClient.cs create mode 100644 AlayaCore/Errors/LauncherError.cs create mode 100644 AlayaCore/Models/Configuration/RetryPolicyOptions.cs create mode 100644 AlayaCore/Policies/RetryPolicy.cs create mode 100644 AlayaCore/Utilities/Enums/LauncherErrorType.cs create mode 100644 AlayaCore/Utilities/Helpers/ErrorHelper.cs create mode 100644 AlayaCore/Utilities/Helpers/OptionsHelper.cs diff --git a/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs b/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs new file mode 100644 index 0000000..bc792f1 --- /dev/null +++ b/AlayaCore/Abstractions/Interfaces/Policies/IRetryPolicy.cs @@ -0,0 +1,7 @@ +namespace AlayaCore.Abstractions.Interfaces.Policies +{ + public class IRetryPolicy + { + + } +} \ No newline at end of file diff --git a/AlayaCore/Clients/DefaultHttpClient.cs b/AlayaCore/Clients/DefaultHttpClient.cs new file mode 100644 index 0000000..c7d762e --- /dev/null +++ b/AlayaCore/Clients/DefaultHttpClient.cs @@ -0,0 +1,58 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AlayaCore.Abstractions.Interfaces.Clients; + +namespace AlayaCore.Clients +{ + public sealed class DefaultHttpClient : IHttpClient, IDisposable + { + private readonly HttpClient _httpClient; + private bool _disposed; + + public DefaultHttpClient(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public Task GetAsync( + Uri requestUri, + HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) + { + if (requestUri == null) + { + throw new ArgumentNullException(nameof(requestUri)); + } + + if (!requestUri.IsAbsoluteUri) + { + throw new ArgumentException("Request URI must be absolute.", nameof(requestUri)); + } + + ThrowIfDisposed(); + + return _httpClient.GetAsync(requestUri, completionOption, cancellationToken); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _httpClient.Dispose(); + _disposed = true; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(DefaultHttpClient)); + } + } + } +} \ No newline at end of file diff --git a/AlayaCore/Errors/LauncherError.cs b/AlayaCore/Errors/LauncherError.cs new file mode 100644 index 0000000..22235f2 --- /dev/null +++ b/AlayaCore/Errors/LauncherError.cs @@ -0,0 +1,7 @@ +namespace AlayaCore.Errors +{ + public class LauncherError + { + + } +} \ No newline at end of file diff --git a/AlayaCore/Models/Configuration/RetryPolicyOptions.cs b/AlayaCore/Models/Configuration/RetryPolicyOptions.cs new file mode 100644 index 0000000..d751583 --- /dev/null +++ b/AlayaCore/Models/Configuration/RetryPolicyOptions.cs @@ -0,0 +1,7 @@ +namespace AlayaCore.Models.Configuration +{ + public class RetryPolicyOptions + { + + } +} \ No newline at end of file diff --git a/AlayaCore/Policies/RetryPolicy.cs b/AlayaCore/Policies/RetryPolicy.cs new file mode 100644 index 0000000..8a7654d --- /dev/null +++ b/AlayaCore/Policies/RetryPolicy.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AlayaCore.Abstractions.Interfaces.Policies; +using AlayaCore.Models.Configuration; +using Microsoft.Extensions.Logging; + +namespace AlayaCore.Models.Policies +{ + public sealed class RetryPolicy : IRetryPolicy + { + private readonly RetryPolicyOptions _options; + private readonly ILogger _logger; + private readonly Random _random; + + public RetryPolicy( + RetryPolicyOptions options, + ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _random = new Random(); + } + + public async Task ExecuteAsync( + Func operation, + string operationName, + CancellationToken cancellationToken = default) + { + if (operation == null) + { + throw new ArgumentNullException(nameof(operation)); + } + + if (string.IsNullOrWhiteSpace(operationName)) + { + throw new ArgumentException("Operation name cannot be null, empty, or whitespace.", nameof(operationName)); + } + + await ExecuteAsync( + async token => + { + await operation(token).ConfigureAwait(false); + return null; + }, + operationName, + cancellationToken).ConfigureAwait(false); + } + + public async Task ExecuteAsync( + Func> operation, + string operationName, + CancellationToken cancellationToken = default) + { + if (operation == null) + { + throw new ArgumentNullException(nameof(operation)); + } + + if (string.IsNullOrWhiteSpace(operationName)) + { + 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."); + } + + Exception? lastException = null; + + for (int attempt = 1; attempt <= _options.MaxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + if (attempt > 1) + { + _logger.LogInformation( + "Retrying operation {OperationName}. Attempt {Attempt} of {MaxAttempts}.", + operationName, + attempt, + _options.MaxAttempts); + } + + return await operation(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _logger.LogInformation( + "Operation {OperationName} was cancelled during attempt {Attempt}.", + operationName, + attempt); + + throw; + } + catch (Exception ex) when (IsRetryable(ex)) + { + lastException = ex; + + if (attempt == _options.MaxAttempts) + { + _logger.LogError( + ex, + "Operation {OperationName} failed after {MaxAttempts} attempts.", + operationName, + _options.MaxAttempts); + + throw; + } + + TimeSpan delay = CalculateDelay(attempt); + + _logger.LogWarning( + ex, + "Operation {OperationName} failed with a transient error on attempt {Attempt} of {MaxAttempts}. Retrying after {DelayMs}ms.", + operationName, + attempt, + _options.MaxAttempts, + (int)delay.TotalMilliseconds); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Operation {OperationName} failed with a non-retryable error on attempt {Attempt}.", + operationName, + attempt); + + throw; + } + } + + throw new InvalidOperationException( + $"Retry policy exited unexpectedly for operation '{operationName}'.", + lastException); + } + + private bool IsRetryable(Exception exception) + { + return exception switch + { + HttpRequestException => true, + IOException => true, + TaskCanceledException => true, + InvalidDataException => false, + ArgumentException => false, + InvalidOperationException => false, + _ => false + }; + } + + private TimeSpan CalculateDelay(int attempt) + { + 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); + } + } +} \ No newline at end of file diff --git a/AlayaCore/Utilities/Enums/LauncherErrorType.cs b/AlayaCore/Utilities/Enums/LauncherErrorType.cs new file mode 100644 index 0000000..dffa93c --- /dev/null +++ b/AlayaCore/Utilities/Enums/LauncherErrorType.cs @@ -0,0 +1,7 @@ +namespace AlayaCore.Utilities.Enums +{ + public enum LauncherErrorType + { + + } +} \ No newline at end of file diff --git a/AlayaCore/Utilities/Helpers/ErrorHelper.cs b/AlayaCore/Utilities/Helpers/ErrorHelper.cs new file mode 100644 index 0000000..8ba56dd --- /dev/null +++ b/AlayaCore/Utilities/Helpers/ErrorHelper.cs @@ -0,0 +1,7 @@ +namespace AlayaCore.Utilities.Helpers +{ + public class ErrorHelper + { + + } +} \ No newline at end of file diff --git a/AlayaCore/Utilities/Helpers/OptionsHelper.cs b/AlayaCore/Utilities/Helpers/OptionsHelper.cs new file mode 100644 index 0000000..bdff83a --- /dev/null +++ b/AlayaCore/Utilities/Helpers/OptionsHelper.cs @@ -0,0 +1,7 @@ +namespace AlayaCore.Utilities.Helpers +{ + public class OptionsHelper + { + + } +} \ No newline at end of file