diff --git a/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadExecutor.cs b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadExecutor.cs new file mode 100644 index 00000000..a84c1f55 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadExecutor.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Download.Models; + +namespace GeneralUpdate.Core.Download.Abstractions; + +/// Executes a single file download. +public interface IDownloadExecutor +{ + Task ExecuteAsync( + string url, string destPath, + IProgress? progress = null, + CancellationToken token = default); +} + +/// Retry / timeout / circuit-breaker policy for downloads. +public interface IDownloadPolicy +{ + Task ExecuteAsync(Func> action, CancellationToken token = default); +} diff --git a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs new file mode 100644 index 00000000..6774fd7c --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs @@ -0,0 +1,110 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Models; + +namespace GeneralUpdate.Core.Download.Executors; + +/// +/// HTTP-based download executor with Range/resume support. +/// Uses the shared HttpClient from VersionService for consistent SSL/auth handling. +/// +public class HttpDownloadExecutor : IDownloadExecutor +{ + private readonly HttpClient _client; + private readonly TimeSpan _timeout; + + public HttpDownloadExecutor(HttpClient client, TimeSpan? timeout = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _timeout = timeout ?? TimeSpan.FromSeconds(30); + } + + public async Task ExecuteAsync( + string url, string destPath, + IProgress? progress = null, + CancellationToken token = default) + { + var sw = Stopwatch.StartNew(); + int retries = 0; + long totalBytes = -1; + long existingBytes = 0; + + // Check for existing partial file (resume support) + if (File.Exists(destPath)) + { + existingBytes = new FileInfo(destPath).Length; + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + + // Request resume from existing position + if (existingBytes > 0) + request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(existingBytes, null); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + cts.CancelAfter(_timeout); + + using var response = await _client.SendAsync( + request, HttpCompletionOption.ResponseHeadersRead, cts.Token) + .ConfigureAwait(false); + + // If server doesn't support Range, discard partial file + if (existingBytes > 0 && response.StatusCode != System.Net.HttpStatusCode.PartialContent) + { + existingBytes = 0; + File.Delete(destPath); + } + + response.EnsureSuccessStatusCode(); + totalBytes = response.Content.Headers.ContentLength ?? -1; + + // Append or create file + var mode = existingBytes > 0 ? FileMode.Append : FileMode.Create; + using var fs = new FileStream(destPath, mode, FileAccess.Write, FileShare.None); + using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + var buffer = new byte[8192]; + long downloaded = existingBytes; + int read; + long lastReport = 0; + + while ((read = await stream.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false)) > 0) + { + await fs.WriteAsync(buffer, 0, read, token).ConfigureAwait(false); + downloaded += read; + + // Report progress every ~250ms + var now = sw.ElapsedMilliseconds; + if (now - lastReport >= 250 || downloaded == totalBytes + existingBytes) + { + lastReport = now; + var pct = totalBytes > 0 ? (double)downloaded / (totalBytes + existingBytes) * 100 : -1; + progress?.Report(new DownloadProgress( + Path.GetFileName(destPath), downloaded, + totalBytes > 0 ? totalBytes + existingBytes : null, + pct, DownloadStatus.Downloading)); + } + } + + sw.Stop(); + progress?.Report(new DownloadProgress( + Path.GetFileName(destPath), downloaded, + totalBytes > 0 ? totalBytes + existingBytes : null, + 100, DownloadStatus.Completed)); + + return new DownloadResult(url, destPath, downloaded, sw.Elapsed, retries, true, null); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + sw.Stop(); + return new DownloadResult(url, null, existingBytes, sw.Elapsed, retries, false, ex.Message); + } + } +} diff --git a/src/c#/GeneralUpdate.Core/Download/Models/DownloadProgress.cs b/src/c#/GeneralUpdate.Core/Download/Models/DownloadProgress.cs new file mode 100644 index 00000000..4c778607 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Models/DownloadProgress.cs @@ -0,0 +1,25 @@ +using System; + +namespace GeneralUpdate.Core.Download.Models; + +public enum DownloadStatus { Pending, Downloading, Completed, Failed, Retrying } + +public enum DownloadPriority { Low = 0, Normal = 1, High = 2 } + +public record DownloadProgress( + string? AssetName, + long BytesDownloaded, + long? TotalBytes, + double Percentage, + DownloadStatus Status +); + +public record DownloadResult( + string? Url, + string? LocalPath, + long DownloadedBytes, + TimeSpan Duration, + int RetryCount, + bool Success, + string? ErrorMessage +); diff --git a/src/c#/GeneralUpdate.Core/Download/Policy/DefaultRetryPolicy.cs b/src/c#/GeneralUpdate.Core/Download/Policy/DefaultRetryPolicy.cs new file mode 100644 index 00000000..93f74e5b --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Policy/DefaultRetryPolicy.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core; + +namespace GeneralUpdate.Core.Download.Policy; + +/// +/// Exponential backoff retry policy for downloads. +/// Retries on transient failures (timeout, network I/O, 5xx server errors). +/// Does NOT retry on permanent failures (4xx client errors, SSL/auth). +/// +public class DefaultRetryPolicy : IDownloadPolicy +{ + private readonly int _maxRetries; + private readonly TimeSpan _initialDelay; + private readonly double _backoffMultiplier; + + public DefaultRetryPolicy(int maxRetries = 3, TimeSpan? initialDelay = null, double backoffMultiplier = 2.0) + { + _maxRetries = maxRetries; + _initialDelay = initialDelay ?? TimeSpan.FromSeconds(1); + _backoffMultiplier = backoffMultiplier; + } + + public async Task ExecuteAsync(Func> action, CancellationToken token = default) + { + for (int attempt = 0; ; attempt++) + { + try + { + return await action(token).ConfigureAwait(false); + } + catch (Exception ex) when (attempt < _maxRetries - 1 && IsRetryable(ex)) + { + GeneralTracer.Warn($"Download attempt {attempt + 1}/{_maxRetries} failed, retrying. {ex.Message}"); + var delay = TimeSpan.FromMilliseconds(_initialDelay.TotalMilliseconds * Math.Pow(_backoffMultiplier, attempt)); + await Task.Delay(delay, token).ConfigureAwait(false); + } + } + } + + private static bool IsRetryable(Exception ex) + { + if (ex is OperationCanceledException) return false; + if (ex is TaskCanceledException or TimeoutException) return true; + if (ex is IOException) return true; + if (ex is HttpRequestException hre) + { + var s = hre.Message ?? ""; + return s.Contains("timeout", StringComparison.OrdinalIgnoreCase) + || s.Contains("timed out", StringComparison.OrdinalIgnoreCase) + || s.Contains("500") || s.Contains("502") + || s.Contains("503") || s.Contains("504"); + } + return false; + } +}