Feat Rate Limit para cuotear peticiones.

This commit is contained in:
2025-08-20 14:17:25 -03:00
parent 68dce9415e
commit 9d5c2086c5
12 changed files with 345 additions and 142 deletions

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+30f1e751b770bf730fc48b1baefb00f560694f35")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -306,6 +306,10 @@
"Microsoft.Extensions.Http": { "Microsoft.Extensions.Http": {
"target": "Package", "target": "Package",
"version": "[9.0.8, )" "version": "[9.0.8, )"
},
"System.Threading.RateLimiting": {
"target": "Package",
"version": "[9.0.8, )"
} }
}, },
"imports": [ "imports": [

View File

@@ -7,6 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.8" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.8" />
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -7,6 +7,7 @@ using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Threading.Tasks; using System.Threading.Tasks;
using static Elecciones.Core.DTOs.BancaDto; using static Elecciones.Core.DTOs.BancaDto;
using System.Threading.RateLimiting;
namespace Elecciones.Infrastructure.Services; namespace Elecciones.Infrastructure.Services;
@@ -15,205 +16,308 @@ public class ElectoralApiService : IElectoralApiService
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<ElectoralApiService> _logger; private readonly ILogger<ElectoralApiService> _logger;
private readonly RateLimiter _rateLimiter;
public ElectoralApiService(IHttpClientFactory httpClientFactory, public ElectoralApiService(IHttpClientFactory httpClientFactory,
IConfiguration configuration, IConfiguration configuration,
ILogger<ElectoralApiService> logger) ILogger<ElectoralApiService> logger,
RateLimiter rateLimiter)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
_rateLimiter = rateLimiter;
} }
public async Task<string?> GetAuthTokenAsync() public async Task<string?> GetAuthTokenAsync()
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var username = _configuration["ElectoralApi:Username"]; // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
var password = _configuration["ElectoralApi:Password"]; using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) return null;
var request = new HttpRequestMessage(HttpMethod.Get, "/api/createtoken/"); // Si se nos concede el permiso para proceder...
request.Headers.Add("username", username); if (lease.IsAcquired)
request.Headers.Add("password", password); {
var response = await client.SendAsync(request); var client = _httpClientFactory.CreateClient("ElectoralApiClient");
if (!response.IsSuccessStatusCode) return null; var username = _configuration["ElectoralApi:Username"];
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(); var password = _configuration["ElectoralApi:Password"];
return (tokenResponse is { Success: true, Data.AccessToken: not null }) ? tokenResponse.Data.AccessToken : null; if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) return null;
var request = new HttpRequestMessage(HttpMethod.Get, "/api/createtoken/");
request.Headers.Add("username", username);
request.Headers.Add("password", password);
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>();
return (tokenResponse is { Success: true, Data.AccessToken: not null }) ? tokenResponse.Data.AccessToken : null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null;
} }
public async Task<CatalogoDto?> GetCatalogoAmbitosAsync(string authToken, int categoriaId) public async Task<CatalogoDto?> GetCatalogoAmbitosAsync(string authToken, int categoriaId)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/catalogo/getCatalogo?categoriaId={categoriaId}"); // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
request.Headers.Add("Authorization", $"Bearer {authToken}"); using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<CatalogoDto>() : null; // Si se nos concede el permiso para proceder...
if (lease.IsAcquired)
{
var client = _httpClientFactory.CreateClient("ElectoralApiClient");
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/catalogo/getCatalogo?categoriaId={categoriaId}");
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<CatalogoDto>() : null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null;
} }
public async Task<List<AgrupacionDto>?> GetAgrupacionesAsync(string authToken, string distritoId, int categoriaId) public async Task<List<AgrupacionDto>?> GetAgrupacionesAsync(string authToken, string distritoId, int categoriaId)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var requestUri = $"/api/catalogo/getAgrupaciones?distritoId={distritoId}&categoriaId={categoriaId}"; // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
var request = new HttpRequestMessage(HttpMethod.Get, requestUri); using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request); // Si se nos concede el permiso para proceder...
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<List<AgrupacionDto>>() : null; if (lease.IsAcquired)
{
var client = _httpClientFactory.CreateClient("ElectoralApiClient");
var requestUri = $"/api/catalogo/getAgrupaciones?distritoId={distritoId}&categoriaId={categoriaId}";
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<List<AgrupacionDto>>() : null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null;
} }
public async Task<ResultadosDto?> GetResultadosAsync(string authToken, string distritoId, string seccionId, string? municipioId, int categoriaId) public async Task<ResultadosDto?> GetResultadosAsync(string authToken, string distritoId, string seccionId, string? municipioId, int categoriaId)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
// Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
// Construimos la URL base // Si se nos concede el permiso para proceder...
var requestUri = $"/api/resultados/getResultados?distritoId={distritoId}&seccionId={seccionId}&categoriaId={categoriaId}"; if (lease.IsAcquired)
// Añadimos el municipioId a la URL SÓLO si no es nulo o vacío
if (!string.IsNullOrEmpty(municipioId))
{ {
requestUri += $"&municipioId={municipioId}"; var client = _httpClientFactory.CreateClient("ElectoralApiClient");
}
var request = new HttpRequestMessage(HttpMethod.Get, requestUri); // Construimos la URL base
request.Headers.Add("Authorization", $"Bearer {authToken}"); var requestUri = $"/api/resultados/getResultados?distritoId={distritoId}&seccionId={seccionId}&categoriaId={categoriaId}";
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<ResultadosDto>() : null; // Añadimos el municipioId a la URL SÓLO si no es nulo o vacío
if (!string.IsNullOrEmpty(municipioId))
{
requestUri += $"&municipioId={municipioId}";
}
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<ResultadosDto>() : null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null;
} }
public async Task<RepartoBancasDto?> GetBancasAsync(string authToken, string distritoId, string? seccionProvincialId, int categoriaId) public async Task<RepartoBancasDto?> GetBancasAsync(string authToken, string distritoId, string? seccionProvincialId, int categoriaId)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var requestUri = $"/api/resultados/getBancas?distritoId={distritoId}&categoriaId={categoriaId}"; // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
if (!string.IsNullOrEmpty(seccionProvincialId)) // Si se nos concede el permiso para proceder...
if (lease.IsAcquired)
{ {
requestUri += $"&seccionProvincialId={seccionProvincialId}"; var client = _httpClientFactory.CreateClient("ElectoralApiClient");
} var requestUri = $"/api/resultados/getBancas?distritoId={distritoId}&categoriaId={categoriaId}";
var request = new HttpRequestMessage(HttpMethod.Get, requestUri); if (!string.IsNullOrEmpty(seccionProvincialId))
request.Headers.Add("Authorization", $"Bearer {authToken}"); {
requestUri += $"&seccionProvincialId={seccionProvincialId}";
}
HttpResponseMessage response; var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
try request.Headers.Add("Authorization", $"Bearer {authToken}");
{
response = await client.SendAsync(request);
}
catch (Exception ex)
{
// Captura errores de red (ej. la API se cae momentáneamente)
_logger.LogError(ex, "La petición HTTP a getBancas falló. URI: {requestUri}", requestUri);
return null;
}
// Comprobamos que la respuesta fue exitosa Y que contiene datos antes de intentar leerla. HttpResponseMessage response;
if (response.IsSuccessStatusCode && response.Content?.Headers.ContentLength > 0)
{
try try
{ {
// Solo si hay contenido, intentamos deserializar. response = await client.SendAsync(request);
return await response.Content.ReadFromJsonAsync<RepartoBancasDto>();
} }
catch (JsonException ex) catch (Exception ex)
{ {
// Si el contenido no es un JSON válido, lo registramos y devolvemos null. // Captura errores de red (ej. la API se cae momentáneamente)
_logger.LogWarning(ex, "La API devolvió una respuesta no-JSON para getBancas. URI: {requestUri}, Status: {statusCode}", requestUri, response.StatusCode); _logger.LogError(ex, "La petición HTTP a getBancas falló. URI: {requestUri}", requestUri);
return null; return null;
} }
}
else if (!response.IsSuccessStatusCode)
{
// Si la API devolvió un error HTTP, lo registramos.
_logger.LogWarning("La API devolvió un código de error {statusCode} para getBancas. URI: {requestUri}", response.StatusCode, requestUri);
}
// Si la respuesta fue 200 OK pero con cuerpo vacío, o si fue un error HTTP, devolvemos null. // Comprobamos que la respuesta fue exitosa Y que contiene datos antes de intentar leerla.
if (response.IsSuccessStatusCode && response.Content?.Headers.ContentLength > 0)
{
try
{
// Solo si hay contenido, intentamos deserializar.
return await response.Content.ReadFromJsonAsync<RepartoBancasDto>();
}
catch (JsonException ex)
{
// Si el contenido no es un JSON válido, lo registramos y devolvemos null.
_logger.LogWarning(ex, "La API devolvió una respuesta no-JSON para getBancas. URI: {requestUri}, Status: {statusCode}", requestUri, response.StatusCode);
return null;
}
}
else if (!response.IsSuccessStatusCode)
{
// Si la API devolvió un error HTTP, lo registramos.
_logger.LogWarning("La API devolvió un código de error {statusCode} para getBancas. URI: {requestUri}", response.StatusCode, requestUri);
}
// Si la respuesta fue 200 OK pero con cuerpo vacío, o si fue un error HTTP, devolvemos null.
return null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null; return null;
} }
public async Task<List<string>?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null) public async Task<List<string>?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var requestUri = $"/api/resultados/getTelegramasTotalizados?distritoId={distritoId}&seccionId={seccionId}"; // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
if (categoriaId.HasValue) // Si se nos concede el permiso para proceder...
if (lease.IsAcquired)
{ {
requestUri += $"&categoriaId={categoriaId.Value}"; var client = _httpClientFactory.CreateClient("ElectoralApiClient");
var requestUri = $"/api/resultados/getTelegramasTotalizados?distritoId={distritoId}&seccionId={seccionId}";
if (categoriaId.HasValue)
{
requestUri += $"&categoriaId={categoriaId.Value}";
}
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
// Ahora deserializamos al tipo correcto: List<string>
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<List<string>>() : null;
} }
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
var request = new HttpRequestMessage(HttpMethod.Get, requestUri); return null;
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
// Ahora deserializamos al tipo correcto: List<string>
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<List<string>>() : null;
} }
public async Task<TelegramaFileDto?> GetTelegramaFileAsync(string authToken, string mesaId) public async Task<TelegramaFileDto?> GetTelegramaFileAsync(string authToken, string mesaId)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var requestUri = $"/api/resultados/getFile?mesaId={mesaId}"; // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
var request = new HttpRequestMessage(HttpMethod.Get, requestUri); using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
request.Headers.Add("Authorization", $"Bearer {authToken}");
HttpResponseMessage response; // Si se nos concede el permiso para proceder...
try if (lease.IsAcquired)
{ {
response = await client.SendAsync(request); var client = _httpClientFactory.CreateClient("ElectoralApiClient");
} var requestUri = $"/api/resultados/getFile?mesaId={mesaId}";
catch (Exception ex) var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
{ request.Headers.Add("Authorization", $"Bearer {authToken}");
_logger.LogError(ex, "La petición HTTP a getFile falló para la mesa {mesaId}", mesaId);
return null;
}
if (response.IsSuccessStatusCode && response.Content?.Headers.ContentLength > 0) HttpResponseMessage response;
{
try try
{ {
return await response.Content.ReadFromJsonAsync<TelegramaFileDto>(); response = await client.SendAsync(request);
} }
catch (JsonException ex) catch (Exception ex)
{ {
// Si la deserialización falla, ahora lo sabremos exactamente. _logger.LogError(ex, "La petición HTTP a getFile falló para la mesa {mesaId}", mesaId);
_logger.LogWarning(ex, "La API devolvió una respuesta no-JSON para la mesa {mesaId}. Status: {statusCode}", mesaId, response.StatusCode);
return null; return null;
} }
}
else if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("La API devolvió un código de error {statusCode} para la mesa {mesaId}", response.StatusCode, mesaId);
}
// Si la respuesta fue exitosa pero sin contenido, no es necesario loguearlo como un error,
// simplemente devolvemos null y el Worker lo ignorará.
if (response.IsSuccessStatusCode && response.Content?.Headers.ContentLength > 0)
{
try
{
return await response.Content.ReadFromJsonAsync<TelegramaFileDto>();
}
catch (JsonException ex)
{
// Si la deserialización falla, ahora lo sabremos exactamente.
_logger.LogWarning(ex, "La API devolvió una respuesta no-JSON para la mesa {mesaId}. Status: {statusCode}", mesaId, response.StatusCode);
return null;
}
}
else if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("La API devolvió un código de error {statusCode} para la mesa {mesaId}", response.StatusCode, mesaId);
}
// Si la respuesta fue exitosa pero sin contenido, no es necesario loguearlo como un error,
// simplemente devolvemos null y el Worker lo ignorará.
return null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null; return null;
} }
public async Task<ResumenDto?> GetResumenAsync(string authToken, string distritoId) public async Task<ResumenDto?> GetResumenAsync(string authToken, string distritoId)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var requestUri = $"/api/resultados/getResumen?distritoId={distritoId}"; // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
var request = new HttpRequestMessage(HttpMethod.Get, requestUri); using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request); // Si se nos concede el permiso para proceder...
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<ResumenDto>() : null; if (lease.IsAcquired)
{
var client = _httpClientFactory.CreateClient("ElectoralApiClient");
var requestUri = $"/api/resultados/getResumen?distritoId={distritoId}";
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<ResumenDto>() : null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null;
} }
public async Task<EstadoRecuentoGeneralDto?> GetEstadoRecuentoGeneralAsync(string authToken, string distritoId, int categoriaId) public async Task<EstadoRecuentoGeneralDto?> GetEstadoRecuentoGeneralAsync(string authToken, string distritoId, int categoriaId)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
// La URL ahora usa el parámetro 'categoriaId' que se recibe // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
var requestUri = $"/api/estados/estadoRecuento?distritoId={distritoId}&categoriaId={categoriaId}"; using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Authorization", $"Bearer {authToken}"); // Si se nos concede el permiso para proceder...
var response = await client.SendAsync(request); if (lease.IsAcquired)
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<EstadoRecuentoGeneralDto>() : null; {
var client = _httpClientFactory.CreateClient("ElectoralApiClient");
// La URL ahora usa el parámetro 'categoriaId' que se recibe
var requestUri = $"/api/estados/estadoRecuento?distritoId={distritoId}&categoriaId={categoriaId}";
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<EstadoRecuentoGeneralDto>() : null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null;
} }
public async Task<List<CategoriaDto>?> GetCategoriasAsync(string authToken) public async Task<List<CategoriaDto>?> GetCategoriasAsync(string authToken)
{ {
var client = _httpClientFactory.CreateClient("ElectoralApiClient"); // "Pedir una ficha". Este método ahora devuelve un "lease" (permiso).
var request = new HttpRequestMessage(HttpMethod.Get, "/api/catalogo/getCategorias"); // Si no hay fichas, esperará aquí automáticamente hasta que se rellene el cubo.
request.Headers.Add("Authorization", $"Bearer {authToken}"); using RateLimitLease lease = await _rateLimiter.AcquireAsync(1);
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<List<CategoriaDto>>() : null; // Si se nos concede el permiso para proceder...
if (lease.IsAcquired)
{
var client = _httpClientFactory.CreateClient("ElectoralApiClient");
var request = new HttpRequestMessage(HttpMethod.Get, "/api/catalogo/getCategorias");
request.Headers.Add("Authorization", $"Bearer {authToken}");
var response = await client.SendAsync(request);
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<List<CategoriaDto>>() : null;
}
// Si no se pudo obtener un permiso (ej. la cola está llena), devolvemos null.
return null;
} }
} }

View File

@@ -0,0 +1,44 @@
// Archivo: Elecciones.Infrastructure/Services/RateLimiterService.cs
using System.Threading;
using System.Threading.Tasks;
namespace Elecciones.Infrastructure.Services;
public class RateLimiterService
{
private readonly SemaphoreSlim _semaphore;
private readonly int _limit;
private readonly TimeSpan _period;
private Timer _timer;
public RateLimiterService(int limit, TimeSpan period)
{
_limit = limit;
_period = period;
_semaphore = new SemaphoreSlim(limit, limit);
// Un temporizador que "rellena el cubo" cada 5 minutos.
_timer = new Timer(
callback: _ => RefillBucket(),
state: null,
dueTime: period,
period: period);
}
private void RefillBucket()
{
// Calcula cuántas fichas faltan en el cubo.
var releaseCount = _limit - _semaphore.CurrentCount;
if (releaseCount > 0)
{
// Añade las fichas que faltan.
_semaphore.Release(releaseCount);
}
}
// El método que usarán nuestras tareas para "pedir una ficha".
public async Task WaitAsync(CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
}
}

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+30f1e751b770bf730fc48b1baefb00f560694f35")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -126,6 +126,10 @@
"Microsoft.Extensions.Http": { "Microsoft.Extensions.Http": {
"target": "Package", "target": "Package",
"version": "[9.0.8, )" "version": "[9.0.8, )"
},
"System.Threading.RateLimiting": {
"target": "Package",
"version": "[9.0.8, )"
} }
}, },
"imports": [ "imports": [

View File

@@ -16,6 +16,7 @@
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="System.Threading.RateLimiting" Version="9.0.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -11,6 +11,7 @@ using System.Net.Security;
using System.Security.Authentication; using System.Security.Authentication;
using Polly; using Polly;
using Polly.Extensions.Http; using Polly.Extensions.Http;
using System.Threading.RateLimiting;
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.WriteTo.Console() .WriteTo.Console()
@@ -81,6 +82,26 @@ builder.Services.AddHttpClient("ElectoralApiClient", client =>
.AddPolicyHandler(GetRetryPolicy()); .AddPolicyHandler(GetRetryPolicy());
// --- LIMITADOR DE VELOCIDAD BASADO EN TOKEN BUCKET ---
builder.Services.AddSingleton<RateLimiter>(sp =>
new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
// El tamaño máximo del cubo (la ráfaga máxima que permitimos).
TokenLimit = 50,
// Con qué frecuencia se añaden nuevas fichas al cubo.
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
// Cuántas fichas se añaden en cada período.
TokensPerPeriod = 20,
// Cuántas peticiones pueden estar en cola esperando una ficha.
QueueLimit = 1000,
// Cómo se comporta cuando la cola está llena.
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
builder.Services.AddScoped<IElectoralApiService, ElectoralApiService>(); builder.Services.AddScoped<IElectoralApiService, ElectoralApiService>();
builder.Services.AddHostedService<Worker>(); builder.Services.AddHostedService<Worker>();

View File

@@ -41,38 +41,54 @@ public class Worker : BackgroundService
_logger.LogInformation("Iniciando sondeo periódico de resultados..."); _logger.LogInformation("Iniciando sondeo periódico de resultados...");
_logger.LogInformation("-------------------------------------------------"); _logger.LogInformation("-------------------------------------------------");
int cicloContador = 0;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
var cicloInicio = DateTime.UtcNow;
cicloContador++;
var authToken = await _apiService.GetAuthTokenAsync(); var authToken = await _apiService.GetAuthTokenAsync();
if (string.IsNullOrEmpty(authToken)) if (string.IsNullOrEmpty(authToken))
{ {
_logger.LogError("CRÍTICO: No se pudo obtener el token de autenticación. Reintentando en 1 minuto..."); _logger.LogError("CRÍTICO: No se pudo obtener el token. Reintentando en 1 minuto...");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue; continue;
} }
// --- CAMBIO CLAVE: DE PARALELO A SECUENCIAL --- // --- CICLO CALIENTE: TAREAS DE ALTA PRIORIDAD (SIEMPRE SE EJECUTAN) ---
// Se elimina Task.WhenAll y se ejecutan las tareas una después de la otra. _logger.LogInformation("--- Iniciando Ciclo Caliente #{ciclo} ---", cicloContador);
// Esto previene los errores de Gateway Timeout (504).
_logger.LogInformation("--- Iniciando sondeo de Resultados Municipales ---");
await SondearResultadosMunicipalesAsync(authToken, stoppingToken); await SondearResultadosMunicipalesAsync(authToken, stoppingToken);
_logger.LogInformation("--- Iniciando sondeo de Resumen Provincial ---");
await SondearResumenProvincialAsync(authToken, stoppingToken); await SondearResumenProvincialAsync(authToken, stoppingToken);
_logger.LogInformation("--- Iniciando sondeo de Estado de Recuento General ---");
await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken); await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken);
_logger.LogInformation("--- Iniciando sondeo de Proyección de Bancas ---"); // --- CICLO FRÍO: TAREAS DE BAJA PRIORIDAD (SE EJECUTAN CADA 5 CICLOS) ---
await SondearProyeccionBancasAsync(authToken, stoppingToken); // El operador '%' (módulo) nos dice si el contador es divisible por 5.
if (cicloContador % 5 == 1) // Se ejecuta en el ciclo 1, 6, 11, etc.
{
_logger.LogInformation("--- Iniciando Ciclo Frío (Bancas y Telegramas) ---");
await SondearProyeccionBancasAsync(authToken, stoppingToken);
await SondearNuevosTelegramasAsync(authToken, stoppingToken);
}
//_logger.LogInformation("--- Iniciando sondeo de Nuevos Telegramas ---"); var cicloFin = DateTime.UtcNow;
//await SondearNuevosTelegramasAsync(authToken, stoppingToken); var duracionCiclo = cicloFin - cicloInicio;
_logger.LogInformation("Ciclo #{ciclo} completado en {duration} segundos.", cicloContador, duracionCiclo.TotalSeconds);
// --- ESPERA INTELIGENTE ---
// Esperamos lo que quede para completar 1 minuto desde el inicio del ciclo.
// Si el ciclo tardó 20 segundos, esperamos 40. Si tardó más de 1 minuto, la espera es mínima.
var tiempoDeEspera = TimeSpan.FromMinutes(1) - duracionCiclo;
if (tiempoDeEspera < TimeSpan.Zero)
{
tiempoDeEspera = TimeSpan.FromSeconds(5); // Una espera mínima si el ciclo se excedió
}
try try
{ {
_logger.LogInformation("Ciclo de sondeo completado. Esperando 5 minutos para el siguiente..."); _logger.LogInformation("Esperando {wait_seconds} segundos para el siguiente ciclo...", tiempoDeEspera.TotalSeconds);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); await Task.Delay(tiempoDeEspera, stoppingToken);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+a4e47b6e3d1f8b0746f4f910f56a94e17b2e030c")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -203,6 +203,10 @@
"Microsoft.Extensions.Http": { "Microsoft.Extensions.Http": {
"target": "Package", "target": "Package",
"version": "[9.0.8, )" "version": "[9.0.8, )"
},
"System.Threading.RateLimiting": {
"target": "Package",
"version": "[9.0.8, )"
} }
}, },
"imports": [ "imports": [
@@ -309,6 +313,10 @@
"Serilog.Sinks.File": { "Serilog.Sinks.File": {
"target": "Package", "target": "Package",
"version": "[7.0.0, )" "version": "[7.0.0, )"
},
"System.Threading.RateLimiting": {
"target": "Package",
"version": "[9.0.8, )"
} }
}, },
"imports": [ "imports": [