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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GeneralUpdate.Core.Download.Models;

namespace GeneralUpdate.Core.Download.Abstractions;

/// <summary>Executes a single file download.</summary>
public interface IDownloadExecutor
{
Task<DownloadResult> ExecuteAsync(
string url, string destPath,
IProgress<DownloadProgress>? progress = null,
CancellationToken token = default);
}

/// <summary>Retry / timeout / circuit-breaker policy for downloads.</summary>
public interface IDownloadPolicy
{
Task<T> ExecuteAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken token = default);
}
110 changes: 110 additions & 0 deletions src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// HTTP-based download executor with Range/resume support.
/// Uses the shared HttpClient from VersionService for consistent SSL/auth handling.
/// </summary>
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<DownloadResult> ExecuteAsync(
string url, string destPath,
IProgress<DownloadProgress>? 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);
}
}
}
25 changes: 25 additions & 0 deletions src/c#/GeneralUpdate.Core/Download/Models/DownloadProgress.cs
Original file line number Diff line number Diff line change
@@ -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
);
61 changes: 61 additions & 0 deletions src/c#/GeneralUpdate.Core/Download/Policy/DefaultRetryPolicy.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
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<T> ExecuteAsync<T>(Func<CancellationToken, Task<T>> 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;
}
}
Loading