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;
+ }
+}