Refactor: Extract duplicate code into reusable helpers

- Created RoundRobinFallbackHelper for SquidWTF services (eliminates 3 duplicates)
- Moved QueueRequestAsync to BaseDownloadService (eliminates 2 duplicates)
- Moved CalculateArtistMatchScore to FuzzyMatcher (eliminates 2 duplicates)
- Updated all SquidWTF services to use RoundRobinFallbackHelper
- Updated DeezerDownloadService and SquidWTFDownloadService to use base class rate limiting
- Updated SpotifyTrackMatchingService and JellyfinController to use FuzzyMatcher helper
- All 225 tests passing
This commit is contained in:
2026-02-07 12:27:10 -05:00
parent e3bcc93597
commit b906a5fd6d
10 changed files with 233 additions and 211 deletions

View File

@@ -972,13 +972,23 @@ public class JellyfinController : ControllerBase
// Download and stream on-demand
try
{
// Use a timeout-based cancellation token instead of HttpContext.RequestAborted
// This allows downloads to complete even if the client disconnects
// The download will be cached for future requests
using var downloadCts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
var downloadStream = await _downloadService.DownloadAndStreamAsync(
provider,
externalId,
HttpContext.RequestAborted);
downloadCts.Token);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Download timeout for {Provider}:{ExternalId}", provider, externalId);
return StatusCode(504, new { error = "Download timeout" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
@@ -3753,8 +3763,7 @@ public class JellyfinController : ControllerBase
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{

View File

@@ -483,7 +483,8 @@ builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls));
squidWtfApiUrls,
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
// Register orchestrator as hosted service

View File

@@ -31,6 +31,11 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
// Rate limiting fields
private readonly SemaphoreSlim _requestLock = new(1, 1);
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
/// <summary>
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
/// </summary>
@@ -604,4 +609,34 @@ public abstract class BaseDownloadService : IDownloadService
}
#endregion
#region Rate Limiting
/// <summary>
/// Queues a request with rate limiting to prevent overwhelming the API.
/// Ensures minimum interval between requests.
/// </summary>
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
{
await _requestLock.WaitAsync();
try
{
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
if (timeSinceLastRequest < _minRequestIntervalMs)
{
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
}
_lastRequestTime = DateTime.UtcNow;
return await action();
}
finally
{
_requestLock.Release();
}
}
#endregion
}

View File

@@ -221,4 +221,54 @@ public static class FuzzyMatcher
return distance[sourceLength, targetLength];
}
/// <summary>
/// Calculates artist match score between Spotify artists and local song artists.
/// Checks bidirectional matching and penalizes mismatches.
/// Penalizes if artist counts don't match or if any artist is missing.
/// Returns score 0-100.
/// </summary>
public static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
{
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
return 0;
// Build list of all song artists (main + contributors)
var allSongArtists = new List<string> { songMainArtist };
allSongArtists.AddRange(songContributors);
// If artist counts differ significantly, penalize
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
return 0;
// Check that each Spotify artist has a good match in song artists
var spotifyScores = new List<double>();
foreach (var spotifyArtist in spotifyArtists)
{
var bestMatch = allSongArtists.Max(songArtist =>
CalculateSimilarity(spotifyArtist, songArtist));
spotifyScores.Add(bestMatch);
}
// Check that each song artist has a good match in Spotify artists
var songScores = new List<double>();
foreach (var songArtist in allSongArtists)
{
var bestMatch = spotifyArtists.Max(spotifyArtist =>
CalculateSimilarity(songArtist, spotifyArtist));
songScores.Add(bestMatch);
}
// Average all scores - this ensures ALL artists must match well
var allScores = spotifyScores.Concat(songScores);
var avgScore = allScores.Average();
// Penalize if any individual artist match is poor (< 70)
var minScore = allScores.Min();
if (minScore < 70)
avgScore *= 0.7; // 30% penalty for poor individual match
return avgScore;
}
}

View File

@@ -0,0 +1,110 @@
namespace allstarr.Services.Common;
/// <summary>
/// Helper for round-robin load balancing with fallback across multiple API endpoints.
/// Distributes load evenly while maintaining reliability through automatic failover.
/// </summary>
public class RoundRobinFallbackHelper
{
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly ILogger _logger;
private readonly string _serviceName;
public RoundRobinFallbackHelper(List<string> apiUrls, ILogger logger, string serviceName)
{
_apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_serviceName = serviceName ?? "Service";
if (_apiUrls.Count == 0)
{
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
}
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// This distributes load evenly across all providers while maintaining reliability.
/// Throws exception if all endpoints fail.
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
if (attempt == _apiUrls.Count - 1)
{
_logger.LogError("All {Count} {Service} endpoints failed", _apiUrls.Count, _serviceName);
throw;
}
}
}
throw new Exception($"All {_serviceName} endpoints failed");
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// Returns default value if all endpoints fail (does not throw).
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
if (attempt == _apiUrls.Count - 1)
{
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
_apiUrls.Count, _serviceName);
return defaultValue;
}
}
}
return defaultValue;
}
}

