From 2a9a598a1bc4461d0c7b2a7350a48b072a116ec6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 9 Jul 2026 03:37:20 +0000
Subject: [PATCH 1/4] Initial plan
From 87dd1bfe50d5322aa8a49c942066311d662ebdd6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 9 Jul 2026 03:41:47 +0000
Subject: [PATCH 2/4] feat(spork): retry transient download failures with
backoff
---
.../Steps/Implementations/StepsPlayer.cs | 56 +++++++++++++++++--
.../StepsPlayerTransientDownloadTests.cs | 50 +++++++++++++++++
2 files changed, 102 insertions(+), 4 deletions(-)
create mode 100644 src/Spork.Test/StepsPlayerTransientDownloadTests.cs
diff --git a/src/Spork.App/Steps/Implementations/StepsPlayer.cs b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
index 9824f7c4..0a07de83 100644
--- a/src/Spork.App/Steps/Implementations/StepsPlayer.cs
+++ b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
@@ -4,7 +4,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.IO;
using System.Linq;
+using System.Net;
+using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using TableCloth.Resources;
@@ -37,6 +40,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 +193,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 +205,53 @@ 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) when (!(ex is OperationCanceledException) || !cancellationToken.IsCancellationRequested)
+ {
+ var hasMoreRetry = attempt < DownloadRetryDelays.Length;
+ if (!hasMoreRetry || !IsTransientDownloadException(ex))
+ throw;
+
+ await Task.Delay(DownloadRetryDelays[attempt], cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private static bool IsTransientDownloadException(Exception exception)
+ {
+ if (exception is HttpRequestException httpRequestException)
+ {
+ if (!httpRequestException.StatusCode.HasValue)
+ return true;
+
+ var statusCode = (int)httpRequestException.StatusCode.Value;
+ return statusCode == (int)HttpStatusCode.RequestTimeout ||
+ statusCode == (int)HttpStatusCode.TooManyRequests ||
+ statusCode >= 500;
+ }
+
+ if (exception is TaskCanceledException || exception is TimeoutException)
+ return true;
+
+ if (exception is IOException)
+ return true;
+
+ return exception.InnerException != null && IsTransientDownloadException(exception.InnerException);
+ }
+
///
/// 특정 Step의 다운로드 완료를 대기합니다.
///
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;
+ }
+ }
+}
From ab00027310e8a41d311d628e83da2e21e2fe617c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 9 Jul 2026 03:46:02 +0000
Subject: [PATCH 3/4] chore(spork): polish retry cancellation and status checks
---
.../Steps/Implementations/StepsPlayer.cs | 22 ++++++++++++++-----
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/src/Spork.App/Steps/Implementations/StepsPlayer.cs b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
index 0a07de83..c1b8704f 100644
--- a/src/Spork.App/Steps/Implementations/StepsPlayer.cs
+++ b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
@@ -219,13 +219,23 @@ await item.Step.LoadContentForStepAsync(
cancellationToken).ConfigureAwait(false);
return;
}
- catch (Exception ex) when (!(ex is OperationCanceledException) || !cancellationToken.IsCancellationRequested)
+ catch (Exception ex)
{
+ if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
+ throw;
+
var hasMoreRetry = attempt < DownloadRetryDelays.Length;
if (!hasMoreRetry || !IsTransientDownloadException(ex))
throw;
- await Task.Delay(DownloadRetryDelays[attempt], cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await Task.Delay(DownloadRetryDelays[attempt], cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
}
}
}
@@ -237,10 +247,10 @@ private static bool IsTransientDownloadException(Exception exception)
if (!httpRequestException.StatusCode.HasValue)
return true;
- var statusCode = (int)httpRequestException.StatusCode.Value;
- return statusCode == (int)HttpStatusCode.RequestTimeout ||
- statusCode == (int)HttpStatusCode.TooManyRequests ||
- statusCode >= 500;
+ var statusCode = httpRequestException.StatusCode.Value;
+ return statusCode == HttpStatusCode.RequestTimeout ||
+ statusCode == HttpStatusCode.TooManyRequests ||
+ (int)statusCode >= (int)HttpStatusCode.InternalServerError;
}
if (exception is TaskCanceledException || exception is TimeoutException)
From 08e3b3128d92d0af5f61d1b3d9cb4811e452f882 Mon Sep 17 00:00:00 2001
From: "Jung Hyun, Nam"
Date: Thu, 9 Jul 2026 22:38:04 +0900
Subject: [PATCH 4/4] =?UTF-8?q?test(spork):=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?=
=?UTF-8?q?=EB=93=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=B6=84=EB=A5=98=20?=
=?UTF-8?q?=EC=A0=95=EB=B0=80=ED=99=94=20+=20=EC=86=8C=EC=BC=93/=EC=83=81?=
=?UTF-8?q?=ED=83=9C=EC=BD=94=EB=93=9C/=EB=A3=A8=ED=94=84=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D=20=EB=B3=B4=EA=B0=95=20(#300)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
일시적 실패 재시도 로직의 분류 정확도를 높이고, "재시도하지 않을 종류가 실제로
재시도되지 않는지"를 루프 동작 수준까지 검증한다.
분류기(StepsPlayer.IsTransientDownloadException)
- 소켓 수준 오류를 명시적으로 처리: 래핑되지 않은 SocketException도 일시적으로 취급
(HttpClient는 보통 HttpRequestException[StatusCode 없음]/IOException으로 래핑하지만,
다른 다운로드 계층 대비).
- 상태 코드 기준 실패 판정을 정밀화: 기존 "모든 5xx 재시도"에서 재시도 대상 코드만
선별(408/429/500/502/503/504). 501(Not Implemented)·505 등 영구성 5xx는 재시도하지
않고 즉시 실패로 건너뛴다(IsRetryableStatusCode 헬퍼로 분리).
테스트(Spork.Test)
- StepsPlayerSocketFailureTests: 순수 SocketException 6종(리셋/거부/중단/타임아웃/
도달불가/DNS) + 래핑 형태가 재시도 대상으로 분류되는지 검증.
- StepsPlayerStatusCodeClassificationTests: 재시도 대상 vs 건너뜀(4xx + 영구성 5xx)
상태 코드, 그리고 비일시적 예외 타입이 건너뛰어지는지 검증.
- StepsPlayerRetryLoopTests: 실제 재시도 루프 시도 횟수 검증 — 영구성 코드/비일시적
예외는 1회(재시도 없음), 일시적 오류는 최대 3회, 중간 성공 시 즉시 중단.
빌드 경고 0, Spork.Test 42개 통과.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Steps/Implementations/StepsPlayer.cs | 28 +++-
src/Spork.Test/StepsPlayerRetryLoopTests.cs | 145 ++++++++++++++++++
.../StepsPlayerSocketFailureTests.cs | 95 ++++++++++++
...tepsPlayerStatusCodeClassificationTests.cs | 85 ++++++++++
4 files changed, 349 insertions(+), 4 deletions(-)
create mode 100644 src/Spork.Test/StepsPlayerRetryLoopTests.cs
create mode 100644 src/Spork.Test/StepsPlayerSocketFailureTests.cs
create mode 100644 src/Spork.Test/StepsPlayerStatusCodeClassificationTests.cs
diff --git a/src/Spork.App/Steps/Implementations/StepsPlayer.cs b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
index c1b8704f..f5a2d96a 100644
--- a/src/Spork.App/Steps/Implementations/StepsPlayer.cs
+++ b/src/Spork.App/Steps/Implementations/StepsPlayer.cs
@@ -8,6 +8,7 @@
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using TableCloth.Resources;
@@ -244,13 +245,13 @@ private static bool IsTransientDownloadException(Exception exception)
{
if (exception is HttpRequestException httpRequestException)
{
+ // 응답 자체를 받지 못한 경우(연결 실패 등)는 StatusCode가 없다 → 일시적으로 취급.
if (!httpRequestException.StatusCode.HasValue)
return true;
- var statusCode = httpRequestException.StatusCode.Value;
- return statusCode == HttpStatusCode.RequestTimeout ||
- statusCode == HttpStatusCode.TooManyRequests ||
- (int)statusCode >= (int)HttpStatusCode.InternalServerError;
+ // 상태 코드가 있으면 "재시도할 가치가 있는" 코드만 일시적. 그 외(4xx 대부분, 501/505 등
+ // 영구성 5xx)는 재시도하지 않고 즉시 실패로 건너뛴다.
+ return IsRetryableStatusCode(httpRequestException.StatusCode.Value);
}
if (exception is TaskCanceledException || exception is TimeoutException)
@@ -259,9 +260,28 @@ private static bool IsTransientDownloadException(Exception exception)
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));
+ }
+ }
+}