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