grpc-server-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- grpc_server_kit/__init__.py +34 -0
- grpc_server_kit/__version__.py +1 -0
- grpc_server_kit/aio/__init__.py +44 -0
- grpc_server_kit/aio/app.py +368 -0
- grpc_server_kit/aio/builder.py +63 -0
- grpc_server_kit/aio/dishka/__init__.py +28 -0
- grpc_server_kit/aio/dishka/bundle.py +70 -0
- grpc_server_kit/aio/dishka/interceptors.py +116 -0
- grpc_server_kit/aio/dishka/metrics.py +50 -0
- grpc_server_kit/aio/dishka/observability.py +86 -0
- grpc_server_kit/aio/dishka/server.py +51 -0
- grpc_server_kit/aio/health/__init__.py +36 -0
- grpc_server_kit/aio/health/cache.py +89 -0
- grpc_server_kit/aio/health/checkers/__init__.py +16 -0
- grpc_server_kit/aio/health/checkers/postgres.py +115 -0
- grpc_server_kit/aio/health/checkers/redis.py +88 -0
- grpc_server_kit/aio/health/checkers/utils.py +97 -0
- grpc_server_kit/aio/health/orchestrator.py +122 -0
- grpc_server_kit/aio/health/protocols.py +43 -0
- grpc_server_kit/aio/health/servicer.py +229 -0
- grpc_server_kit/aio/interceptors/__init__.py +62 -0
- grpc_server_kit/aio/interceptors/base.py +235 -0
- grpc_server_kit/aio/interceptors/context.py +179 -0
- grpc_server_kit/aio/interceptors/exception_handler.py +167 -0
- grpc_server_kit/aio/interceptors/metadata.py +57 -0
- grpc_server_kit/aio/interceptors/metrics.py +104 -0
- grpc_server_kit/aio/interceptors/protocols.py +54 -0
- grpc_server_kit/aio/interceptors/request_logger.py +103 -0
- grpc_server_kit/aio/interceptors/sentry.py +102 -0
- grpc_server_kit/aio/interceptors/tracing.py +130 -0
- grpc_server_kit/aio/observability/__init__.py +5 -0
- grpc_server_kit/aio/observability/tracing.py +48 -0
- grpc_server_kit/aio/runtime.py +206 -0
- grpc_server_kit/aio/server.py +191 -0
- grpc_server_kit/config.py +100 -0
- grpc_server_kit/constants.py +34 -0
- grpc_server_kit/credentials.py +112 -0
- grpc_server_kit/dishka/__init__.py +38 -0
- grpc_server_kit/dishka/settings.py +41 -0
- grpc_server_kit/interceptors/__init__.py +15 -0
- grpc_server_kit/interceptors/constants.py +44 -0
- grpc_server_kit/interceptors/utils.py +74 -0
- grpc_server_kit/observability/__init__.py +12 -0
- grpc_server_kit/observability/metrics.py +116 -0
- grpc_server_kit/observability/naming.py +26 -0
- grpc_server_kit/observability/protocols.py +86 -0
- grpc_server_kit/observability/sentry.py +63 -0
- grpc_server_kit/options.py +136 -0
- grpc_server_kit/protocols.py +114 -0
- grpc_server_kit/py.typed +0 -0
- grpc_server_kit/server.py +59 -0
- grpc_server_kit/settings.py +151 -0
- grpc_server_kit/signals.py +228 -0
- grpc_server_kit-0.1.0.dist-info/METADATA +484 -0
- grpc_server_kit-0.1.0.dist-info/RECORD +57 -0
- grpc_server_kit-0.1.0.dist-info/WHEEL +4 -0
- grpc_server_kit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Runtime helpers for gRPC server lifecycle (async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from ..constants import DEFAULT_GRACE_PERIOD
|
|
11
|
+
from ..signals import SignalManager
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
|
|
16
|
+
from ..protocols import GrpcAsyncServerProtocol
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# After a successful stop() the server's termination waiter resolves promptly;
|
|
21
|
+
# this cap only guards against non-conforming server implementations.
|
|
22
|
+
_POST_STOP_TERMINATION_TIMEOUT = 1.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ServerLifecycleManager:
|
|
26
|
+
"""Manages the lifecycle of an async gRPC server.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
server: The async gRPC server to manage.
|
|
30
|
+
address: Human-readable bind address used for logging.
|
|
31
|
+
grace_period: Graceful shutdown grace period in seconds. ``None`` maps
|
|
32
|
+
to gRPC's own semantics: all in-flight RPCs are aborted immediately.
|
|
33
|
+
signal_manager: Optional custom signal manager.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
server: GrpcAsyncServerProtocol,
|
|
39
|
+
address: str,
|
|
40
|
+
grace_period: float | None = DEFAULT_GRACE_PERIOD,
|
|
41
|
+
signal_manager: SignalManager | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
if grace_period is not None and grace_period < 0:
|
|
44
|
+
raise ValueError(f"grace_period must be non-negative, got {grace_period}")
|
|
45
|
+
|
|
46
|
+
self._server = server
|
|
47
|
+
self._address = address
|
|
48
|
+
self._grace_period = grace_period
|
|
49
|
+
self._signal_manager = signal_manager or SignalManager()
|
|
50
|
+
self._stop_event = asyncio.Event()
|
|
51
|
+
self._stop_reason = "terminated"
|
|
52
|
+
self._handlers_set_up = False
|
|
53
|
+
|
|
54
|
+
async def __aenter__(self) -> GrpcAsyncServerProtocol:
|
|
55
|
+
"""Async context manager entry: start the server."""
|
|
56
|
+
await self._server.start()
|
|
57
|
+
logger.info("gRPC server started", extra={"address": self._address})
|
|
58
|
+
return self._server
|
|
59
|
+
|
|
60
|
+
async def __aexit__(
|
|
61
|
+
self,
|
|
62
|
+
exc_type: type[BaseException] | None,
|
|
63
|
+
exc_val: BaseException | None,
|
|
64
|
+
exc_tb: TracebackType | None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Async context manager exit: stop the server."""
|
|
67
|
+
await self.stop()
|
|
68
|
+
|
|
69
|
+
def request_shutdown(self, reason: str = "requested") -> None:
|
|
70
|
+
"""Request a graceful shutdown, honored by the running or the next :meth:`run` loop.
|
|
71
|
+
|
|
72
|
+
Idempotent: repeated requests are ignored and the FIRST reason is kept,
|
|
73
|
+
since that is what actually triggered the shutdown. A request made
|
|
74
|
+
before :meth:`run` starts is remembered, so a signal racing startup is
|
|
75
|
+
never lost.
|
|
76
|
+
|
|
77
|
+
Safe to call from the event loop (e.g. from a handler or a background
|
|
78
|
+
task); from other threads use ``loop.call_soon_threadsafe``.
|
|
79
|
+
"""
|
|
80
|
+
if self._stop_event.is_set():
|
|
81
|
+
logger.debug("Shutdown already requested, ignoring", extra={"reason": reason})
|
|
82
|
+
return
|
|
83
|
+
self._stop_reason = reason
|
|
84
|
+
self._stop_event.set()
|
|
85
|
+
|
|
86
|
+
async def run(self, setup_signals: bool = True) -> None:
|
|
87
|
+
"""Run the server until termination, shutdown request, or signal."""
|
|
88
|
+
self._handlers_set_up = setup_signals
|
|
89
|
+
if not self._stop_event.is_set():
|
|
90
|
+
# Fresh run: forget why the PREVIOUS run stopped. When a shutdown is
|
|
91
|
+
# already pending for this run, its reason is kept instead.
|
|
92
|
+
self._stop_reason = "terminated"
|
|
93
|
+
|
|
94
|
+
# Install signal handlers BEFORE starting the server so a setup failure
|
|
95
|
+
# can never leave a started server running with no way to stop it.
|
|
96
|
+
if setup_signals:
|
|
97
|
+
self._signal_manager.setup(self._on_signal)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
await self._server.start()
|
|
101
|
+
except BaseException:
|
|
102
|
+
if setup_signals:
|
|
103
|
+
await self._signal_manager.reset_async()
|
|
104
|
+
raise
|
|
105
|
+
logger.info("gRPC server started", extra={"address": self._address})
|
|
106
|
+
|
|
107
|
+
wait_task: asyncio.Task[bool] = asyncio.create_task(
|
|
108
|
+
self._server.wait_for_termination(), name=f"grpc-server-wait:{self._address}"
|
|
109
|
+
)
|
|
110
|
+
stop_task = asyncio.create_task(self._stop_event.wait(), name=f"grpc-server-stop-event:{self._address}")
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
done, _ = await asyncio.wait(
|
|
114
|
+
[wait_task, stop_task],
|
|
115
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
116
|
+
)
|
|
117
|
+
for t in done:
|
|
118
|
+
t.result()
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
self._stop_reason = "cancelled"
|
|
121
|
+
raise
|
|
122
|
+
except KeyboardInterrupt:
|
|
123
|
+
self._stop_reason = "keyboard_interrupt"
|
|
124
|
+
raise
|
|
125
|
+
finally:
|
|
126
|
+
stop_task.cancel()
|
|
127
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
128
|
+
await stop_task
|
|
129
|
+
|
|
130
|
+
# Stop the server BEFORE touching wait_task: cancelling a task that
|
|
131
|
+
# awaits the server's shared termination future would poison that
|
|
132
|
+
# future and break stop() itself. After a successful stop the
|
|
133
|
+
# termination waiter completes on its own.
|
|
134
|
+
try:
|
|
135
|
+
await self.stop()
|
|
136
|
+
with contextlib.suppress(asyncio.CancelledError, TimeoutError):
|
|
137
|
+
await asyncio.wait_for(wait_task, timeout=_POST_STOP_TERMINATION_TIMEOUT)
|
|
138
|
+
finally:
|
|
139
|
+
if not wait_task.done():
|
|
140
|
+
wait_task.cancel()
|
|
141
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
142
|
+
await wait_task
|
|
143
|
+
|
|
144
|
+
# Re-arm AFTER this run has fully stopped (never at the start of
|
|
145
|
+
# the next one): a shutdown requested between runs must be
|
|
146
|
+
# honored by the next run, not silently discarded. The stop
|
|
147
|
+
# reason survives for post-run introspection.
|
|
148
|
+
self._stop_event = asyncio.Event()
|
|
149
|
+
|
|
150
|
+
async def stop(self) -> None:
|
|
151
|
+
"""Stop the server gracefully, then restore signal handlers.
|
|
152
|
+
|
|
153
|
+
Signal handlers stay installed through the whole drain so a repeated
|
|
154
|
+
SIGINT/SIGTERM during the grace period is absorbed (logged and ignored)
|
|
155
|
+
instead of killing the process mid-drain.
|
|
156
|
+
"""
|
|
157
|
+
logger.info("Shutting down gRPC server...", extra={"reason": self._stop_reason})
|
|
158
|
+
|
|
159
|
+
# Use a task and shield to ensure stop() completes even if this task is cancelled
|
|
160
|
+
stop_task: asyncio.Task[None] = asyncio.create_task(self._server.stop(grace=self._grace_period))
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.shield(stop_task)
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
# If the waiter is cancelled, re-await the task so shutdown still
|
|
165
|
+
# completes, then let the cancellation propagate.
|
|
166
|
+
await stop_task
|
|
167
|
+
raise
|
|
168
|
+
finally:
|
|
169
|
+
if self._handlers_set_up:
|
|
170
|
+
self._handlers_set_up = False
|
|
171
|
+
await self._signal_manager.reset_async()
|
|
172
|
+
|
|
173
|
+
logger.info("gRPC server stopped")
|
|
174
|
+
|
|
175
|
+
async def _on_signal(self, sig_name: str) -> None:
|
|
176
|
+
"""Signal handler."""
|
|
177
|
+
self.request_shutdown(f"signal_{sig_name}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def run_async_grpc_server(
|
|
181
|
+
server: GrpcAsyncServerProtocol,
|
|
182
|
+
*,
|
|
183
|
+
address: str,
|
|
184
|
+
grace_period: float | None = DEFAULT_GRACE_PERIOD,
|
|
185
|
+
setup_signals: bool = True,
|
|
186
|
+
signal_manager: SignalManager | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Start gRPC server and handle graceful shutdown lifecycle.
|
|
189
|
+
|
|
190
|
+
Uses ServerLifecycleManager to orchestrate the server runtime.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
server: The async gRPC server to run.
|
|
194
|
+
address: Human-readable bind address used for logging.
|
|
195
|
+
grace_period: Graceful shutdown grace period in seconds. ``None`` maps
|
|
196
|
+
to gRPC's own semantics: all in-flight RPCs are aborted immediately.
|
|
197
|
+
setup_signals: Whether to install SIGINT/SIGTERM handlers.
|
|
198
|
+
signal_manager: Optional custom signal manager (a fresh one is created if omitted).
|
|
199
|
+
"""
|
|
200
|
+
manager = ServerLifecycleManager(
|
|
201
|
+
server=server,
|
|
202
|
+
address=address,
|
|
203
|
+
grace_period=grace_period,
|
|
204
|
+
signal_manager=signal_manager,
|
|
205
|
+
)
|
|
206
|
+
await manager.run(setup_signals=setup_signals)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""gRPC server creation and configuration helpers (async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import grpc
|
|
9
|
+
|
|
10
|
+
from ..options import build_grpc_options
|
|
11
|
+
from ..protocols import GrpcAsyncServerProtocol, GrpcServerSettingsProtocol
|
|
12
|
+
from ..server import is_valid_service_name
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from grpc_reflection.v1alpha import reflection
|
|
19
|
+
except ImportError:
|
|
20
|
+
reflection = None
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from grpc_channelz.v1 import channelz
|
|
24
|
+
except ImportError:
|
|
25
|
+
channelz = None
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncServer(GrpcAsyncServerProtocol):
|
|
31
|
+
"""Thin, typed wrapper over :class:`grpc.aio.Server`.
|
|
32
|
+
|
|
33
|
+
Exposes the surface the kit needs (ports, lifecycle, servicer registration)
|
|
34
|
+
with precise types; everything else is reachable via :attr:`raw_server`.
|
|
35
|
+
Generated ``add_*Servicer_to_server`` functions accept this wrapper directly.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, server: grpc.aio.Server) -> None:
|
|
39
|
+
self._server = server
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def raw_server(self) -> grpc.aio.Server:
|
|
43
|
+
"""Get the underlying gRPC server."""
|
|
44
|
+
return self._server
|
|
45
|
+
|
|
46
|
+
def add_generic_rpc_handlers(self, generic_rpc_handlers: Sequence[grpc.GenericRpcHandler]) -> None:
|
|
47
|
+
"""Register generic RPC handlers (used by generated registration functions)."""
|
|
48
|
+
self._server.add_generic_rpc_handlers(generic_rpc_handlers)
|
|
49
|
+
|
|
50
|
+
def add_registered_method_handlers(self, service_name: str, method_handlers: dict[str, Any]) -> None:
|
|
51
|
+
"""Register per-method handlers (used by newer generated registration functions)."""
|
|
52
|
+
self._server.add_registered_method_handlers(service_name, method_handlers)
|
|
53
|
+
|
|
54
|
+
def add_insecure_port(self, address: str) -> int:
|
|
55
|
+
"""Add insecure port; delegates to underlying server and returns int."""
|
|
56
|
+
return int(self._server.add_insecure_port(address))
|
|
57
|
+
|
|
58
|
+
def add_secure_port(self, address: str, credentials: grpc.ServerCredentials) -> int:
|
|
59
|
+
"""Add secure port; delegates to underlying server and returns int."""
|
|
60
|
+
return int(self._server.add_secure_port(address, credentials))
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
"""Start the server."""
|
|
64
|
+
await self._server.start()
|
|
65
|
+
|
|
66
|
+
async def stop(self, grace: float | None) -> None:
|
|
67
|
+
"""Stop the server."""
|
|
68
|
+
if grace is not None and grace < 0:
|
|
69
|
+
raise ValueError(f"grace must be non-negative or None, got {grace}")
|
|
70
|
+
await self._server.stop(grace)
|
|
71
|
+
|
|
72
|
+
async def wait_for_termination(self, timeout: float | None = None) -> bool:
|
|
73
|
+
"""Wait for server termination.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if the server has terminated, False if the timeout expired before termination.
|
|
77
|
+
"""
|
|
78
|
+
result = await self._server.wait_for_termination(timeout)
|
|
79
|
+
return result if isinstance(result, bool) else bool(result)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_async_grpc_server(
|
|
83
|
+
*,
|
|
84
|
+
interceptors: list[grpc.aio.ServerInterceptor],
|
|
85
|
+
settings: GrpcServerSettingsProtocol,
|
|
86
|
+
register_servicers: Callable[[grpc.aio.Server], None] | None = None,
|
|
87
|
+
enable_reflection: bool = False,
|
|
88
|
+
reflection_service_names: Sequence[str] | None = None,
|
|
89
|
+
enable_channelz: bool = False,
|
|
90
|
+
) -> AsyncServer:
|
|
91
|
+
"""Create async gRPC server, register servicers, and optionally enable debug services.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
interceptors: List of async gRPC interceptors
|
|
95
|
+
settings: gRPC server settings
|
|
96
|
+
register_servicers: Optional callback to register gRPC servicers (servicers can
|
|
97
|
+
also be registered later on the returned server)
|
|
98
|
+
enable_reflection: Whether to enable gRPC reflection
|
|
99
|
+
reflection_service_names: List of service names to expose via reflection
|
|
100
|
+
enable_channelz: Whether to enable gRPC channelz
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Configured async gRPC server ready to be started
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If reflection is enabled but service names are not provided
|
|
107
|
+
ImportError: If reflection or channelz packages are missing when enabled
|
|
108
|
+
"""
|
|
109
|
+
server_wrapper = create_base_async_grpc_server(interceptors, settings)
|
|
110
|
+
if register_servicers is not None:
|
|
111
|
+
# register_servicers expects the raw grpc.aio.Server
|
|
112
|
+
register_servicers(server_wrapper.raw_server)
|
|
113
|
+
|
|
114
|
+
if enable_reflection:
|
|
115
|
+
if reflection is None:
|
|
116
|
+
raise ImportError(
|
|
117
|
+
"grpcio-reflection is not installed. "
|
|
118
|
+
"Please install it with 'pip install grpcio-reflection' "
|
|
119
|
+
"or use 'grpc-server-kit[reflection]' optional dependency."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
validated_names = validate_reflection_service_names(reflection_service_names)
|
|
123
|
+
reflection.enable_server_reflection(
|
|
124
|
+
[*validated_names, reflection.SERVICE_NAME],
|
|
125
|
+
server_wrapper.raw_server,
|
|
126
|
+
)
|
|
127
|
+
logger.info("gRPC reflection enabled")
|
|
128
|
+
|
|
129
|
+
if enable_channelz:
|
|
130
|
+
if channelz is None:
|
|
131
|
+
raise ImportError(
|
|
132
|
+
"grpcio-channelz is not installed. "
|
|
133
|
+
"Please install it with 'pip install grpcio-channelz' "
|
|
134
|
+
"or use 'grpc-server-kit[channelz]' optional dependency."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
channelz.add_channelz_servicer(server_wrapper.raw_server)
|
|
138
|
+
logger.info("Channelz enabled")
|
|
139
|
+
|
|
140
|
+
return server_wrapper
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def validate_reflection_service_names(service_names: Sequence[str] | None) -> list[str]:
|
|
144
|
+
"""Validate and normalize (strip, dedupe) reflection service names.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If the list is empty or contains invalid service names.
|
|
148
|
+
"""
|
|
149
|
+
if not service_names:
|
|
150
|
+
raise ValueError("reflection_service_names must be provided when reflection is enabled")
|
|
151
|
+
|
|
152
|
+
validated_names: list[str] = []
|
|
153
|
+
for name in service_names:
|
|
154
|
+
if not isinstance(name, str) or not name.strip():
|
|
155
|
+
raise ValueError(f"Invalid service name: {name}")
|
|
156
|
+
|
|
157
|
+
clean_name = name.strip()
|
|
158
|
+
if not is_valid_service_name(clean_name):
|
|
159
|
+
raise ValueError(f"Invalid gRPC service name format: {clean_name}")
|
|
160
|
+
|
|
161
|
+
if clean_name not in validated_names:
|
|
162
|
+
validated_names.append(clean_name)
|
|
163
|
+
|
|
164
|
+
return validated_names
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def create_base_async_grpc_server(
|
|
168
|
+
interceptors: list[grpc.aio.ServerInterceptor],
|
|
169
|
+
settings: GrpcServerSettingsProtocol,
|
|
170
|
+
) -> AsyncServer:
|
|
171
|
+
"""Create and configure basic async gRPC server.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
interceptors: List of async gRPC interceptors
|
|
175
|
+
settings: gRPC server settings
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Configured but not started async gRPC server wrapper
|
|
179
|
+
"""
|
|
180
|
+
if settings.max_concurrent_rpcs is not None and settings.max_concurrent_rpcs < 1:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"max_concurrent_rpcs must be >= 1 (or None for unlimited), got {settings.max_concurrent_rpcs}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
server = grpc.aio.server(
|
|
186
|
+
interceptors=interceptors,
|
|
187
|
+
options=build_grpc_options(settings),
|
|
188
|
+
maximum_concurrent_rpcs=settings.max_concurrent_rpcs,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return AsyncServer(server)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Zero-dependency gRPC server configuration (pure stdlib dataclass).
|
|
2
|
+
|
|
3
|
+
:class:`GrpcServerConfig` structurally satisfies
|
|
4
|
+
:class:`grpc_server_kit.protocols.GrpcServerSettingsProtocol` without pulling in
|
|
5
|
+
pydantic. Services that want environment loading and validation via pydantic
|
|
6
|
+
should use :class:`grpc_server_kit.settings.BaseGrpcServerSettings` (the
|
|
7
|
+
``[settings]`` extra) — both shapes are interchangeable everywhere the kit
|
|
8
|
+
accepts settings.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
|
|
15
|
+
from .constants import (
|
|
16
|
+
DEFAULT_GRACE_PERIOD,
|
|
17
|
+
DEFAULT_HOST,
|
|
18
|
+
DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA,
|
|
19
|
+
DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
|
|
20
|
+
DEFAULT_INITIAL_WINDOW_SIZE,
|
|
21
|
+
DEFAULT_KEEPALIVE_TIME_MS,
|
|
22
|
+
DEFAULT_KEEPALIVE_TIMEOUT_MS,
|
|
23
|
+
DEFAULT_MAX_METADATA_SIZE,
|
|
24
|
+
DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH,
|
|
25
|
+
DEFAULT_MAX_SEND_MESSAGE_LENGTH,
|
|
26
|
+
DEFAULT_PORT,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = ["GrpcServerConfig"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
33
|
+
class GrpcServerConfig:
|
|
34
|
+
"""gRPC server configuration with production-grade defaults (stdlib only)."""
|
|
35
|
+
|
|
36
|
+
# Basic
|
|
37
|
+
host: str = DEFAULT_HOST
|
|
38
|
+
port: int = DEFAULT_PORT
|
|
39
|
+
|
|
40
|
+
# TLS/SSL
|
|
41
|
+
ssl_enabled: bool = False
|
|
42
|
+
ssl_cert_file: str | None = None
|
|
43
|
+
ssl_key_file: str | None = None
|
|
44
|
+
ssl_ca_file: str | None = None
|
|
45
|
+
ssl_client_auth: bool = False
|
|
46
|
+
ssl_max_cert_size: int | None = None
|
|
47
|
+
|
|
48
|
+
# Keepalive
|
|
49
|
+
keepalive_time_ms: int = DEFAULT_KEEPALIVE_TIME_MS
|
|
50
|
+
keepalive_timeout_ms: int = DEFAULT_KEEPALIVE_TIMEOUT_MS
|
|
51
|
+
keepalive_permit_without_calls: bool = False
|
|
52
|
+
http2_min_recv_ping_interval_without_data_ms: int = DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS
|
|
53
|
+
http2_max_pings_without_data: int = DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA
|
|
54
|
+
|
|
55
|
+
# Connection limits
|
|
56
|
+
max_concurrent_rpcs: int | None = None
|
|
57
|
+
max_connection_idle_ms: int | None = None
|
|
58
|
+
max_connection_age_ms: int | None = None
|
|
59
|
+
max_connection_age_grace_ms: int | None = None
|
|
60
|
+
|
|
61
|
+
# Message size limits
|
|
62
|
+
max_send_message_length: int = DEFAULT_MAX_SEND_MESSAGE_LENGTH
|
|
63
|
+
max_receive_message_length: int = DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH
|
|
64
|
+
max_metadata_size: int = DEFAULT_MAX_METADATA_SIZE
|
|
65
|
+
|
|
66
|
+
# Compression
|
|
67
|
+
compression_algorithm: str | None = None
|
|
68
|
+
|
|
69
|
+
# Flow control
|
|
70
|
+
initial_stream_window_size: int = DEFAULT_INITIAL_WINDOW_SIZE
|
|
71
|
+
initial_connection_window_size: int = DEFAULT_INITIAL_WINDOW_SIZE
|
|
72
|
+
|
|
73
|
+
# Features
|
|
74
|
+
enable_reflection: bool = False
|
|
75
|
+
enable_channelz: bool = False
|
|
76
|
+
|
|
77
|
+
# Graceful shutdown
|
|
78
|
+
grace_period: float = DEFAULT_GRACE_PERIOD
|
|
79
|
+
|
|
80
|
+
# Metrics
|
|
81
|
+
metrics_enabled: bool = False
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
if not self.host:
|
|
85
|
+
raise ValueError("host cannot be empty")
|
|
86
|
+
if not (0 <= self.port <= 65535):
|
|
87
|
+
raise ValueError(f"port must be within [0, 65535], got {self.port}")
|
|
88
|
+
if self.grace_period < 0:
|
|
89
|
+
raise ValueError(f"grace_period must be non-negative, got {self.grace_period}")
|
|
90
|
+
if self.max_concurrent_rpcs is not None and self.max_concurrent_rpcs < 1:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"max_concurrent_rpcs must be >= 1 (or None for unlimited), got {self.max_concurrent_rpcs}"
|
|
93
|
+
)
|
|
94
|
+
if self.ssl_enabled:
|
|
95
|
+
if not self.ssl_cert_file:
|
|
96
|
+
raise ValueError("ssl_cert_file is required when ssl_enabled=True")
|
|
97
|
+
if not self.ssl_key_file:
|
|
98
|
+
raise ValueError("ssl_key_file is required when ssl_enabled=True")
|
|
99
|
+
if self.ssl_client_auth and not self.ssl_ca_file:
|
|
100
|
+
raise ValueError("ssl_ca_file is required when ssl_client_auth=True")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""gRPC server constants and default values."""
|
|
2
|
+
|
|
3
|
+
# Bind address
|
|
4
|
+
DEFAULT_HOST = "[::]"
|
|
5
|
+
DEFAULT_PORT = 50051
|
|
6
|
+
|
|
7
|
+
# Message size limits
|
|
8
|
+
DEFAULT_MAX_SEND_MESSAGE_LENGTH = 4 * 1024 * 1024 # 4 MB
|
|
9
|
+
DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH = 4 * 1024 * 1024 # 4 MB
|
|
10
|
+
DEFAULT_MAX_METADATA_SIZE = 8 * 1024 # 8 KB (grpc-core default soft limit)
|
|
11
|
+
|
|
12
|
+
# Keepalive settings
|
|
13
|
+
DEFAULT_KEEPALIVE_TIME_MS = 7200000 # 2 hours
|
|
14
|
+
DEFAULT_KEEPALIVE_TIMEOUT_MS = 20000 # 20 seconds
|
|
15
|
+
DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS = 300000 # 5 minutes
|
|
16
|
+
DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA = 2
|
|
17
|
+
|
|
18
|
+
# Flow control (HTTP/2 default initial window)
|
|
19
|
+
DEFAULT_INITIAL_WINDOW_SIZE = 65535
|
|
20
|
+
|
|
21
|
+
# Graceful shutdown
|
|
22
|
+
DEFAULT_GRACE_PERIOD = 5.0 # seconds
|
|
23
|
+
|
|
24
|
+
# gRPC Health Checking Protocol v1 (single source of truth for the name)
|
|
25
|
+
HEALTH_SERVICE_NAME = "grpc.health.v1.Health"
|
|
26
|
+
|
|
27
|
+
# Health checking defaults. The cache TTL matches the Watch check interval so
|
|
28
|
+
# that concurrent watchers and Check probes amortize onto one real dependency
|
|
29
|
+
# check per interval instead of each running their own.
|
|
30
|
+
DEFAULT_HEALTH_CACHE_TTL = 5.0 # seconds; 0 disables caching
|
|
31
|
+
DEFAULT_HEALTH_CHECK_TIMEOUT = 10.0 # seconds; one full check round
|
|
32
|
+
DEFAULT_HEALTH_CHECK_INTERVAL = 5.0 # seconds; Watch polling interval
|
|
33
|
+
DEFAULT_HEALTH_HEARTBEAT_INTERVAL = 60.0 # seconds; Watch resend-if-unchanged
|
|
34
|
+
DEFAULT_CHECKER_TIMEOUT = 5.0 # seconds; a single dependency checker (pg/redis)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""SSL/TLS credentials loading for gRPC server."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import grpc
|
|
9
|
+
|
|
10
|
+
from .protocols import GrpcSslSettingsProtocol
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_CERT_SIZE = 1024 * 1024 # 1MB
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_server_credentials(settings: GrpcSslSettingsProtocol, strict: bool = True) -> grpc.ServerCredentials:
|
|
18
|
+
"""Load TLS credentials for secure gRPC server.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
settings: SSL/TLS configuration protocol
|
|
22
|
+
strict: If True (default), fail on insecure file permissions (Unix only)
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Configured gRPC ServerCredentials
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If configuration is invalid or files are too large/invalid
|
|
29
|
+
FileNotFoundError: If certificate/key files are missing
|
|
30
|
+
PermissionError: If files cannot be accessed due to permissions
|
|
31
|
+
OSError: If other I/O errors occur
|
|
32
|
+
"""
|
|
33
|
+
if not settings.ssl_cert_file or not settings.ssl_key_file:
|
|
34
|
+
raise ValueError("SSL enabled but ssl_cert_file or ssl_key_file not provided")
|
|
35
|
+
|
|
36
|
+
max_size = settings.ssl_max_cert_size or DEFAULT_MAX_CERT_SIZE
|
|
37
|
+
|
|
38
|
+
cert = _read_cert_file(settings.ssl_cert_file, "certificate", max_size=max_size, strict=strict)
|
|
39
|
+
key = _read_cert_file(settings.ssl_key_file, "private key", max_size=max_size, strict=strict)
|
|
40
|
+
|
|
41
|
+
ca_cert = None
|
|
42
|
+
if settings.ssl_ca_file:
|
|
43
|
+
ca_cert = _read_cert_file(settings.ssl_ca_file, "CA certificate", max_size=max_size, strict=strict)
|
|
44
|
+
|
|
45
|
+
return grpc.ssl_server_credentials(
|
|
46
|
+
[(key, cert)],
|
|
47
|
+
root_certificates=ca_cert,
|
|
48
|
+
require_client_auth=settings.ssl_client_auth,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_cert_file(path_str: str, label: str, max_size: int = DEFAULT_MAX_CERT_SIZE, strict: bool = True) -> bytes:
|
|
53
|
+
"""Read and validate certificate/key file."""
|
|
54
|
+
path = Path(path_str)
|
|
55
|
+
if not path.exists():
|
|
56
|
+
raise FileNotFoundError(f"{label.capitalize()} file not found: {path}")
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
raise ValueError(f"Path is not a file: {path}")
|
|
59
|
+
|
|
60
|
+
_check_permissions(path, label, strict=strict)
|
|
61
|
+
|
|
62
|
+
stats = path.stat()
|
|
63
|
+
if stats.st_size > max_size:
|
|
64
|
+
raise ValueError(f"{label.capitalize()} file too large ({stats.st_size} bytes): {path}")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
data = path.read_bytes()
|
|
68
|
+
_validate_pem(data, label)
|
|
69
|
+
except PermissionError as e:
|
|
70
|
+
raise PermissionError(f"Permission denied reading {label} file {path}: {e}") from e
|
|
71
|
+
except OSError as e:
|
|
72
|
+
raise OSError(f"Unexpected error reading {label} file {path}: {e}") from e
|
|
73
|
+
else:
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_pem(data: bytes, label: str) -> None:
|
|
78
|
+
"""Light sanity check that the file contains a PEM block.
|
|
79
|
+
|
|
80
|
+
Deliberately permissive: accepts preambles before the block (e.g. ``openssl``
|
|
81
|
+
text output), any PEM type (PKCS#8, PKCS#1, SEC1, encrypted keys), and CA
|
|
82
|
+
bundles with multiple blocks. Real validation belongs to the TLS stack —
|
|
83
|
+
this only catches obviously wrong files (empty, DER, JSON, ...).
|
|
84
|
+
"""
|
|
85
|
+
if not data.strip():
|
|
86
|
+
raise ValueError(f"Empty {label} file")
|
|
87
|
+
if b"-----BEGIN " not in data or b"-----END " not in data:
|
|
88
|
+
raise ValueError(f"{label.capitalize()} file does not contain a PEM block")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_permissions(path: Path, label: str, strict: bool = True) -> None:
|
|
92
|
+
"""Check file permissions for security (Unix only)."""
|
|
93
|
+
if os.name == "nt":
|
|
94
|
+
if label == "private key":
|
|
95
|
+
logger.debug(
|
|
96
|
+
f"Skipping permission check for {label} on Windows. "
|
|
97
|
+
"Ensure only the service user has access to this file.",
|
|
98
|
+
extra={"path": str(path)},
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
mode = path.stat().st_mode
|
|
103
|
+
# Fail if world-readable or group-readable private keys on Unix
|
|
104
|
+
if label == "private key" and (mode & stat.S_IRWXG or mode & stat.S_IRWXO):
|
|
105
|
+
msg = (
|
|
106
|
+
f"Sensitive file {path} has too permissive permissions ({oct(mode & 0o777)}). "
|
|
107
|
+
"Private keys must be readable only by owner (chmod 600)."
|
|
108
|
+
)
|
|
109
|
+
if strict:
|
|
110
|
+
raise PermissionError(msg)
|
|
111
|
+
else:
|
|
112
|
+
logger.warning(msg, extra={"path": str(path)})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Dishka dependency-injection integration for the gRPC server (``[dishka]`` extra).
|
|
2
|
+
|
|
3
|
+
A thin, curated re-export of Dishka's official ``grpcio`` integration. The shared,
|
|
4
|
+
transport-agnostic helpers (``inject``, ``FromDishka``, ``GrpcioProvider``,
|
|
5
|
+
``from_context``) live here; the async server interceptor is
|
|
6
|
+
:class:`grpc_server_kit.aio.dishka.DishkaAioInterceptor`.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from dishka import make_async_container
|
|
10
|
+
from grpc_server_kit.dishka import FromDishka, inject
|
|
11
|
+
from grpc_server_kit.aio.dishka import DishkaAioInterceptor
|
|
12
|
+
|
|
13
|
+
container = make_async_container(MyProvider())
|
|
14
|
+
server = (
|
|
15
|
+
AsyncGrpcServerBuilder(settings)
|
|
16
|
+
.with_interceptors([DishkaAioInterceptor(container)])
|
|
17
|
+
.with_servicers(register)
|
|
18
|
+
.build()
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class MyServicer(...):
|
|
22
|
+
@inject
|
|
23
|
+
async def MyMethod(self, request, context, service: FromDishka[MyService]):
|
|
24
|
+
...
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from dishka import from_context
|
|
28
|
+
from dishka.integrations.grpcio import FromDishka, GrpcioProvider, inject
|
|
29
|
+
|
|
30
|
+
from grpc_server_kit.dishka.settings import GrpcServerSettingsProvider
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"FromDishka",
|
|
34
|
+
"GrpcServerSettingsProvider",
|
|
35
|
+
"GrpcioProvider",
|
|
36
|
+
"from_context",
|
|
37
|
+
"inject",
|
|
38
|
+
]
|