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
86 changes: 82 additions & 4 deletions src/Spork.App/Steps/Implementations/StepsPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

/// <summary>
/// 재시도할 가치가 있는(일시적) HTTP 상태 코드인지 여부. 일시적인 서버측 오류/과부하만 true로 하고,
/// 요청 자체의 문제(4xx 대부분)나 영구성 서버 오류(501 Not Implemented, 505 등)는 false로 하여
/// 재시도하지 않고 건너뛴다.
/// </summary>
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

/// <summary>
/// 특정 Step의 다운로드 완료를 대기합니다.
/// </summary>
Expand Down
145 changes: 145 additions & 0 deletions src/Spork.Test/StepsPlayerRetryLoopTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 재시도 루프(LoadStepContentWithRetryAsync) 자체의 동작을 검증한다. 특히 "영구성(비일시적) 오류는
/// 실패 후 재시도하지 않고 1회 시도로 끝나는지", "일시적 오류는 최대 2회까지 재시도(총 3회)하는지",
/// "중간에 성공하면 멈추는지"를 실제 호출 횟수로 확인한다.
/// (생성자 의존성이 필요 없는 메서드라 GetUninitializedObject로 인스턴스를 만들어 리플렉션으로 호출한다.
/// 실제 1s/3s 지연은 테스트 속도를 위해 리플렉션으로 0으로 바꿔 두고 종료 시 복원한다.)
/// </summary>
[TestClass]
public class StepsPlayerRetryLoopTests
Comment on lines +22 to +23
{
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<TimeSpan>();

[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<int, Task> _behavior;
public int InvocationCount { get; private set; }

public CountingStep(Func<int, Task> behavior) => _behavior = behavior;

public Task LoadContentForStepAsync(InstallItemViewModel viewModel, Action<double> progressCallback, CancellationToken cancellationToken = default)
=> _behavior(InvocationCount++);

public Task<bool> EvaluateRequiredStepAsync(InstallItemViewModel viewModel, CancellationToken cancellationToken = default)
=> Task.FromResult(true);

public Task PlayStepAsync(InstallItemViewModel viewModel, Action<double> 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회 시도로 끝나야 한다.");
}
}
}
95 changes: 95 additions & 0 deletions src/Spork.Test/StepsPlayerSocketFailureTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 소켓/연결 수준 다운로드 실패(연결 거부·리셋, 스트림 중간 끊김 등)가 일시적 오류로 분류되어
/// 재시도 대상이 되는지 검증한다. Spork의 실제 다운로드 경로(PackageInstallStep → HttpClient.GetAsync
/// + ReadAsStreamAsync)에서 소켓 오류는 아래 형태로 표면화되므로 그 형태를 그대로 재현한다.
/// - 연결 수립 단계 실패 → HttpRequestException (StatusCode 없음), 내부는 SocketException
/// - 다운로드(스트리밍) 끊김 → IOException (내부는 SocketException) / .NET 8+ HttpIOException : IOException
/// </summary>
[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));
}
}
}
Loading
Loading