fix: reuse KIS tokens across concurrent requests
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 11s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 51s

This commit is contained in:
2026-07-12 11:32:13 +09:00
parent 129e2ec2d7
commit 780ccee1fe
2 changed files with 49 additions and 27 deletions
@@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -29,6 +31,7 @@ public class KisApiClient : IKisApiClient
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ITokenCache _tokenCache; private readonly ITokenCache _tokenCache;
private readonly ILogger<KisApiClient> _logger; private readonly ILogger<KisApiClient> _logger;
private static readonly ConcurrentDictionary<string, SemaphoreSlim> TokenLocks = new();
public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger) public KisApiClient(HttpClient httpClient, ITokenCache tokenCache, ILogger<KisApiClient> logger)
{ {
@@ -159,40 +162,54 @@ public class KisApiClient : IKisApiClient
private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds) private async Task<string> GetOrRefreshTokenAsync(KisCredentials creds)
{ {
var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account); var tokenLock = TokenLocks.GetOrAdd(creds.Account, _ => new SemaphoreSlim(1, 1));
if (!string.IsNullOrEmpty(cachedToken)) await tokenLock.WaitAsync();
return cachedToken;
var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
try try
{ {
var response = await _httpClient.PostAsJsonAsync( // Re-check after acquiring the account lock. Another request may
$"{creds.Domain}/oauth2/tokenP", // have refreshed the shared cache while this request was waiting.
tokenRequest var cachedToken = await _tokenCache.GetCachedTokenAsync(creds.Account);
); if (!string.IsNullOrEmpty(cachedToken))
response.EnsureSuccessStatusCode(); return cachedToken;
var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>(); var tokenRequest = new { grant_type = "client_credentials", appkey = creds.AppKey, appsecret = creds.AppSecret };
if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null) try
throw new InvalidOperationException("No access_token in response"); {
var accessToken = tokenObj.ToString()!; var response = await _httpClient.PostAsJsonAsync(
$"{creds.Domain}/oauth2/tokenP",
tokenRequest
);
response.EnsureSuccessStatusCode();
var expiresInStr = tokenData.TryGetValue("expires_in", out var expiresObj) && expiresObj != null var tokenData = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();
? expiresObj.ToString() if (tokenData == null) throw new InvalidOperationException("Token response body is empty");
: "86400";
var expiresInSec = int.TryParse(expiresInStr, out var seconds) ? seconds : 86400;
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
await _tokenCache.SaveTokenAsync(creds.Account, accessToken, expiresAt); if (!tokenData.TryGetValue("access_token", out var tokenObj) || tokenObj == null)
return accessToken; 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"); tokenLock.Release();
throw new InvalidOperationException("KIS token refresh failed; check credentials and API availability.", ex);
} }
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Globalization;
using System.Threading.Tasks; using System.Threading.Tasks;
using Dapper; using Dapper;
using QuantEngine.Core.Interfaces; using QuantEngine.Core.Interfaces;
@@ -31,7 +32,11 @@ namespace QuantEngine.Infrastructure.Services
if (token == null) if (token == null)
return 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 now = DateTime.UtcNow;
var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes); var refreshSkew = TimeSpan.FromMinutes(TokenRefreshSkewMinutes);