Port the trex-orbit Redis token-cache pattern so the KIS access token
survives daemon restarts and is shared across processes, avoiding KIS
token-issuance rate limits. KISBroker._ensure_token now resolves L1
in-memory -> L2 cache -> issue, storing issued tokens back to the cache.
- adapters/broker/token_cache.py: TokenCache protocol + RedisTokenCache
(key trex:broker:kis:{account_ref}:token, token+expiry+app_key suffix
JSON, ex=ttl, best-effort with graceful fallback) + InMemoryTokenCache.
- kis.py: optional token_cache injection.
- runtime config/daemon: TREX_KIS_REDIS_URL wires a RedisTokenCache.
- pyproject: redis>=5.
Verification: 96 passed, ruff clean — cache hit avoids issuance, cache is
shared across instances (restart/multi-process survival), backend errors
fall back to issuance (no regression), and app_key-suffix mismatch
invalidates a stale cache. Tested deterministically with an in-memory
double (no Redis server required). Processed via the minor-change fast
path: REQ-016 -> Implemented, BAT-005 (batch-lite) -> release-candidate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
2.8 KiB
Python
89 lines
2.8 KiB
Python
"""KIS token cache tests (REQ-016).
|
|
|
|
Uses InMemoryTokenCache as a deterministic double + a stubbed token endpoint
|
|
(httpx.post monkeypatched) so no real Redis server or network is needed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from trex_core.adapters.broker.kis import KISBroker
|
|
from trex_core.adapters.broker.token_cache import InMemoryTokenCache
|
|
|
|
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
|
|
|
|
|
class _TokenResp:
|
|
status_code = 200
|
|
|
|
def __init__(self, token: str) -> None:
|
|
self._token = token
|
|
|
|
def raise_for_status(self) -> None:
|
|
pass
|
|
|
|
def json(self) -> dict:
|
|
return {"access_token": self._token, "expires_in": 86400}
|
|
|
|
|
|
@pytest.fixture
|
|
def count_token_calls(monkeypatch):
|
|
calls = {"n": 0}
|
|
|
|
def fake_post(url, **kwargs):
|
|
assert url.endswith("/oauth2/tokenP")
|
|
calls["n"] += 1
|
|
return _TokenResp(f"TOK-{calls['n']}")
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
return calls
|
|
|
|
|
|
def _broker(cache, app_key="appkey0001"):
|
|
return KISBroker("12345678-01", app_key=app_key, app_secret="s",
|
|
base_url=_MOCK_URL, token_cache=cache)
|
|
|
|
|
|
def test_cache_hit_avoids_issuance(count_token_calls):
|
|
cache = InMemoryTokenCache()
|
|
b = _broker(cache)
|
|
t1 = b._ensure_token() # L3 issue + store
|
|
assert count_token_calls["n"] == 1
|
|
b._token = None # drop L1 to force L2 lookup
|
|
t2 = b._ensure_token() # L2 cache hit — no new issuance
|
|
assert t2 == t1 and count_token_calls["n"] == 1
|
|
|
|
|
|
def test_shared_cache_across_instances(count_token_calls):
|
|
cache = InMemoryTokenCache()
|
|
a = _broker(cache)
|
|
tok = a._ensure_token() # instance A issues + stores
|
|
assert count_token_calls["n"] == 1
|
|
b = _broker(cache) # instance B (e.g. after restart) shares cache
|
|
assert b._ensure_token() == tok and count_token_calls["n"] == 1
|
|
|
|
|
|
def test_graceful_fallback_when_cache_errors(count_token_calls):
|
|
# RedisTokenCache swallows backend errors and returns None on miss; this
|
|
# double models that post-swallow behaviour, so the broker still issues.
|
|
class SilentCache:
|
|
def load(self, *a, **k):
|
|
return None
|
|
|
|
def store(self, *a, **k):
|
|
return None
|
|
|
|
b = _broker(SilentCache())
|
|
assert b._ensure_token() == "TOK-1" and count_token_calls["n"] == 1
|
|
|
|
|
|
def test_app_key_suffix_mismatch_ignores_cache(count_token_calls):
|
|
cache = InMemoryTokenCache()
|
|
a = _broker(cache, app_key="AAAA1111")
|
|
a._ensure_token() # cached under suffix 1111
|
|
assert count_token_calls["n"] == 1
|
|
b = _broker(cache, app_key="BBBB2222") # rotated credential, suffix 2222
|
|
b._ensure_token() # must NOT reuse stale cache -> re-issue
|
|
assert count_token_calls["n"] == 2
|