From 0dca6b792d78aa6a66fb501cd11c4b519ddbc365 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 9 Feb 2026 16:09:38 -0500 Subject: [PATCH] Fix Spotify 429 rate limiting and startup performance issues - Fix: Use correct HttpClient (_webApiClient) for GraphQL library playlists endpoint - Was using _httpClient which pointed to wrong base URL causing 429 errors - Add: Retry logic with Retry-After header support for 429 responses - Add: Minimum 500ms delay between library playlist pages to prevent rate limiting - Add: 5-second timeout per endpoint benchmark ping to prevent slow endpoints from blocking startup - Add: Documentation for timeout requirements in EndpointBenchmarkService - Fix: ARM64 compatibility for spotify-lyrics service via platform emulation in docker-compose --- .../Common/EndpointBenchmarkService.cs | 3 ++ allstarr/Services/Spotify/SpotifyApiClient.cs | 35 +++++++++++++++---- .../SquidWTF/SquidWTFStartupValidator.cs | 6 +++- docker-compose.yml | 3 ++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/allstarr/Services/Common/EndpointBenchmarkService.cs b/allstarr/Services/Common/EndpointBenchmarkService.cs index 688e3d9..0e22afd 100644 --- a/allstarr/Services/Common/EndpointBenchmarkService.cs +++ b/allstarr/Services/Common/EndpointBenchmarkService.cs @@ -20,6 +20,9 @@ public class EndpointBenchmarkService /// /// Benchmarks a list of endpoints by making test requests. /// Returns endpoints sorted by average response time (fastest first). + /// + /// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints + /// from blocking startup. Recommended: 5-10 second timeout per ping. /// public async Task> BenchmarkEndpointsAsync( List endpoints, diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs index 8bb5cf2..65286ca 100644 --- a/allstarr/Services/Spotify/SpotifyApiClient.cs +++ b/allstarr/Services/Spotify/SpotifyApiClient.cs @@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable var response = await _webApiClient.SendAsync(request, cancellationToken); + // Handle 429 rate limiting with exponential backoff + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5); + _logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds); + await Task.Delay(retryAfter, cancellationToken); + + // Retry the request + response = await _webApiClient.SendAsync(request, cancellationToken); + } + if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode); @@ -801,7 +812,19 @@ public class SpotifyApiClient : IDisposable }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await _httpClient.SendAsync(request, cancellationToken); + var response = await _webApiClient.SendAsync(request, cancellationToken); + + // Handle 429 rate limiting with exponential backoff + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5); + _logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds); + await Task.Delay(retryAfter, cancellationToken); + + // Retry the request + response = await _httpClient.SendAsync(request, cancellationToken); + } + if (!response.IsSuccessStatusCode) { _logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode); @@ -851,11 +874,11 @@ public class SpotifyApiClient : IDisposable if (itemCount < limit) break; offset += limit; - // GraphQL is less rate-limited, but still add a small delay - if (_settings.RateLimitDelayMs > 0) - { - await Task.Delay(_settings.RateLimitDelayMs, cancellationToken); - } + // Add delay between pages to avoid rate limiting + // Library fetching can be aggressive, so use a longer delay + var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages + _logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs); + await Task.Delay(delayMs, cancellationToken); } _logger.LogInformation("Found {Count} playlists matching '{SearchName}' via GraphQL", playlists.Count, searchName); diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index e1bbbfc..e23ce76 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator { try { - var response = await _httpClient.GetAsync(endpoint, ct); + // 5 second timeout per ping - mark slow endpoints as failed + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); + + var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token); return response.IsSuccessStatusCode; } catch diff --git a/docker-compose.yml b/docker-compose.yml index 81bbbc5..f4ed98c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,8 +17,11 @@ services: networks: - allstarr-network + # Spotify Lyrics API sidecar service + # Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation. spotify-lyrics: image: akashrchandran/spotify-lyrics-api:latest + platform: linux/amd64 container_name: allstarr-spotify-lyrics restart: unless-stopped ports: