Feat Rate Limit para cuotear peticiones.
This commit is contained in:
		| @@ -14,7 +14,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Api")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
| @@ -306,6 +306,10 @@ | ||||
|             "Microsoft.Extensions.Http": { | ||||
|               "target": "Package", | ||||
|               "version": "[9.0.8, )" | ||||
|             }, | ||||
|             "System.Threading.RateLimiting": { | ||||
|               "target": "Package", | ||||
|               "version": "[9.0.8, )" | ||||
|             } | ||||
|           }, | ||||
|           "imports": [ | ||||
|   | ||||
| @@ -7,6 +7,7 @@ | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.8" /> | ||||
|     <PackageReference Include="System.Threading.RateLimiting" Version="9.0.8" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|   | ||||
| @@ -7,6 +7,7 @@ using System.Text.Json; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System.Threading.Tasks; | ||||
| using static Elecciones.Core.DTOs.BancaDto; | ||||
| using System.Threading.RateLimiting; | ||||
|  | ||||
| namespace Elecciones.Infrastructure.Services; | ||||
|  | ||||
| @@ -15,205 +16,308 @@ public class ElectoralApiService : IElectoralApiService | ||||
|     private readonly IHttpClientFactory _httpClientFactory; | ||||
|     private readonly IConfiguration _configuration; | ||||
|     private readonly ILogger<ElectoralApiService> _logger; | ||||
|     private readonly RateLimiter _rateLimiter; | ||||
|  | ||||
|     public ElectoralApiService(IHttpClientFactory httpClientFactory, | ||||
|     IConfiguration configuration, | ||||
|         ILogger<ElectoralApiService> logger) | ||||
|         IConfiguration configuration, | ||||
|         ILogger<ElectoralApiService> logger, | ||||
|         RateLimiter rateLimiter) | ||||
|     { | ||||
|         _httpClientFactory = httpClientFactory; | ||||
|         _configuration = configuration; | ||||
|         _logger = logger; | ||||
|         _rateLimiter = rateLimiter; | ||||
|     } | ||||
|  | ||||
|     public async Task<string?> GetAuthTokenAsync() | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient("ElectoralApiClient"); | ||||
|         var username = _configuration["ElectoralApi:Username"]; | ||||
|         var password = _configuration["ElectoralApi:Password"]; | ||||
|         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; | ||||
|         // "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); | ||||
|  | ||||
|         // Si se nos concede el permiso para proceder... | ||||
|         if (lease.IsAcquired) | ||||
|         { | ||||
|             var client = _httpClientFactory.CreateClient("ElectoralApiClient"); | ||||
|             var username = _configuration["ElectoralApi:Username"]; | ||||
|             var password = _configuration["ElectoralApi:Password"]; | ||||
|             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) | ||||
|     { | ||||
|         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; | ||||
|         // "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); | ||||
|  | ||||
|         // 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) | ||||
|     { | ||||
|         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; | ||||
|         // "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); | ||||
|  | ||||
|         // Si se nos concede el permiso para proceder... | ||||
|         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) | ||||
|     { | ||||
|         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 | ||||
|         var requestUri = $"/api/resultados/getResultados?distritoId={distritoId}&seccionId={seccionId}&categoriaId={categoriaId}"; | ||||
|  | ||||
|         // Añadimos el municipioId a la URL SÓLO si no es nulo o vacío | ||||
|         if (!string.IsNullOrEmpty(municipioId)) | ||||
|         // Si se nos concede el permiso para proceder... | ||||
|         if (lease.IsAcquired) | ||||
|         { | ||||
|             requestUri += $"&municipioId={municipioId}"; | ||||
|         } | ||||
|             var client = _httpClientFactory.CreateClient("ElectoralApiClient"); | ||||
|  | ||||
|         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; | ||||
|             // Construimos la URL base | ||||
|             var requestUri = $"/api/resultados/getResultados?distritoId={distritoId}&seccionId={seccionId}&categoriaId={categoriaId}"; | ||||
|  | ||||
|             // 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) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient("ElectoralApiClient"); | ||||
|         var requestUri = $"/api/resultados/getBancas?distritoId={distritoId}&categoriaId={categoriaId}"; | ||||
|         // "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); | ||||
|  | ||||
|         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); | ||||
|         request.Headers.Add("Authorization", $"Bearer {authToken}"); | ||||
|             if (!string.IsNullOrEmpty(seccionProvincialId)) | ||||
|             { | ||||
|                 requestUri += $"&seccionProvincialId={seccionProvincialId}"; | ||||
|             } | ||||
|  | ||||
|         HttpResponseMessage response; | ||||
|         try | ||||
|         { | ||||
|             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; | ||||
|         } | ||||
|             var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||||
|             request.Headers.Add("Authorization", $"Bearer {authToken}"); | ||||
|  | ||||
|         // Comprobamos que la respuesta fue exitosa Y que contiene datos antes de intentar leerla. | ||||
|         if (response.IsSuccessStatusCode && response.Content?.Headers.ContentLength > 0) | ||||
|         { | ||||
|             HttpResponseMessage response; | ||||
|             try | ||||
|             { | ||||
|                 // Solo si hay contenido, intentamos deserializar. | ||||
|                 return await response.Content.ReadFromJsonAsync<RepartoBancasDto>(); | ||||
|                 response = await client.SendAsync(request); | ||||
|             } | ||||
|             catch (JsonException ex) | ||||
|             catch (Exception 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); | ||||
|                 // 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; | ||||
|             } | ||||
|         } | ||||
|         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; | ||||
|     } | ||||
|  | ||||
|     public async Task<List<string>?> GetTelegramasTotalizadosAsync(string authToken, string distritoId, string seccionId, int? categoriaId = null) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient("ElectoralApiClient"); | ||||
|         var requestUri = $"/api/resultados/getTelegramasTotalizados?distritoId={distritoId}&seccionId={seccionId}"; | ||||
|         // "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); | ||||
|  | ||||
|         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; | ||||
|         } | ||||
|  | ||||
|         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. | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public async Task<TelegramaFileDto?> GetTelegramaFileAsync(string authToken, string mesaId) | ||||
|     { | ||||
|         var client = _httpClientFactory.CreateClient("ElectoralApiClient"); | ||||
|         var requestUri = $"/api/resultados/getFile?mesaId={mesaId}"; | ||||
|         var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||||
|         request.Headers.Add("Authorization", $"Bearer {authToken}"); | ||||
|         // "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); | ||||
|  | ||||
|         HttpResponseMessage response; | ||||
|         try | ||||
|         // Si se nos concede el permiso para proceder... | ||||
|         if (lease.IsAcquired) | ||||
|         { | ||||
|             response = await client.SendAsync(request); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogError(ex, "La petición HTTP a getFile falló para la mesa {mesaId}", mesaId); | ||||
|             return null; | ||||
|         } | ||||
|             var client = _httpClientFactory.CreateClient("ElectoralApiClient"); | ||||
|             var requestUri = $"/api/resultados/getFile?mesaId={mesaId}"; | ||||
|             var request = new HttpRequestMessage(HttpMethod.Get, requestUri); | ||||
|             request.Headers.Add("Authorization", $"Bearer {authToken}"); | ||||
|  | ||||
|         if (response.IsSuccessStatusCode && response.Content?.Headers.ContentLength > 0) | ||||
|         { | ||||
|             HttpResponseMessage response; | ||||
|             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.LogWarning(ex, "La API devolvió una respuesta no-JSON para la mesa {mesaId}. Status: {statusCode}", mesaId, response.StatusCode); | ||||
|                 _logger.LogError(ex, "La petición HTTP a getFile falló para la mesa {mesaId}", mesaId); | ||||
|                 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; | ||||
|     } | ||||
|  | ||||
|     public async Task<ResumenDto?> GetResumenAsync(string authToken, string distritoId) | ||||
|     { | ||||
|         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; | ||||
|         // "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); | ||||
|  | ||||
|         // Si se nos concede el permiso para proceder... | ||||
|         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) | ||||
|     { | ||||
|         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; | ||||
|         // "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); | ||||
|  | ||||
|         // Si se nos concede el permiso para proceder... | ||||
|         if (lease.IsAcquired) | ||||
|         { | ||||
|             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) | ||||
|     { | ||||
|         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; | ||||
|         // "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); | ||||
|  | ||||
|         // 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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -13,7 +13,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Infrastructure")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
| @@ -126,6 +126,10 @@ | ||||
|             "Microsoft.Extensions.Http": { | ||||
|               "target": "Package", | ||||
|               "version": "[9.0.8, )" | ||||
|             }, | ||||
|             "System.Threading.RateLimiting": { | ||||
|               "target": "Package", | ||||
|               "version": "[9.0.8, )" | ||||
|             } | ||||
|           }, | ||||
|           "imports": [ | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
|     <PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" /> | ||||
|     <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> | ||||
|     <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> | ||||
|     <PackageReference Include="System.Threading.RateLimiting" Version="9.0.8" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|   | ||||
| @@ -11,6 +11,7 @@ using System.Net.Security; | ||||
| using System.Security.Authentication; | ||||
| using Polly; | ||||
| using Polly.Extensions.Http; | ||||
| using System.Threading.RateLimiting; | ||||
|  | ||||
| Log.Logger = new LoggerConfiguration() | ||||
|     .WriteTo.Console() | ||||
| @@ -81,6 +82,26 @@ builder.Services.AddHttpClient("ElectoralApiClient", client => | ||||
|  | ||||
| .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.AddHostedService<Worker>(); | ||||
|   | ||||
| @@ -41,38 +41,54 @@ public class Worker : BackgroundService | ||||
|         _logger.LogInformation("Iniciando sondeo periódico de resultados..."); | ||||
|         _logger.LogInformation("-------------------------------------------------"); | ||||
|  | ||||
|         int cicloContador = 0; | ||||
|  | ||||
|         while (!stoppingToken.IsCancellationRequested) | ||||
|         { | ||||
|             var cicloInicio = DateTime.UtcNow; | ||||
|             cicloContador++; | ||||
|  | ||||
|             var authToken = await _apiService.GetAuthTokenAsync(); | ||||
|             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); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // --- CAMBIO CLAVE: DE PARALELO A SECUENCIAL --- | ||||
|             // Se elimina Task.WhenAll y se ejecutan las tareas una después de la otra. | ||||
|             // Esto previene los errores de Gateway Timeout (504). | ||||
|             _logger.LogInformation("--- Iniciando sondeo de Resultados Municipales ---"); | ||||
|             // --- CICLO CALIENTE: TAREAS DE ALTA PRIORIDAD (SIEMPRE SE EJECUTAN) --- | ||||
|             _logger.LogInformation("--- Iniciando Ciclo Caliente #{ciclo} ---", cicloContador); | ||||
|  | ||||
|             await SondearResultadosMunicipalesAsync(authToken, stoppingToken); | ||||
|  | ||||
|             _logger.LogInformation("--- Iniciando sondeo de Resumen Provincial ---"); | ||||
|             await SondearResumenProvincialAsync(authToken, stoppingToken); | ||||
|  | ||||
|             _logger.LogInformation("--- Iniciando sondeo de Estado de Recuento General ---"); | ||||
|             await SondearEstadoRecuentoGeneralAsync(authToken, stoppingToken); | ||||
|  | ||||
|             _logger.LogInformation("--- Iniciando sondeo de Proyección de Bancas ---"); | ||||
|             await SondearProyeccionBancasAsync(authToken, stoppingToken); | ||||
|             // --- CICLO FRÍO: TAREAS DE BAJA PRIORIDAD (SE EJECUTAN CADA 5 CICLOS) --- | ||||
|             // 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 ---"); | ||||
|             //await SondearNuevosTelegramasAsync(authToken, stoppingToken); | ||||
|             var cicloFin = DateTime.UtcNow; | ||||
|             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 | ||||
|             { | ||||
|                 _logger.LogInformation("Ciclo de sondeo completado. Esperando 5 minutos para el siguiente..."); | ||||
|                 await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); | ||||
|                 _logger.LogInformation("Esperando {wait_seconds} segundos para el siguiente ciclo...", tiempoDeEspera.TotalSeconds); | ||||
|                 await Task.Delay(tiempoDeEspera, stoppingToken); | ||||
|             } | ||||
|             catch (TaskCanceledException) | ||||
|             { | ||||
|   | ||||
| @@ -14,7 +14,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Worker")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
| @@ -203,6 +203,10 @@ | ||||
|             "Microsoft.Extensions.Http": { | ||||
|               "target": "Package", | ||||
|               "version": "[9.0.8, )" | ||||
|             }, | ||||
|             "System.Threading.RateLimiting": { | ||||
|               "target": "Package", | ||||
|               "version": "[9.0.8, )" | ||||
|             } | ||||
|           }, | ||||
|           "imports": [ | ||||
| @@ -309,6 +313,10 @@ | ||||
|             "Serilog.Sinks.File": { | ||||
|               "target": "Package", | ||||
|               "version": "[7.0.0, )" | ||||
|             }, | ||||
|             "System.Threading.RateLimiting": { | ||||
|               "target": "Package", | ||||
|               "version": "[9.0.8, )" | ||||
|             } | ||||
|           }, | ||||
|           "imports": [ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user