Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: "lint and test"

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
types: [ opened, synchronize, reopened ]

permissions:
contents: read
pull-requests: write

jobs:
lint-and-test:
uses: esclient/tools/.github/workflows/lint-and-test-python.yml@v1.0.1
with:
python-version: "3.13.7"
source: "apigateway"
sonar-inclusions: "src/**,Dockerfile"
sonar-exclusions: "**/stubs/**"
sonar-coverage-exclusions: "src/apigateway/server.py,src/apigateway/settings.py"
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
20 changes: 0 additions & 20 deletions .github/workflows/lint-api-gateway.yml

This file was deleted.

256 changes: 254 additions & 2 deletions pdm.lock

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.pdm]
distribution = false
distribution = true

[tool.black]
line-length = 120
Expand Down Expand Up @@ -100,6 +100,25 @@ plugins = ["pydantic.mypy"]
module = ["apigateway.stubs.*"]
ignore_errors = true

[[tool.mypy.overrides]]
module = ["tests.*"]
ignore_errors = true

[tool.pytest.ini_options]
addopts = "--import-mode=importlib"
asyncio_mode = "strict"
testpaths = ["tests"]
python_files = ["test_*.py"]

[tool.coverage.run]
source = ["src/apigateway"]
relative_files = true

[tool.coverage.report]
include = [
"src/apigateway/*",
]

[project]
name = "api-gateway"
version = "0.0.1"
Expand All @@ -124,11 +143,16 @@ lint = [
"flake8>=7.3.0",
]
dev = [
"black>=25.9.0",
"faker>=25.0.0",
"flake8>=7.3.0",
"flake8-pyproject>=1.2.3",
"black>=25.9.0",
"isort>=6.0.1",
"mypy>=1.18.2",
"pytest>=8.3.0",
"pytest-cov==5.0.0",
"pytest-asyncio>=0.25.0",
"pytest-faker>=2.0.0",
"ruff>=0.13.1",
"types-grpcio>=1.0.0.20250914",
"types-protobuf>=6.32.1.20250918",
Expand Down
48 changes: 27 additions & 21 deletions src/apigateway/clients/base_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Self
from types import TracebackType
from typing import Self, TypeVar

import grpc
import grpc.aio
Expand All @@ -10,46 +11,51 @@

logger = logging.getLogger(__name__)

ResponseT = TypeVar("ResponseT", bound=_message.Message)


class GrpcError(Exception):
pass


class GrpcClient:
class GrpcClient[StubT]:
def __init__(self, channel: grpc.aio.Channel):
self._channel = channel
self._stub = None
self._initialize_stub()
self._stub: StubT = self._initialize_stub()

def _initialize_stub(self) -> None:
raise NotImplementedError()
def _initialize_stub(self) -> StubT:
raise NotImplementedError

@grpc_retry() # type: ignore
@grpc_retry()
async def call(
self,
rpc_method: Callable[["_message.Message"], Awaitable["_message.Message"]],
request: "_message.Message",
rpc_method: Callable[..., Awaitable[ResponseT]],
request: _message.Message,
*,
timeout: int = 30,
) -> "_message.Message":
) -> ResponseT:
method_name = getattr(rpc_method, "__name__", repr(rpc_method))

try:
response: _message.Message = await rpc_method(request, timeout=timeout) # type: ignore[operator]
response = await rpc_method(request, timeout=timeout)
return response
except grpc.RpcError as e:
logger.error(f"gRPC ошибка {rpc_method.__name__ if hasattr(rpc_method, '__name__') else rpc_method}: {e}")
raise GrpcError(f"gRPC Ошибка вызова: {e}") from e
except Exception as e:
logger.error(
f"Неизвестная ошибка при вызове {rpc_method.__name__ if hasattr(rpc_method, '__name__') else rpc_method}: {e}"
)
except grpc.RpcError as exc:
logger.error("gRPC error while calling %s: %s", method_name, exc)
raise GrpcError(f"gRPC call failed: {exc}") from exc
except Exception as exc:
logger.error("Unknown error while calling %s: %s", method_name, exc)
raise

async def close(self) -> None:
if self._channel:
await self._channel.close()
await self._channel.close()

async def __aenter__(self) -> Self:
return self