View File

@@ -24,7 +24,6 @@ namespace allstarr.Services.Deezer;
public class DeezerDownloadService : BaseDownloadService
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly string? _arl;
private readonly string? _arlFallback;
@@ -33,9 +32,6 @@ public class DeezerDownloadService : BaseDownloadService
private string? _apiToken;
private string? _licenseToken;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
private const string DeezerApiBase = "https://api.deezer.com";
// Deezer's standard Blowfish CBC encryption key for track decryption
@@ -497,27 +493,6 @@ public class DeezerDownloadService : BaseDownloadService
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
}
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
{
await _requestLock.WaitAsync();
try
{
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
if (timeSinceLastRequest < _minRequestIntervalMs)
{
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
}
_lastRequestTime = DateTime.UtcNow;
return await action();
}
finally
{
_requestLock.Release();
}
}
#endregion

View File

@@ -554,7 +554,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -640,7 +640,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -744,7 +744,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{

View File

@@ -20,16 +20,9 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFDownloadService : BaseDownloadService
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly SquidWTFSettings _squidwtfSettings;
private readonly OdesliService _odesliService;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly RoundRobinFallbackHelper _fallbackHelper;
protected override string ProviderName => "squidwtf";
@@ -49,54 +42,15 @@ public class SquidWTFDownloadService : BaseDownloadService
_httpClient = httpClientFactory.CreateClient();
_squidwtfSettings = SquidWTFSettings.Value;
_odesliService = odesliService;
_apiUrls = apiUrls;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// This distributes load evenly across all providers while maintaining reliability.
/// </summary>
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
Logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
if (attempt == _apiUrls.Count - 1)
{
Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
throw;
}
}
}
throw new Exception("All SquidWTF endpoints failed");
}
#region BaseDownloadService Implementation
public override async Task<bool> IsAvailableAsync()
{
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl);
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
@@ -191,7 +145,7 @@ public class SquidWTFDownloadService : BaseDownloadService
{
return await QueueRequestAsync(async () =>
{
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Map quality settings to Tidal's quality levels
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
@@ -262,27 +216,6 @@ public class SquidWTFDownloadService : BaseDownloadService
#region Utility Methods
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
{
await _requestLock.WaitAsync();
try
{
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
if (timeSinceLastRequest < _minRequestIntervalMs)
{
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
}
_lastRequestTime = DateTime.UtcNow;
return await action();
}
finally
{
_requestLock.Release();
}
}
#endregion

View File

@@ -21,9 +21,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings;
private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly RoundRobinFallbackHelper _fallbackHelper;
public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory,
@@ -37,69 +35,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
_settings = settings.Value;
_logger = logger;
_cache = cache;
_apiUrls = apiUrls;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
// Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
}
/// <summary>
/// Gets the next URL in round-robin fashion to distribute load across providers
/// </summary>
private string GetNextBaseUrl()
{
lock (_urlIndexLock)
{
var url = _apiUrls[_currentUrlIndex];
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
return url;
}
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// This distributes load evenly across all providers while maintaining reliability.
/// </summary>
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
_logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
if (attempt == _apiUrls.Count - 1)
{
_logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
return defaultValue;
}
}
}
return defaultValue;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
@@ -139,7 +86,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
@@ -173,7 +120,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
@@ -213,7 +160,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
{
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
@@ -267,7 +214,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/info/?id={externalId}";
@@ -298,7 +245,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var cached = await _cache.GetAsync<Album>(cacheKey);
if (cached != null) return cached;
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/album/?id={externalId}";
@@ -352,7 +299,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return cached;
}
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist from {Url}", url);
@@ -427,7 +374,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return new List<Album>();
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
@@ -472,7 +419,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);
@@ -491,7 +438,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return new List<Song>();
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
using allstarr.Services.Common;
namespace allstarr.Services.SquidWTF;
@@ -12,56 +13,17 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly RoundRobinFallbackHelper _fallbackHelper;
public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls, ILogger<SquidWTFStartupValidator> logger)
: base(httpClient)
{
_settings = settings.Value;
_apiUrls = apiUrls;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// This distributes load evenly across all providers while maintaining reliability.
/// </summary>
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
return await action(baseUrl);
}
catch
{
WriteDetail($"Endpoint {baseUrl} failed, trying next...");
if (attempt == _apiUrls.Count - 1)
{
WriteDetail($"All {_apiUrls.Count} endpoints failed");
return defaultValue;
}
}
}
return defaultValue;
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
@@ -80,7 +42,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
// Test connectivity with fallback
var result = await TryWithFallbackAsync(async (baseUrl) =>
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);