From 856888a02e5eba237972c3f112a1594ddec4f8e8 Mon Sep 17 00:00:00 2001 From: tech nasty <262050521+t3chnaztea@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:49:48 -0500 Subject: [PATCH] fix(search): one indexer timeout no longer discards every other indexer's results An HttpClient timeout surfaces as TaskCanceledException, a subclass of OperationCanceledException. The per-indexer catch in the parallel search loop excluded OperationCanceledException, so a single slow/timing-out indexer's exception escaped the catch, propagated out of Task.WhenAll, and discarded every healthy indexer's results for the whole search (zeroing the automatic cycle for the book). No workflow-level cancellation token flows into SearchIndexersAsync, so any OCE there is a per-request timeout: catch it per-indexer, log a warning naming the indexer, and return an empty result for that indexer only. Co-Authored-By: Claude Fable 5 --- .../Indexers/Common/IndexerSearchWorkflow.cs | 11 +- .../Common/IndexerSearchWorkflowTests.cs | 114 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/Features/Application/Search/Indexers/Common/IndexerSearchWorkflowTests.cs diff --git a/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs b/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs index 85e789977..97becd89b 100644 --- a/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs +++ b/listenarr.application/Search/Indexers/Common/IndexerSearchWorkflow.cs @@ -78,7 +78,16 @@ public async Task> SearchIndexersAsync( _logger.LogInformation("Found {Count} results from indexer {Name}", indexerResults.Count, indexer.Name); return indexerResults; } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + catch (OperationCanceledException ex) + { + // No workflow-level cancellation token flows into this search, so an + // OperationCanceledException here is an HttpClient per-request timeout + // (TaskCanceledException derives from OperationCanceledException). Contain it + // to this indexer so a single slow indexer can't abort every other one's results. + _logger.LogWarning(ex, "Timed out searching indexer {Name} for query: {Query}", indexer.Name, query); + return new List(); + } + catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException) { _logger.LogError(ex, "Error searching indexer {Name} for query: {Query}", indexer.Name, query); return new List(); diff --git a/tests/Features/Application/Search/Indexers/Common/IndexerSearchWorkflowTests.cs b/tests/Features/Application/Search/Indexers/Common/IndexerSearchWorkflowTests.cs new file mode 100644 index 000000000..2cab14deb --- /dev/null +++ b/tests/Features/Application/Search/Indexers/Common/IndexerSearchWorkflowTests.cs @@ -0,0 +1,114 @@ +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Search.Indexers.Common +{ + [Trait("Name", "IndexerSearchWorkflowTests")] + [Trait("Category", "IndexerSearchWorkflow")] + public class IndexerSearchWorkflowTests : BaseTests + { + private static IndexerSearchWorkflow CreateWorkflow( + IEnumerable enabledIndexers, + IEnumerable providers) + { + var indexerRepository = new Mock(); + indexerRepository + .Setup(r => r.GetEnabledAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(enabledIndexers.ToList()); + + var additionalSettingsParser = new IndexerAdditionalSettingsParser( + Mock.Of>()); + + return new IndexerSearchWorkflow( + new HttpClient(), + Mock.Of(), + indexerRepository.Object, + providers, + additionalSettingsParser, + Mock.Of>()); + } + + [Fact] + public async Task SearchIndexersAsync_OneIndexerTimesOut_ReturnsHealthyIndexerResults() + { + // Given: two enabled indexers, one whose provider times out (TaskCanceledException) + // and one that returns results + var timingOutIndexer = new IndexerBuilder() + .WithName("SlowIndexer") + .WithImplementation("Slow") + .Build(); + var healthyIndexer = new IndexerBuilder() + .WithName("FastIndexer") + .WithImplementation("Fast") + .Build(); + + var healthyResult = new IndexerSearchResult + { + Id = "healthy-1", + Title = "The Healthy Result", + Source = "FastIndexer", + Seeders = 42 + }; + + var providers = new IIndexerSearchProvider[] + { + new FakeSearchProvider("Slow", () => throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout")), + new FakeSearchProvider("Fast", () => new List { healthyResult }) + }; + + var workflow = CreateWorkflow(new[] { timingOutIndexer, healthyIndexer }, providers); + + // When: an automatic search runs across both indexers + var results = await workflow.SearchIndexersAsync("some query", isAutomaticSearch: true); + + // Then: the healthy indexer's results survive and no exception escapes + Assert.Single(results); + Assert.Equal("healthy-1", results[0].Id); + Assert.Equal("FastIndexer", results[0].Source); + } + + [Fact] + public async Task SearchIndexersAsync_AllIndexersTimeOut_ReturnsEmptyWithoutThrowing() + { + // Given: every enabled indexer's provider times out + var indexerA = new IndexerBuilder().WithName("A").WithImplementation("SlowA").Build(); + var indexerB = new IndexerBuilder().WithName("B").WithImplementation("SlowB").Build(); + + var providers = new IIndexerSearchProvider[] + { + new FakeSearchProvider("SlowA", () => throw new TaskCanceledException()), + new FakeSearchProvider("SlowB", () => throw new TaskCanceledException()) + }; + + var workflow = CreateWorkflow(new[] { indexerA, indexerB }, providers); + + // When: a search runs across both timing-out indexers + var results = await workflow.SearchIndexersAsync("some query", isAutomaticSearch: true); + + // Then: the timeouts are contained and the search returns an empty list, not an exception + Assert.Empty(results); + } + + private sealed class FakeSearchProvider : IIndexerSearchProvider + { + private readonly Func> _behavior; + + public FakeSearchProvider(string indexerType, Func> behavior) + { + IndexerType = indexerType; + _behavior = behavior; + } + + public string IndexerType { get; } + + public Task> SearchAsync( + Indexer indexer, + string query, + string? category = null, + SearchRequest? request = null) + { + return Task.FromResult(_behavior()); + } + } + } +}