async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> None:
await self.close()
6 changes: 3 additions & 3 deletions src/apigateway/clients/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
from apigateway.stubs.comment import comment_pb2, comment_pb2_grpc


class CommentServiceClient(GrpcClient):
def _initialize_stub(self) -> None:
self._stub = comment_pb2_grpc.CommentServiceStub(self._channel) # type: ignore
class CommentServiceClient(GrpcClient[comment_pb2_grpc.CommentServiceStub]):
def _initialize_stub(self) -> comment_pb2_grpc.CommentServiceStub:
return comment_pb2_grpc.CommentServiceStub(self._channel) # type: ignore[no-untyped-call]

async def create_comment(self, mod_id: int, author_id: int, text: str) -> comment_pb2.CreateCommentResponse:
request = comment_pb2.CreateCommentRequest(mod_id=mod_id, author_id=author_id, text=text)
Expand Down
12 changes: 8 additions & 4 deletions src/apigateway/clients/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
from apigateway.stubs.mod import mod_pb2, mod_pb2_grpc


class ModServiceClient(GrpcClient):
def _initialize_stub(self) -> None:
self._stub = mod_pb2_grpc.ModServiceStub(self._channel) # type: ignore
class ModServiceClient(GrpcClient[mod_pb2_grpc.ModServiceStub]):
def _initialize_stub(self) -> mod_pb2_grpc.ModServiceStub:
return mod_pb2_grpc.ModServiceStub(self._channel) # type: ignore[no-untyped-call]

async def create_mod(
self, title: str, author_id: int, filename: str, description: str
self,
title: str,
author_id: int,
filename: str,
description: str,
) -> mod_pb2.CreateModResponse:
request = mod_pb2.CreateModRequest(title=title, author_id=author_id, filename=filename, description=description)
return await self.call(self._stub.CreateMod, request)
Expand Down
6 changes: 3 additions & 3 deletions src/apigateway/clients/rating.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
from apigateway.stubs.rating import rating_pb2, rating_pb2_grpc


class RatingServiceClient(GrpcClient):
def _initialize_stub(self) -> None:
self._stub = rating_pb2_grpc.RatingServiceStub(self._channel) # type: ignore
class RatingServiceClient(GrpcClient[rating_pb2_grpc.RatingServiceStub]):
def _initialize_stub(self) -> rating_pb2_grpc.RatingServiceStub:
return rating_pb2_grpc.RatingServiceStub(self._channel) # type: ignore[no-untyped-call]

async def rate_mod(self, mod_id: int, author_id: int, rate: str) -> rating_pb2.RateModResponse:
request = rating_pb2.RateModRequest(mod_id=mod_id, author_id=author_id, rate=rate)
Expand Down
2 changes: 1 addition & 1 deletion src/apigateway/esclient_graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

class GQLContextViewer:
def __init__(self) -> None:
self.clients: dict[str, GrpcClient] = {}
self.clients: dict[str, GrpcClient[Any]] = {}

def get_current(self, request: Any) -> dict[str, Any]:
return {"request": request, "clients": self.clients}
36 changes: 22 additions & 14 deletions src/apigateway/helpers/retry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import logging
from collections.abc import Callable
from typing import TypeVar

import grpc
from tenacity import RetryCallState, retry, retry_if_exception, stop_after_attempt, wait_exponential
Expand All @@ -12,6 +14,8 @@
RETRY_DELAY_MIN = 1
RETRY_DELAY_MAX = 10

F = TypeVar("F", bound=Callable[..., object])


def is_retryable_grpc_exception(exc: BaseException) -> bool:
if isinstance(exc, grpc.RpcError):
Expand All @@ -27,27 +31,31 @@ def log_retry_attempt(retry_state: RetryCallState) -> None:
if retry_state.outcome is None or not retry_state.outcome.failed or not hasattr(retry_state.outcome, "exception"):
return

try:
exception = retry_state.outcome.exception()
if exception is None:
return
exception = retry_state.outcome.exception() if retry_state.outcome else None
if exception is None:
return

sleep_time = getattr(retry_state.next_action, "sleep", 0) if retry_state.next_action else 0
sleep_time = getattr(retry_state.next_action, "sleep", 0.0) if retry_state.next_action else 0.0

