from __future__ import annotations import logging from pathlib import Path import pytest import requests from pydantic import SecretStr from app.integrations.gateway import ( API_KEY_HEADER, GatewayAuthError, GatewayClient, GatewayError, GatewayNotFoundError, GatewayUnavailableError, UpstreamNotFoundError, ) TX_HEADER = "X-Gravitee-Transaction-Id" SECRET = "super-secret-moex-key-7f3a" class FakeResponse: def __init__(self, status_code: int, text: str = "", headers=None) -> None: self.status_code = status_code self.text = text self.content = text.encode("utf-8") self.headers = headers or {} class FakeSession: """Подменённая requests.Session: отдаёт ответы/исключения по очереди.""" def __init__(self, *outcomes) -> None: # noqa: ANN002 self._outcomes = list(outcomes) self.calls: list[dict] = [] def get(self, url, **kwargs): # noqa: ANN001, ANN003 self.calls.append({"url": url, **kwargs}) outcome = self._outcomes.pop(0) if isinstance(outcome, Exception): raise outcome return outcome def _client(session: FakeSession, *, retries: int = 3, key: str = SECRET): sleeps: list[float] = [] client = GatewayClient( source="moex", base_url="https://gateway.test", path_prefix="/external/moex-iss", api_key=SecretStr(key), retries=retries, session=session, sleep=sleeps.append, ) return client, sleeps def test_success_sends_key_header_and_records_request() -> None: session = FakeSession(FakeResponse(200, "{}")) client, sleeps = _client(session) response = client.get("/iss/securities.json", {"q": "RU000A106169"}) assert response.status_code == 200 assert session.calls[0]["url"] == ( "https://gateway.test/external/moex-iss/iss/securities.json" ) assert session.calls[0]["headers"] == {API_KEY_HEADER: SECRET} assert session.calls[0]["timeout"] == 15 assert sleeps == [] [record] = client.drain_records() assert record.ok is True assert record.http_status == 200 assert record.endpoint == "/external/moex-iss/iss/securities.json" assert record.params == {"q": "RU000A106169"} assert client.drain_records() == [] def test_502_then_200_is_retried() -> None: session = FakeSession(FakeResponse(502, "Bad Gateway"), FakeResponse(200, "{}")) client, sleeps = _client(session) response = client.get("/iss/securities.json") assert response.status_code == 200 assert len(session.calls) == 2 assert sleeps == [1.0] [record] = client.drain_records() assert record.ok is True @pytest.mark.parametrize("status", [429, 502, 503, 504]) def test_retryable_statuses_exhaust_into_unavailable(status: int) -> None: session = FakeSession(*[FakeResponse(status) for _ in range(4)]) client, sleeps = _client(session, retries=3) with pytest.raises(GatewayUnavailableError) as exc_info: client.get("/iss/securities.json") assert len(session.calls) == 4 # первая попытка + 3 повтора assert sleeps == [1.0, 2.0, 4.0] assert exc_info.value.http_status == status [record] = client.drain_records() assert record.ok is False assert record.http_status == status def test_429_respects_retry_after() -> None: session = FakeSession( FakeResponse(429, headers={"Retry-After": "7"}), FakeResponse(200, "{}") ) client, sleeps = _client(session) client.get("/iss/securities.json") assert sleeps == [7.0] def test_network_timeout_is_retried() -> None: session = FakeSession( requests.ConnectTimeout("connect timeout"), requests.ConnectionError("reset"), FakeResponse(200, "{}"), ) client, _ = _client(session) assert client.get("/iss/securities.json").status_code == 200 assert len(session.calls) == 3 def test_network_errors_exhaust_into_unavailable() -> None: session = FakeSession(requests.ReadTimeout("read timeout")) client, _ = _client(session, retries=0) with pytest.raises(GatewayUnavailableError, match="таймаут"): client.get("/iss/securities.json") @pytest.mark.parametrize("status", [401, 403]) def test_auth_errors_are_not_retried(status: int, fixtures_dir: Path) -> None: body = (fixtures_dir / "gateway_401_unauthorized.txt").read_text("utf-8") session = FakeSession(FakeResponse(status, body)) client, sleeps = _client(session) with pytest.raises(GatewayAuthError) as exc_info: client.get("/iss/securities.json") assert len(session.calls) == 1 assert sleeps == [] assert "MOEX_API_KEY" in str(exc_info.value) assert exc_info.value.http_status == status def test_404_no_context_path_is_gateway_not_found() -> None: body = ( '{"message":"No context-path matches the request URI.","http_status_code":404}' ) session = FakeSession(FakeResponse(404, body)) client, sleeps = _client(session) with pytest.raises(GatewayNotFoundError) as exc_info: client.get("/iss/securities.json") assert len(session.calls) == 1 assert sleeps == [] assert "MOEX_PATH" in str(exc_info.value) def test_plain_404_is_upstream_not_found_not_gateway_path() -> None: session = FakeSession(FakeResponse(404, "Not Found")) client, _ = _client(session) with pytest.raises(UpstreamNotFoundError) as exc_info: client.get("/iss/securities.json") assert not isinstance(exc_info.value, GatewayNotFoundError) assert len(session.calls) == 1 def test_500_is_not_retried() -> None: session = FakeSession(FakeResponse(500, "Internal error")) client, _ = _client(session) with pytest.raises(GatewayError) as exc_info: client.get("/iss/securities.json") assert type(exc_info.value) is GatewayError assert len(session.calls) == 1 def test_missing_key_fails_without_request() -> None: session = FakeSession() client, _ = _client(session, key="") with pytest.raises(GatewayAuthError, match="Не задан ключ"): client.get("/iss/securities.json") assert session.calls == [] def test_key_never_appears_in_logs_errors_or_records(caplog) -> None: caplog.set_level(logging.DEBUG) session = FakeSession( FakeResponse(200, "{}"), FakeResponse(502), FakeResponse(401, "Unauthorized"), requests.ConnectionError("connection refused"), requests.ConnectionError("connection refused"), ) client, _ = _client(session, retries=1) client.get("/iss/securities.json", {"q": "RU000A106169"}) errors: list[str] = [] for _ in range(2): with pytest.raises(GatewayError) as exc_info: client.get("/iss/securities.json") errors.append(str(exc_info.value)) assert caplog.records, "клиент должен логировать запросы" assert SECRET not in caplog.text assert API_KEY_HEADER not in caplog.text assert all(SECRET not in message for message in errors) assert all(SECRET not in repr(record) for record in client.drain_records()) assert SECRET not in repr(client) def test_gravitee_transaction_id_is_recorded_on_success() -> None: session = FakeSession(FakeResponse(200, "{}", headers={TX_HEADER: "tx-ok-1"})) client, _ = _client(session) client.get("/iss/securities.json") [record] = client.drain_records() assert record.transaction_id == "tx-ok-1" def test_gravitee_transaction_id_of_last_attempt_on_exhausted_retries() -> None: session = FakeSession( FakeResponse(502, headers={TX_HEADER: "tx-1"}), FakeResponse(502, headers={TX_HEADER: "tx-2"}), ) client, _ = _client(session, retries=1) with pytest.raises(GatewayUnavailableError) as exc_info: client.get("/iss/securities.json") assert exc_info.value.transaction_id == "tx-2" [record] = client.drain_records() assert record.transaction_id == "tx-2" def test_gravitee_transaction_id_on_auth_error_and_absent_on_network_error( caplog, ) -> None: caplog.set_level(logging.INFO) session = FakeSession( FakeResponse(401, "Unauthorized", headers={TX_HEADER: "tx-401"}), requests.ConnectionError("connection refused"), ) client, _ = _client(session, retries=0) for _ in range(2): with pytest.raises(GatewayError): client.get("/iss/securities.json") auth, network = client.drain_records() assert auth.transaction_id == "tx-401" assert network.transaction_id is None assert "tx=tx-401" in caplog.text