"""R4: KIS 서킷브레이커는 주문(청산 포함)을 차단하지 않고, 주문 실패는 조회 breaker 에 세지 않는다.""" import asyncio import pytest from app.services import korea_investment_api as kia from app.services.utils import CircuitBreakerState ORDER_EP = "/uapi/domestic-stock/v1/trading/order-cash" QUOTE_EP = "/uapi/domestic-stock/v1/quotations/inquire-price" class _Resp: def __init__(self, status=200, body=None): self.status_code, self._b, self.headers, self.text = status, body or {}, {}, "" def json(self): return self._b class _Client: def __init__(self, resp): self.resp, self.calls = resp, 0 async def request(self, *a, **k): self.calls += 1 return self.resp def _api(monkeypatch, resp): api = kia.KoreaInvestmentAPI.__new__(kia.KoreaInvestmentAPI) api.circuit_breaker = kia.CircuitBreaker(failure_threshold=2, recovery_timeout=120) api.monitor = kia.APICallMonitor() api.semaphore = asyncio.Semaphore(2) api._throttle_lock = asyncio.Lock() api.last_request_time = None api.min_request_interval = 0 api.max_retries = 0 api.retry_delay = 0 api.retry_backoff = 1 api.timeout = 1 api.app_key = api.app_secret = "x" api.base_url = "http://x" client = _Client(resp) async def _tok(*a, **k): return "tok" async def _cl(): return client monkeypatch.setattr(api, "_get_access_token", _tok, raising=False) monkeypatch.setattr(api, "_get_http_client", _cl, raising=False) return api, client def _open(api): for _ in range(api.circuit_breaker.failure_threshold): api.circuit_breaker.record_failure() assert api.circuit_breaker.state == CircuitBreakerState.OPEN @pytest.mark.asyncio async def test_order_not_blocked_when_breaker_open(monkeypatch): api, client = _api(monkeypatch, _Resp(200, {"rt_cd": "0"})) _open(api) out, _ = await api._make_request("POST", ORDER_EP, "TTTC0801U") assert out["rt_cd"] == "0" and client.calls == 1 with pytest.raises(Exception, match="Circuit breaker open"): await api._make_request("GET", QUOTE_EP, "X") assert client.calls == 1 @pytest.mark.asyncio async def test_order_failures_do_not_trip_breaker(monkeypatch): api, _ = _api(monkeypatch, _Resp(500, {"rt_cd": "1", "msg_cd": "EGW00201"})) for _ in range(api.circuit_breaker.failure_threshold + 3): await api._make_request("POST", ORDER_EP, "TTTC0801U") assert api.circuit_breaker.state == CircuitBreakerState.CLOSED assert api.circuit_breaker.failure_count == 0 @pytest.mark.asyncio async def test_query_failures_still_trip_and_alert(monkeypatch): sent = [] from app.services import telegram_notifier as tn async def _warn(cat, msg): sent.append(cat) monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", _warn) api, _ = _api(monkeypatch, _Resp(500, {})) for _ in range(api.circuit_breaker.failure_threshold): with pytest.raises(Exception): await api._make_request("GET", QUOTE_EP, "X") await asyncio.sleep(0) assert api.circuit_breaker.state == CircuitBreakerState.OPEN assert api.circuit_breaker.open_count == 1 assert sent == ["KIS_CIRCUIT_BREAKER"] assert api.circuit_breaker.health()["open_count"] == 1 assert "korea_investment_api" in kia.get_kis_breaker_health_v2()