From b1787cbc36d44d7feccd7e9adfadf739a56cc190 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 27 Sep 2026 16:30:42 -0400 Subject: Backport pull request #18095 from jellyfin/release-12.z Retry MusicBrainz requests when the server is busy Original-merge: 1478011dfc67c46d52d22b89a49e4519ec42bdb8 Merged-by: crobibero Backported-by: Cody Robibero --- .../Music/MusicBrainzQueryExtensionsTests.cs | 136 +++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs (limited to 'tests') diff --git a/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs new file mode 100644 index 0000000000..6b0d3a2ab2 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Music/MusicBrainzQueryExtensionsTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Providers.Plugins.MusicBrainz; +using MetaBrainz.Common; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Providers.Tests.Music; + +public static class MusicBrainzQueryExtensionsTests +{ + [Fact] + public static async Task RetryOnTransientErrorAsync_ServerBusy_RetriesAndSucceeds() + { + var attempts = 0; + + var result = await MusicBrainzQueryExtensions.RetryOnTransientErrorAsync( + async _ => + { + attempts++; + if (attempts == 1) + { + throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + } + + return "found"; + }, + "test", + NullLogger.Instance, + CancellationToken.None); + + Assert.Equal("found", result); + Assert.Equal(2, attempts); + } + + [Fact] + public static async Task RetryOnTransientErrorAsync_ServerStaysBusy_GivesUp() + { + var attempts = 0; + + var error = await Assert.ThrowsAsync(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync( + async _ => + { + attempts++; + throw await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + }, + "test", + NullLogger.Instance, + CancellationToken.None)); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, error.Status); + Assert.Equal(3, attempts); + } + + [Fact] + public static async Task RetryOnTransientErrorAsync_NotFound_DoesNotRetry() + { + var attempts = 0; + + await Assert.ThrowsAsync(() => MusicBrainzQueryExtensions.RetryOnTransientErrorAsync( + async _ => + { + attempts++; + throw await CreateErrorAsync(HttpStatusCode.NotFound, null); + }, + "test", + NullLogger.Instance, + CancellationToken.None)); + + Assert.Equal(1, attempts); + } + + [Fact] + public static async Task GetRetryDelay_NoHint_BacksOffExponentially() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, null); + + Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + Assert.Equal(TimeSpan.FromSeconds(2), MusicBrainzQueryExtensions.GetRetryDelay(error, 2)); + } + + [Fact] + public static async Task GetRetryDelay_RetryAfterZero_WaitsMinimum() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.Zero); + + Assert.Equal(TimeSpan.FromSeconds(1), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_RetryAfterLongerThanBackoff_UsesRetryAfter() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromSeconds(10)); + + Assert.Equal(TimeSpan.FromSeconds(10), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_LongRetryAfter_IsCapped() + { + var error = await CreateErrorAsync(HttpStatusCode.ServiceUnavailable, TimeSpan.FromHours(1)); + + Assert.Equal(TimeSpan.FromSeconds(15), MusicBrainzQueryExtensions.GetRetryDelay(error, 1)); + } + + [Fact] + public static async Task GetRetryDelay_RateLimitWindow_WaitsForReset() + { + var error = await CreateErrorAsync( + HttpStatusCode.ServiceUnavailable, + null, + headers => headers.TryAddWithoutValidation("X-RateLimit-Reset", DateTimeOffset.UtcNow.AddSeconds(8).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))); + + var delay = MusicBrainzQueryExtensions.GetRetryDelay(error, 1); + + Assert.InRange(delay, TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(8)); + } + + private static async Task CreateErrorAsync(HttpStatusCode status, TimeSpan? retryAfter, Action? configureHeaders = null) + { + using var response = new HttpResponseMessage(status); + if (retryAfter is not null) + { + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter.Value); + } + + configureHeaders?.Invoke(response.Headers); + + return await HttpError.FromResponseAsync(response); + } +} -- cgit v1.2.3