using System.Net; namespace Jellyfin.Plugin.Multilang.Services.Providers; public enum ProviderFailureKind { Retryable, Terminal } public sealed record ProviderHttpDiagnostics( ProviderHttpCount[] Counts, ProviderHttpRecent[] Recent); public sealed record ProviderHttpCount( string Provider, string Outcome, int Count); public sealed record ProviderHttpRecent( string Provider, string Url, string Outcome, int Attempt, long At); public static class ProviderHttp { private static readonly object DiagnosticsLock = new(); private static readonly Dictionary Counts = new(StringComparer.OrdinalIgnoreCase); private static readonly Queue Recent = new(); public static ProviderFailureKind Classify(HttpStatusCode code) => code is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout || (int)code >= 500 ? ProviderFailureKind.Retryable : ProviderFailureKind.Terminal; public static async Task SendWithRetryAsync( HttpClient http, HttpRequestMessage request, CancellationToken cancellationToken, FetchRateLimiter? rateLimiter = null) { const int maxAttempts = 3; for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { if (rateLimiter is not null) await rateLimiter.WaitAsync(cancellationToken).ConfigureAwait(false); using var attemptRequest = Clone(request); var response = await http.SendAsync(attemptRequest, cancellationToken).ConfigureAwait(false); Record(request, "HTTP " + (int)response.StatusCode, attempt); if (response.IsSuccessStatusCode || Classify(response.StatusCode) == ProviderFailureKind.Terminal || attempt == maxAttempts) { return response; } var delay = GetRetryDelay(response, attempt); response.Dispose(); await Task.Delay(delay < TimeSpan.Zero ? TimeSpan.Zero : delay, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested && ex is HttpRequestException or OperationCanceledException) { Record(request, ex.GetType().Name, attempt); if (attempt >= maxAttempts) { throw; } await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), cancellationToken).ConfigureAwait(false); } } throw new HttpRequestException("Provider request failed after retries."); } public static ProviderHttpDiagnostics GetDiagnostics() { lock (DiagnosticsLock) { return new ProviderHttpDiagnostics( Counts .Select(kvp => { var index = kvp.Key.IndexOf('|', StringComparison.Ordinal); var provider = index >= 0 ? kvp.Key[..index] : kvp.Key; var outcome = index >= 0 ? kvp.Key[(index + 1)..] : string.Empty; return new ProviderHttpCount(provider, outcome, kvp.Value); }) .OrderBy(c => c.Provider) .ThenBy(c => c.Outcome) .ToArray(), Recent.Reverse().ToArray()); } } private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) => response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date is { } date ? date - DateTimeOffset.UtcNow : TimeSpan.FromMilliseconds(250 * attempt)); private static void Record(HttpRequestMessage request, string outcome, int attempt) { var provider = ProviderName(request.RequestUri); var key = provider + "|" + outcome; var recent = new ProviderHttpRecent( provider, SafeUrl(request.RequestUri), outcome, attempt, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); lock (DiagnosticsLock) { Counts[key] = Counts.TryGetValue(key, out var count) ? count + 1 : 1; Recent.Enqueue(recent); while (Recent.Count > 50) Recent.Dequeue(); } } private static string ProviderName(Uri? uri) { var host = uri?.Host ?? string.Empty; if (host.Contains("themoviedb", StringComparison.OrdinalIgnoreCase) || host.Contains("tmdb", StringComparison.OrdinalIgnoreCase)) return "TMDb"; if (host.Contains("fanart", StringComparison.OrdinalIgnoreCase)) return "Fanart.tv"; return string.IsNullOrWhiteSpace(host) ? "unknown" : host; } private static string SafeUrl(Uri? uri) => uri is null ? string.Empty : uri.GetLeftPart(UriPartial.Path); private static HttpRequestMessage Clone(HttpRequestMessage request) { var clone = new HttpRequestMessage(request.Method, request.RequestUri); foreach (var header in request.Headers) clone.Headers.TryAddWithoutValidation(header.Key, header.Value); return clone; } }