diff --git a/src/Spork.App/Steps/Implementations/StepsPlayer.cs b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
index 9824f7c4..f5a2d96a 100644
--- a/src/Spork.App/Steps/Implementations/StepsPlayer.cs
+++ b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
@@ -4,7 +4,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.IO;
using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using TableCloth.Resources;
@@ -37,6 +41,7 @@ public StepsPlayer(
private const double PreparationProgress = 33.0;
private const double LoadingProgress = 66.0;
private const double PerformingProgress = 100.0;
+ private static readonly TimeSpan[] DownloadRetryDelays = [TimeSpan.FromSeconds(1d), TimeSpan.FromSeconds(3d)];
// 현재 단계의 진행률을 업데이트합니다. value는 0에서 1 사이여야 합니다.
private double CalculateProgressRate(int stage, double value)
@@ -189,10 +194,7 @@ private async Task DownloadContentAsync(
}
else
{
- await item.Step.LoadContentForStepAsync(
- item.Argument,
- (value) => item.ProgressRate = CalculateProgressRate(2, value),
- cancellationToken).ConfigureAwait(false);
+ await LoadStepContentWithRetryAsync(item, cancellationToken).ConfigureAwait(false);
}
item.IsContentLoaded = true;
@@ -204,6 +206,82 @@ await item.Step.LoadContentForStepAsync(
}
}
+ private async Task LoadStepContentWithRetryAsync(
+ StepItemViewModel item,
+ CancellationToken cancellationToken)
+ {
+ for (var attempt = 0; ; attempt++)
+ {
+ try
+ {
+ await item.Step.LoadContentForStepAsync(
+ item.Argument,
+ (value) => item.ProgressRate = CalculateProgressRate(2, value),
+ cancellationToken).ConfigureAwait(false);
+ return;
+ }
+ catch (Exception ex)
+ {
+ if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
+ throw;
+
+ var hasMoreRetry = attempt < DownloadRetryDelays.Length;
+ if (!hasMoreRetry || !IsTransientDownloadException(ex))
+ throw;
+
+ try
+ {
+ await Task.Delay(DownloadRetryDelays[attempt], cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ }
+ }
+ }
+
+ private static bool IsTransientDownloadException(Exception exception)
+ {
+ if (exception is HttpRequestException httpRequestException)
+ {
+ // 응답 자체를 받지 못한 경우(연결 실패 등)는 StatusCode가 없다 → 일시적으로 취급.
+ if (!httpRequestException.StatusCode.HasValue)
+ return true;
+
+ // 상태 코드가 있으면 "재시도할 가치가 있는" 코드만 일시적. 그 외(4xx 대부분, 501/505 등
+ // 영구성 5xx)는 재시도하지 않고 즉시 실패로 건너뛴다.
+ return IsRetryableStatusCode(httpRequestException.StatusCode.Value);
+ }
+
+ if (exception is TaskCanceledException || exception is TimeoutException)
+ return true;
+
+ if (exception is IOException)
+ return true;
+
+ // 소켓 수준 오류(연결 거부·리셋·타임아웃, DNS 해석 실패 등). HttpClient는 대개 이를
+ // HttpRequestException(StatusCode 없음)이나 IOException으로 래핑하지만, 래핑되지 않은 순수
+ // SocketException이 올라오는 경로(다른 다운로드 계층/핸들러)도 일시적으로 취급한다.
+ if (exception is SocketException)
+ return true;
+
+ return exception.InnerException != null && IsTransientDownloadException(exception.InnerException);
+ }
+
+ ///
+ /// 재시도할 가치가 있는(일시적) HTTP 상태 코드인지 여부. 일시적인 서버측 오류/과부하만 true로 하고,
+ /// 요청 자체의 문제(4xx 대부분)나 영구성 서버 오류(501 Not Implemented, 505 등)는 false로 하여
+ /// 재시도하지 않고 건너뛴다.
+ ///
+ private static bool IsRetryableStatusCode(HttpStatusCode statusCode)
+ => statusCode == HttpStatusCode.RequestTimeout // 408
+ || statusCode == HttpStatusCode.TooManyRequests // 429
+ || statusCode == HttpStatusCode.InternalServerError // 500
+ || statusCode == HttpStatusCode.BadGateway // 502
+ || statusCode == HttpStatusCode.ServiceUnavailable // 503
+ || statusCode == HttpStatusCode.GatewayTimeout; // 504
+
///
/// 특정 Step의 다운로드 완료를 대기합니다.
///
diff --git a/src/Spork.Test/StepsPlayerRetryLoopTests.cs b/src/Spork.Test/StepsPlayerRetryLoopTests.cs
new file mode 100644
index 00000000..4ef05726
--- /dev/null
+++ b/src/Spork.Test/StepsPlayerRetryLoopTests.cs
@@ -0,0 +1,145 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Spork.Steps;
+using Spork.Steps.Implementations;
+using Spork.ViewModels;
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Spork.Test
+{
+ ///
+ /// 재시도 루프(LoadStepContentWithRetryAsync) 자체의 동작을 검증한다. 특히 "영구성(비일시적) 오류는
+ /// 실패 후 재시도하지 않고 1회 시도로 끝나는지", "일시적 오류는 최대 2회까지 재시도(총 3회)하는지",
+ /// "중간에 성공하면 멈추는지"를 실제 호출 횟수로 확인한다.
+ /// (생성자 의존성이 필요 없는 메서드라 GetUninitializedObject로 인스턴스를 만들어 리플렉션으로 호출한다.
+ /// 실제 1s/3s 지연은 테스트 속도를 위해 리플렉션으로 0으로 바꿔 두고 종료 시 복원한다.)
+ ///
+ [TestClass]
+ public class StepsPlayerRetryLoopTests
+ {
+ private static readonly FieldInfo DownloadRetryDelaysField = typeof(StepsPlayer)
+ .GetField("DownloadRetryDelays", BindingFlags.NonPublic | BindingFlags.Static)!;
+ private static readonly MethodInfo LoadStepContentWithRetryAsyncMethod = typeof(StepsPlayer)
+ .GetMethod("LoadStepContentWithRetryAsync", BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ private TimeSpan[] _originalDelays = Array.Empty();
+
+ [TestInitialize]
+ public void ZeroOutRetryDelays()
+ {
+ // readonly는 필드 참조만 보호하므로 배열 원소는 수정 가능하다. 지연을 0으로 만들어 테스트를 빠르게 한다.
+ var delays = (TimeSpan[])DownloadRetryDelaysField.GetValue(null)!;
+ _originalDelays = (TimeSpan[])delays.Clone();
+ for (var i = 0; i < delays.Length; i++)
+ delays[i] = TimeSpan.Zero;
+ }
+
+ [TestCleanup]
+ public void RestoreRetryDelays()
+ {
+ var delays = (TimeSpan[])DownloadRetryDelaysField.GetValue(null)!;
+ for (var i = 0; i < delays.Length; i++)
+ delays[i] = _originalDelays[i];
+ }
+
+ // 호출 횟수를 세고, 시도 번호(0-based)에 따라 원하는 결과(faulted/완료)를 돌려주는 fake Step.
+ private sealed class CountingStep : IStep
+ {
+ private readonly Func _behavior;
+ public int InvocationCount { get; private set; }
+
+ public CountingStep(Func behavior) => _behavior = behavior;
+
+ public Task LoadContentForStepAsync(InstallItemViewModel viewModel, Action progressCallback, CancellationToken cancellationToken = default)
+ => _behavior(InvocationCount++);
+
+ public Task EvaluateRequiredStepAsync(InstallItemViewModel viewModel, CancellationToken cancellationToken = default)
+ => Task.FromResult(true);
+
+ public Task PlayStepAsync(InstallItemViewModel viewModel, Action progressCallback, CancellationToken cancellationToken = default)
+ => Task.CompletedTask;
+
+ public bool ShouldSimulateWhenDryRun => false;
+ }
+
+ private static async Task InvokeRetryLoopAsync(StepItemViewModel item)
+ {
+ var player = (StepsPlayer)RuntimeHelpers.GetUninitializedObject(typeof(StepsPlayer));
+ var task = (Task)LoadStepContentWithRetryAsyncMethod.Invoke(player, [item, CancellationToken.None])!;
+ await task;
+ }
+
+ private static StepItemViewModel CreateItem(CountingStep step)
+ => new StepItemViewModel { Step = step, Argument = new InstallItemViewModel() };
+
+ private static Task Fail(HttpStatusCode statusCode)
+ => Task.FromException(new HttpRequestException("simulated", null, statusCode));
+
+ // ── 영구성 상태 코드: 실패 후 재시도하지 않고 1회 시도로 끝난다 ──
+ [TestMethod]
+ public async Task PermanentStatusCode_501_DoesNotRetry_SingleAttempt()
+ {
+ var step = new CountingStep(_ => Fail(HttpStatusCode.NotImplemented)); // 501
+ var item = CreateItem(step);
+
+ Exception? caught = null;
+ try { await InvokeRetryLoopAsync(item); }
+ catch (Exception ex) { caught = ex; }
+
+ Assert.IsNotNull(caught, "영구성 오류는 최종적으로 예외로 전파되어야 한다.");
+ Assert.IsTrue(caught is HttpRequestException);
+ Assert.AreEqual(1, step.InvocationCount, "영구성 상태 코드는 재시도하지 않아야 한다(1회).");
+ }
+
+ // ── 비일시적 예외 타입: 재시도하지 않고 1회 시도로 끝난다 ──
+ [TestMethod]
+ public async Task NonTransientException_DoesNotRetry_SingleAttempt()
+ {
+ var step = new CountingStep(_ => Task.FromException(new InvalidOperationException("permanent")));
+ var item = CreateItem(step);
+
+ Exception? caught = null;
+ try { await InvokeRetryLoopAsync(item); }
+ catch (Exception ex) { caught = ex; }
+
+ Assert.IsNotNull(caught);
+ Assert.IsTrue(caught is InvalidOperationException);
+ Assert.AreEqual(1, step.InvocationCount, "비일시적 예외는 재시도하지 않아야 한다(1회).");
+ }
+
+ // ── 일시적 상태 코드가 계속 실패: 총 3회(초기 + 재시도 2회) 시도 후 예외 ──
+ [TestMethod]
+ public async Task TransientStatusCode_AlwaysFails_RetriesUpToThreeAttempts()
+ {
+ var step = new CountingStep(_ => Fail(HttpStatusCode.ServiceUnavailable)); // 503
+ var item = CreateItem(step);
+
+ Exception? caught = null;
+ try { await InvokeRetryLoopAsync(item); }
+ catch (Exception ex) { caught = ex; }
+
+ Assert.IsNotNull(caught, "재시도 소진 후에는 마지막 예외가 전파되어야 한다.");
+ Assert.IsTrue(caught is HttpRequestException);
+ Assert.AreEqual(3, step.InvocationCount, "일시적 오류는 초기 1회 + 재시도 2회 = 총 3회 시도해야 한다.");
+ }
+
+ // ── 일시적 실패 후 중간에 성공: 성공하면 더 이상 시도하지 않는다 ──
+ [TestMethod]
+ public async Task TransientThenSuccess_StopsRetrying_OnFirstSuccess()
+ {
+ var step = new CountingStep(attempt => attempt == 0
+ ? Fail(HttpStatusCode.ServiceUnavailable) // 첫 시도 실패(일시적)
+ : Task.CompletedTask); // 두 번째 시도 성공
+ var item = CreateItem(step);
+
+ await InvokeRetryLoopAsync(item); // 예외 없이 반환되어야 한다.
+
+ Assert.AreEqual(2, step.InvocationCount, "첫 재시도에서 성공하면 총 2회 시도로 끝나야 한다.");
+ }
+ }
+}
diff --git a/src/Spork.Test/StepsPlayerSocketFailureTests.cs b/src/Spork.Test/StepsPlayerSocketFailureTests.cs
new file mode 100644
index 00000000..d84a1850
--- /dev/null
+++ b/src/Spork.Test/StepsPlayerSocketFailureTests.cs
@@ -0,0 +1,95 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Spork.Steps.Implementations;
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Reflection;
+
+namespace Spork.Test
+{
+ ///
+ /// 소켓/연결 수준 다운로드 실패(연결 거부·리셋, 스트림 중간 끊김 등)가 일시적 오류로 분류되어
+ /// 재시도 대상이 되는지 검증한다. Spork의 실제 다운로드 경로(PackageInstallStep → HttpClient.GetAsync
+ /// + ReadAsStreamAsync)에서 소켓 오류는 아래 형태로 표면화되므로 그 형태를 그대로 재현한다.
+ /// - 연결 수립 단계 실패 → HttpRequestException (StatusCode 없음), 내부는 SocketException
+ /// - 다운로드(스트리밍) 끊김 → IOException (내부는 SocketException) / .NET 8+ HttpIOException : IOException
+ ///
+ [TestClass]
+ public class StepsPlayerSocketFailureTests
+ {
+ private static readonly MethodInfo IsTransientDownloadExceptionMethod = typeof(StepsPlayer)
+ .GetMethod("IsTransientDownloadException", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static bool IsTransient(Exception exception)
+ {
+ Assert.IsNotNull(IsTransientDownloadExceptionMethod);
+ var result = IsTransientDownloadExceptionMethod.Invoke(null, [exception]);
+ Assert.IsNotNull(result);
+ return (bool)result;
+ }
+
+ // ── 연결 수립 단계: HttpClient는 소켓 실패를 StatusCode 없는 HttpRequestException으로 던진다 ──
+
+ [TestMethod]
+ public void ConnectionFailure_HttpRequestExceptionWithoutStatusCode_IsTransient()
+ {
+ // 연결 거부/DNS 실패 등 HTTP 응답 자체를 받지 못한 경우 StatusCode == null.
+ var exception = new HttpRequestException("No such host is known.");
+ Assert.IsTrue(IsTransient(exception));
+ }
+
+ [TestMethod]
+ public void ConnectionReset_HttpRequestExceptionWrappingSocketException_IsTransient()
+ {
+ // 실제 HttpClient가 연결 리셋을 감싸는 형태: StatusCode 없음 + inner SocketException.
+ var socket = new SocketException((int)SocketError.ConnectionReset);
+ var exception = new HttpRequestException("connection reset by peer", socket);
+ Assert.IsTrue(IsTransient(exception));
+ }
+
+ // ── 다운로드(스트리밍) 중간 끊김: IOException으로 표면화 ──
+
+ [TestMethod]
+ public void MidStreamDrop_IOException_IsTransient()
+ {
+ var exception = new IOException("Unable to read data from the transport connection.");
+ Assert.IsTrue(IsTransient(exception));
+ }
+
+ [TestMethod]
+ public void MidStreamDrop_IOExceptionWrappingSocketException_IsTransient()
+ {
+ // "An existing connection was forcibly closed by the remote host." (WSAECONNABORTED)
+ var socket = new SocketException((int)SocketError.ConnectionAborted);
+ var exception = new IOException("forcibly closed", socket);
+ Assert.IsTrue(IsTransient(exception));
+ }
+
+ // ── 재귀적 InnerException 검사: 분류 대상이 아닌 래퍼가 일시적 내부 예외를 감싸도 잡힌다 ──
+
+ [TestMethod]
+ public void WrappedTransientInner_ViaRecursion_IsTransient()
+ {
+ // 겉은 분류 대상이 아니지만(Exception) 내부가 IOException → 재귀로 true.
+ var exception = new Exception("aggregate-like wrapper", new IOException("socket closed mid-stream"));
+ Assert.IsTrue(IsTransient(exception));
+ }
+
+ // ── 래핑되지 않은 순수 SocketException도 명시 검사로 일시적으로 취급된다 ──
+ // (HttpClient는 보통 래핑하지만, 다른 다운로드 계층/핸들러가 순수 SocketException을 던져도 재시도.)
+
+ [TestMethod]
+ [DataRow((int)SocketError.ConnectionReset)] // 연결 리셋
+ [DataRow((int)SocketError.ConnectionRefused)] // 연결 거부
+ [DataRow((int)SocketError.ConnectionAborted)] // 연결 강제 종료
+ [DataRow((int)SocketError.TimedOut)] // 타임아웃
+ [DataRow((int)SocketError.HostUnreachable)] // 호스트 도달 불가
+ [DataRow((int)SocketError.HostNotFound)] // DNS 해석 실패
+ public void BareSocketException_IsTransient(int socketErrorCode)
+ {
+ var exception = new SocketException(socketErrorCode);
+ Assert.IsTrue(IsTransient(exception));
+ }
+ }
+}
diff --git a/src/Spork.Test/StepsPlayerStatusCodeClassificationTests.cs b/src/Spork.Test/StepsPlayerStatusCodeClassificationTests.cs
new file mode 100644
index 00000000..d44d1c9b
--- /dev/null
+++ b/src/Spork.Test/StepsPlayerStatusCodeClassificationTests.cs
@@ -0,0 +1,85 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Spork.Steps.Implementations;
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+
+namespace Spork.Test
+{
+ ///
+ /// HTTP 상태 코드 기준 재시도 판정과, "재시도하지 않을 종류"(비일시적)가 잘 건너뛰어지는지를 검증한다.
+ /// 재시도 대상: 408/429 + 일시적 5xx(500/502/503/504). 건너뜀: 4xx 대부분 + 영구성 5xx(501/505 등)
+ /// 및 요청/구성 오류성 예외 타입.
+ ///
+ [TestClass]
+ public class StepsPlayerStatusCodeClassificationTests
+ {
+ private static readonly MethodInfo IsTransientDownloadExceptionMethod = typeof(StepsPlayer)
+ .GetMethod("IsTransientDownloadException", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ private static bool IsTransient(Exception exception)
+ {
+ Assert.IsNotNull(IsTransientDownloadExceptionMethod);
+ var result = IsTransientDownloadExceptionMethod.Invoke(null, [exception]);
+ Assert.IsNotNull(result);
+ return (bool)result;
+ }
+
+ // ── 재시도 대상 상태 코드 ──
+ [TestMethod]
+ [DataRow(408)] // Request Timeout
+ [DataRow(429)] // Too Many Requests
+ [DataRow(500)] // Internal Server Error
+ [DataRow(502)] // Bad Gateway
+ [DataRow(503)] // Service Unavailable
+ [DataRow(504)] // Gateway Timeout
+ public void RetryableStatusCode_IsTransient(int statusCode)
+ {
+ var exception = new HttpRequestException("server-side failure", null, (HttpStatusCode)statusCode);
+ Assert.IsTrue(IsTransient(exception));
+ }
+
+ // ── 재시도하지 않고 건너뛸 상태 코드(4xx 대부분 + 영구성 5xx) ──
+ [TestMethod]
+ [DataRow(400)] // Bad Request
+ [DataRow(401)] // Unauthorized
+ [DataRow(403)] // Forbidden
+ [DataRow(404)] // Not Found
+ [DataRow(405)] // Method Not Allowed
+ [DataRow(409)] // Conflict
+ [DataRow(410)] // Gone
+ [DataRow(501)] // Not Implemented (영구성 5xx → 재시도 안 함)
+ [DataRow(505)] // HTTP Version Not Supported (영구성 5xx)
+ public void NonRetryableStatusCode_IsSkipped(int statusCode)
+ {
+ var exception = new HttpRequestException("permanent/client failure", null, (HttpStatusCode)statusCode);
+ Assert.IsFalse(IsTransient(exception));
+ }
+
+ // ── 재시도하지 않을 예외 타입들이 잘 건너뛰어지는지 ──
+ [TestMethod]
+ public void InvalidOperationException_IsSkipped()
+ => Assert.IsFalse(IsTransient(new InvalidOperationException("permanent")));
+
+ [TestMethod]
+ public void ArgumentException_IsSkipped()
+ => Assert.IsFalse(IsTransient(new ArgumentException("bad argument")));
+
+ [TestMethod]
+ public void NotSupportedException_IsSkipped()
+ => Assert.IsFalse(IsTransient(new NotSupportedException("unsupported")));
+
+ [TestMethod]
+ public void UnauthorizedAccessException_IsSkipped()
+ => Assert.IsFalse(IsTransient(new UnauthorizedAccessException("denied")));
+
+ // 비일시적 예외가 비일시적 예외를 감싸는 경우에도 재귀 결과가 false여서 건너뛰어져야 한다.
+ [TestMethod]
+ public void NonTransientWrappingNonTransient_IsSkipped()
+ {
+ var exception = new InvalidOperationException("outer", new ArgumentException("inner"));
+ Assert.IsFalse(IsTransient(exception));
+ }
+ }
+}
diff --git a/src/Spork.Test/StepsPlayerTransientDownloadTests.cs b/src/Spork.Test/StepsPlayerTransientDownloadTests.cs
new file mode 100644
index 00000000..720cd256
--- /dev/null
+++ b/src/Spork.Test/StepsPlayerTransientDownloadTests.cs
@@ -0,0 +1,50 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Spork.Steps.Implementations;
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+
+namespace Spork.Test
+{
+ [TestClass]
+ public class StepsPlayerTransientDownloadTests
+ {
+ private static readonly MethodInfo IsTransientDownloadExceptionMethod = typeof(StepsPlayer)
+ .GetMethod("IsTransientDownloadException", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ [TestMethod]
+ public void IsTransientDownloadException_ShouldReturnTrue_For503HttpRequestException()
+ {
+ var exception = new HttpRequestException("server error", null, HttpStatusCode.ServiceUnavailable);
+ Assert.IsTrue(Invoke(exception));
+ }
+
+ [TestMethod]
+ public void IsTransientDownloadException_ShouldReturnFalse_For404HttpRequestException()
+ {
+ var exception = new HttpRequestException("not found", null, HttpStatusCode.NotFound);
+ Assert.IsFalse(Invoke(exception));
+ }
+
+ [TestMethod]
+ public void IsTransientDownloadException_ShouldReturnTrue_ForTaskCanceledException()
+ {
+ Assert.IsTrue(Invoke(new TaskCanceledException("timeout")));
+ }
+
+ [TestMethod]
+ public void IsTransientDownloadException_ShouldReturnFalse_ForInvalidOperationException()
+ {
+ Assert.IsFalse(Invoke(new InvalidOperationException("permanent failure")));
+ }
+
+ private static bool Invoke(Exception exception)
+ {
+ Assert.IsNotNull(IsTransientDownloadExceptionMethod);
+ var result = IsTransientDownloadExceptionMethod.Invoke(null, [exception]);
+ Assert.IsNotNull(result);
+ return (bool)result;
+ }
+ }
+}