Aggiunto HtmlCacheService per caching e rate limiting
Introdotto un servizio centralizzato (`HtmlCacheService`) per gestire richieste HTTP con: - Cache HTML (5 minuti) per ridurre richieste duplicate. - Rate limiting (max 5 richieste/sec) e concorrenza limitata (3 richieste parallele). - Retry automatico (max 2 tentativi) e timeout configurabile (15s). - Logging dettagliato per cache hit, retry e richieste fallite. Aggiornati i metodi di caricamento dei nomi e delle informazioni prodotto per utilizzare il nuovo servizio, migliorando caching, gestione degli errori e decodifica delle entità HTML. Aggiunto supporto per il recupero automatico dei nomi generici delle aste e un timer per la pulizia periodica della cache. Documentato il servizio in `FEATURE_HTML_CACHE_SERVICE.md`. Correzioni minori e miglioramenti alla leggibilità del codice.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutoBidder.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Servizio centralizzato per gestione richieste HTTP con cache, rate limiting e queue
|
||||
/// </summary>
|
||||
public class HtmlCacheService
|
||||
{
|
||||
private static readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
// Cache HTML con timestamp
|
||||
private readonly ConcurrentDictionary<string, CachedHtml> _cache = new();
|
||||
|
||||
// Coda richieste con priorità
|
||||
private readonly SemaphoreSlim _rateLimiter;
|
||||
private readonly TimeSpan _minRequestDelay;
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly object _requestLock = new object();
|
||||
|
||||
// Configurazione
|
||||
private readonly int _maxConcurrentRequests;
|
||||
private readonly TimeSpan _cacheExpiration;
|
||||
private readonly int _maxRetries;
|
||||
|
||||
// Logging callback
|
||||
public Action<string>? OnLog { get; set; }
|
||||
|
||||
public HtmlCacheService(
|
||||
int maxConcurrentRequests = 3,
|
||||
int requestsPerSecond = 5,
|
||||
TimeSpan? cacheExpiration = null,
|
||||
int maxRetries = 2)
|
||||
{
|
||||
_maxConcurrentRequests = maxConcurrentRequests;
|
||||
_minRequestDelay = TimeSpan.FromMilliseconds(1000.0 / requestsPerSecond);
|
||||
_cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(5);
|
||||
_maxRetries = maxRetries;
|
||||
_rateLimiter = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests);
|
||||
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recupera HTML con cache automatica e rate limiting
|
||||
/// </summary>
|
||||
public async Task<HtmlResponse> GetHtmlAsync(
|
||||
string url,
|
||||
RequestPriority priority = RequestPriority.Normal,
|
||||
bool bypassCache = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Controlla cache se non bypassata
|
||||
if (!bypassCache && TryGetFromCache(url, out var cachedHtml))
|
||||
{
|
||||
OnLog?.Invoke($"[HTML CACHE] Hit per: {GetShortUrl(url)}");
|
||||
return new HtmlResponse
|
||||
{
|
||||
Success = true,
|
||||
Html = cachedHtml,
|
||||
FromCache = true,
|
||||
Url = url
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Rate limiting - aspetta il tuo turno
|
||||
await _rateLimiter.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Applica min delay tra richieste
|
||||
await ApplyRateLimitAsync();
|
||||
|
||||
// 4. Esegui richiesta HTTP con retry
|
||||
var html = await ExecuteWithRetryAsync(url);
|
||||
|
||||
// 5. Salva in cache
|
||||
SaveToCache(url, html);
|
||||
|
||||
OnLog?.Invoke($"[HTML FETCH] Success: {GetShortUrl(url)} ({html.Length} chars)");
|
||||
|
||||
return new HtmlResponse
|
||||
{
|
||||
Success = true,
|
||||
Html = html,
|
||||
FromCache = false,
|
||||
Url = url
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rateLimiter.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnLog?.Invoke($"[HTML ERROR] {GetShortUrl(url)}: {ex.Message}");
|
||||
return new HtmlResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
Url = url
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Richiesta HTTP con retry automatico
|
||||
/// </summary>
|
||||
private async Task<string> ExecuteWithRetryAsync(string url)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (int attempt = 1; attempt <= _maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var html = await _httpClient.GetStringAsync(url);
|
||||
|
||||
if (attempt > 1)
|
||||
{
|
||||
OnLog?.Invoke($"[HTML RETRY] Success al tentativo {attempt}: {GetShortUrl(url)}");
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
catch (TaskCanceledException) when (attempt < _maxRetries)
|
||||
{
|
||||
lastException = new TaskCanceledException($"Timeout (tentativo {attempt}/{_maxRetries})");
|
||||
OnLog?.Invoke($"[HTML RETRY] Timeout tentativo {attempt}/{_maxRetries}: {GetShortUrl(url)}");
|
||||
await Task.Delay(1000 * attempt); // Exponential backoff
|
||||
}
|
||||
catch (HttpRequestException ex) when (attempt < _maxRetries)
|
||||
{
|
||||
lastException = ex;
|
||||
OnLog?.Invoke($"[HTML RETRY] Errore tentativo {attempt}/{_maxRetries}: {ex.Message}");
|
||||
await Task.Delay(1000 * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException ?? new Exception("Tutti i tentativi falliti");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applica rate limiting tra richieste
|
||||
/// </summary>
|
||||
private async Task ApplyRateLimitAsync()
|
||||
{
|
||||
lock (_requestLock)
|
||||
{
|
||||
var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
|
||||
if (timeSinceLastRequest < _minRequestDelay)
|
||||
{
|
||||
var delay = _minRequestDelay - timeSinceLastRequest;
|
||||
OnLog?.Invoke($"[RATE LIMIT] Delay di {delay.TotalMilliseconds:F0}ms");
|
||||
Thread.Sleep(delay); // Sync sleep dentro lock per garantire ordinamento
|
||||
}
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Controlla se HTML è in cache e ancora valido
|
||||
/// </summary>
|
||||
private bool TryGetFromCache(string url, out string html)
|
||||
{
|
||||
if (_cache.TryGetValue(url, out var cached))
|
||||
{
|
||||
if (DateTime.UtcNow - cached.Timestamp < _cacheExpiration)
|
||||
{
|
||||
html = cached.Html;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Expired - rimuovi
|
||||
_cache.TryRemove(url, out _);
|
||||
OnLog?.Invoke($"[HTML CACHE] Expired: {GetShortUrl(url)}");
|
||||
}
|
||||
}
|
||||
|
||||
html = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva HTML in cache
|
||||
/// </summary>
|
||||
private void SaveToCache(string url, string html)
|
||||
{
|
||||
_cache[url] = new CachedHtml
|
||||
{
|
||||
Html = html,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulisce cache scaduta
|
||||
/// </summary>
|
||||
public void CleanExpiredCache()
|
||||
{
|
||||
var expired = _cache
|
||||
.Where(kvp => DateTime.UtcNow - kvp.Value.Timestamp >= _cacheExpiration)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expired)
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
OnLog?.Invoke($"[HTML CACHE] Pulite {expired.Count} entry scadute");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulisce tutta la cache
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
var count = _cache.Count;
|
||||
_cache.Clear();
|
||||
OnLog?.Invoke($"[HTML CACHE] Cache pulita ({count} entries)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistiche cache
|
||||
/// </summary>
|
||||
public CacheStats GetStats()
|
||||
{
|
||||
return new CacheStats
|
||||
{
|
||||
TotalEntries = _cache.Count,
|
||||
AvailableSlots = _rateLimiter.CurrentCount,
|
||||
MaxConcurrent = _maxConcurrentRequests
|
||||
};
|
||||
}
|
||||
|
||||
private string GetShortUrl(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var path = uri.AbsolutePath;
|
||||
if (path.Length > 40)
|
||||
path = "..." + path.Substring(path.Length - 37);
|
||||
return path;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return url.Length > 40 ? url.Substring(0, 37) + "..." : url;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classe per cache con timestamp
|
||||
/// </summary>
|
||||
private class CachedHtml
|
||||
{
|
||||
public string Html { get; set; } = string.Empty;
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risposta richiesta HTML
|
||||
/// </summary>
|
||||
public class HtmlResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Html { get; set; } = string.Empty;
|
||||
public string Error { get; set; } = string.Empty;
|
||||
public bool FromCache { get; set; }
|
||||
public string Url { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priorità richiesta (per future implementazioni)
|
||||
/// </summary>
|
||||
public enum RequestPriority
|
||||
{
|
||||
Low = 0,
|
||||
Normal = 1,
|
||||
High = 2,
|
||||
Critical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistiche cache
|
||||
/// </summary>
|
||||
public class CacheStats
|
||||
{
|
||||
public int TotalEntries { get; set; }
|
||||
public int AvailableSlots { get; set; }
|
||||
public int MaxConcurrent { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user