274 lines
8.5 KiB
Python
274 lines
8.5 KiB
Python
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
|