From f7e3ba4196ebbdec7c7d4b55cd6ea6786bcddcb6 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Wed, 12 Aug 2026 15:39:33 +0300 Subject: [PATCH] Fix Transmission connections failing when the 409 session challenge is retried (#714) --- .../DynamicHttpClientConfigurationTests.cs | 43 ++++++++++++++ .../DynamicHttpClientConfiguration.cs | 25 +++++--- .../transmission-session-handshake.spec.ts | 58 +++++++++++++++++++ 3 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs create mode 100644 e2e/tests/download-cleaner/transmission-session-handshake.spec.ts diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs new file mode 100644 index 00000000..1e595e4f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientConfigurationTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Http; + +public sealed class DynamicHttpClientConfigurationTests +{ + [Fact] + public void IsRetryable_ShouldReturnFalse_WhenResponseIsConflict() + { + using HttpResponseMessage response = new(HttpStatusCode.Conflict); + + DynamicHttpClientConfiguration.IsRetryable(response, excludeUnauthorized: true).ShouldBeFalse(); + DynamicHttpClientConfiguration.IsRetryable(response, excludeUnauthorized: false).ShouldBeFalse(); + } + + [Fact] + public void IsRetryable_ShouldReturnFalse_WhenResponseIsSuccessful() + { + using HttpResponseMessage response = new(HttpStatusCode.OK); + + DynamicHttpClientConfiguration.IsRetryable(response, excludeUnauthorized: true).ShouldBeFalse(); + } + + [Fact] + public void IsRetryable_ShouldRespectExcludeUnauthorized() + { + using HttpResponseMessage response = new(HttpStatusCode.Unauthorized); + + DynamicHttpClientConfiguration.IsRetryable(response, excludeUnauthorized: true).ShouldBeFalse(); + DynamicHttpClientConfiguration.IsRetryable(response, excludeUnauthorized: false).ShouldBeTrue(); + } + + [Fact] + public void IsRetryable_ShouldReturnTrue_WhenResponseIsServerError() + { + using HttpResponseMessage response = new(HttpStatusCode.InternalServerError); + + DynamicHttpClientConfiguration.IsRetryable(response, excludeUnauthorized: true).ShouldBeTrue(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs index b2111c40..f6635d0a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientConfiguration.cs @@ -103,15 +103,7 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions - !response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Unauthorized); - } - else - { - retryPolicy = retryPolicy.OrResult(response => !response.IsSuccessStatusCode); - } + retryPolicy = retryPolicy.OrResult(response => IsRetryable(response, retryConfig.ExcludeUnauthorized)); var policy = retryPolicy.WaitAndRetryAsync( retryConfig.MaxRetries, @@ -121,6 +113,21 @@ public class DynamicHttpClientConfiguration : IConfigureNamedOptions { + return { + enabled: true, + name: 'Transmission handshake e2e', + typeName: transmission.typeName, + type: 'Torrent', + host: transmission.cleanuparrHost, + username: transmission.username, + password: transmission.password, + }; +} + +test.describe.serial('Transmission session handshake', () => { + let token: string; + let originalGeneralConfig: Record; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + await transmission.ready(); + + originalGeneralConfig = await getGeneralConfig(token); + await updateGeneralConfig(token, { + ...originalGeneralConfig, + httpTimeout: HTTP_TIMEOUT_SECONDS, + httpMaxRetries: HTTP_MAX_RETRIES, + }); + }); + + test.afterAll(async () => { + await updateGeneralConfig(token, originalGeneralConfig).catch(() => {}); + }); + + test('connects without spending the request timeout on 409 retries', async () => { + const startedAt = Date.now(); + const res = await testDownloadClient(token, payload()); + const elapsedMs = Date.now() - startedAt; + + expect(res.ok, `test connection failed: ${res.status} ${await res.text()}`).toBe(true); + expect( + elapsedMs, + `handshake took ${elapsedMs}ms: the 409 session challenge is being retried with backoff`, + ).toBeLessThan(5_000); + }); +});