logger.warning(
f"Повторная попытка {retry_state.attempt_number}/{RETRY_ATTEMPTS} "
f"для gRPC вызова. Ошибка: {exception} "
f"(следующая попытка через {sleep_time:.1f} сек)"
)
except Exception as e:
logger.debug(f"Ошибка при логировании повторной попытки: {e}")
logger.warning(
"Retry attempt %s/%s for gRPC call failed. Exception: %s (next sleep %.1fs)",
retry_state.attempt_number,
RETRY_ATTEMPTS,
exception,
sleep_time,
)


def grpc_retry(): # type: ignore
return retry(
def grpc_retry() -> Callable[[F], F]:
decorator = retry(
stop=stop_after_attempt(RETRY_ATTEMPTS),
wait=wait_exponential(multiplier=RETRY_DELAY_MULTIPLIER, min=RETRY_DELAY_MIN, max=RETRY_DELAY_MAX),
retry=retry_if_exception(is_retryable_grpc_exception),
reraise=True,
before_sleep=log_retry_attempt,
)

def _wrapper(func: F) -> F:
return decorator(func)

return _wrapper
100 changes: 100 additions & 0 deletions tests/clients/test_base_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import asyncio
from types import SimpleNamespace
from typing import Any, cast

import grpc
import grpc.aio
import pytest
from faker import Faker
from google.protobuf import message as _message

from apigateway.clients.base_client import GrpcClient, GrpcError


class _FakeChannel:
def __init__(self) -> None:
self.closed = False

async def close(self) -> None:
self.closed = True


class _FakeRpcError(grpc.RpcError):
def __init__(self, status_code: grpc.StatusCode) -> None:
super().__init__()
self._status_code = status_code

def code(self) -> grpc.StatusCode: # type: ignore[override]
return self._status_code


class _ConcreteClient(GrpcClient[SimpleNamespace]):
def __init__(self, channel: _FakeChannel) -> None:
self.stub_initialized = False
super().__init__(cast(grpc.aio.Channel, channel))

def _initialize_stub(self) -> SimpleNamespace:
self.stub_initialized = True
return SimpleNamespace()


@pytest.mark.asyncio
async def test_call_invokes_rpc_method_with_timeout(faker: Faker) -> None:
channel = _FakeChannel()
client = _ConcreteClient(channel)
captured: dict[str, Any] = {}
request_payload = cast(_message.Message, SimpleNamespace(payload=faker.pystr()))
expected_response = faker.pystr()
timeout_value = faker.random_int(min=1, max=60)

async def rpc_method(request: _message.Message, timeout: int = 30) -> str:
await asyncio.sleep(0)
captured["request"] = request
captured["timeout"] = timeout
return expected_response

result = await client.call(rpc_method, request=request_payload, timeout=timeout_value)

assert result == expected_response
assert captured == {"request": request_payload, "timeout": timeout_value}


@pytest.mark.asyncio
async def test_call_wraps_grpc_errors() -> None:
channel = _FakeChannel()
client = _ConcreteClient(channel)

async def failing_rpc(request: _message.Message, timeout: int = 30) -> str:
await asyncio.sleep(0)
raise _FakeRpcError(grpc.StatusCode.INVALID_ARGUMENT)

with pytest.raises(GrpcError) as exc:
await client.call(failing_rpc, request=cast(_message.Message, SimpleNamespace()))

assert "gRPC" in str(exc.value)


@pytest.mark.asyncio
async def test_call_propagates_other_exceptions(faker: Faker) -> None:
channel = _FakeChannel()
client = _ConcreteClient(channel)
error_message = faker.sentence(nb_words=3)

async def failing_rpc(request: _message.Message, timeout: int = 30) -> str:
await asyncio.sleep(0)
raise RuntimeError(error_message)

with pytest.raises(RuntimeError) as exc:
await client.call(failing_rpc, request=cast(_message.Message, SimpleNamespace()))

assert str(exc.value) == error_message


@pytest.mark.asyncio
async def test_close_closes_channel() -> None:
channel = _FakeChannel()
client = _ConcreteClient(channel)

await client.close()

assert channel.closed is True
Loading
Loading