diff --git a/src/dotnet/QuantEngine.Infrastructure/Services/KisApiClient.cs b/src/dotnet/QuantEngine.Infrastructure/Services/KisApiClient.cs index 678b3569..ed4227b5 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Services/KisApiClient.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Services/KisApiClient.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Net.Http.Json; @@ -29,6 +31,7 @@ public class KisApiClient : IKisApiClient private readonly HttpClient _httpClient; private readonly ITokenCache _tokenCache; private readonly ILogger _logger; + private static readonly ConcurrentDictionary TokenLocks = new(); public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger logger) { @@ -159,40 +162,54 @@ public class KisApiClient : IKisApiClient private async Task GetOrRefreshTokenAsync(KisCredentials creds) { - var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account); - if (!string.IsNullOrEmpty(cachedToken)) - return cachedToken; - - var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret }; - + var tokenLock = TokenLocks.GetOrAdd(creds.Account, _ => new SemaphoreSlim(1, 1)); + await tokenLock.WaitAsync(); try { - var response = await _httpClient.PostAsJsonAsync( - $"{creds.Domain}/oauth2/tokenP", - tokenRequest - ); - response.EnsureSuccessStatusCode(); + // Re-check after acquiring the account lock. Another request may + // have refreshed the shared cache while this request was waiting. + var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account); + if (!string.IsNullOrEmpty(cachedToken)) + return cachedToken; - var tokenData = await response.Content.ReadFromJsonAsync>(); - if (tokenData == null) throw new InvalidOperationException("Token response body is empty"); + var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret }; - if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null) - throw new InvalidOperationException("No access_token in response"); - var accessToken = tokenObj.ToString()!; + try + { + var response = await _httpClient.PostAsJsonAsync( + $"{creds.Domain}/oauth2/tokenP", + tokenRequest + ); + response.EnsureSuccessStatusCode(); - var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null - ? expiresObj.ToString() - : "86400"; - var expiresInSec = int.TryParse(expiresInStr, out var seconds) ? seconds : 86400; - var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec); + var tokenData = await response.Content.ReadFromJsonAsync>(); + if (tokenData == null) throw new InvalidOperationException("Token response body is empty"); - await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt); - return accessToken; + if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null) + throw new InvalidOperationException("No access_token in response"); + var accessToken = tokenObj.ToString()!; + + var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null + ? expiresObj.ToString() + : "86400"; + var expiresInSec = int.TryParse(expiresInStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) + ? seconds + : 86400; + var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec); + + await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt); + _logger.LogInformation("KIS token refreshed for {Account}; expires at {ExpiresAtUtc}", creds.Account, expiresAt); + return accessToken; + } + catch (Exception ex) + { + _logger.LogError(ex, "KIS token refresh failed"); + throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex); + } } - catch (Exception ex) + finally { - _logger.LogError(ex, "KIS token refresh failed"); - throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex); + tokenLock.Release(); } } diff --git a/src/dotnet/QuantEngine.Infrastructure/Services/PostgresTokenCache.cs b/src/dotnet/QuantEngine.Infrastructure/Services/PostgresTokenCache.cs index 392eeb41..320acc9d 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Services/PostgresTokenCache.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Services/PostgresTokenCache.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Threading.Tasks; using Dapper; using QuantEngine.Core.Interfaces; @@ -31,7 +32,11 @@ namespace QuantEngine.Infrastructure.Services if (token == null) return null; - var expiresAt = DateTime.Parse(token.ExpiresAt); + DateTime expiresAt; + if (!DateTime.TryParse((string)token.ExpiresAt, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out expiresAt)) + return null; var now = DateTime.UtcNow; var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);