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.
Files changed (57) hide show
  1. grpc_server_kit/__init__.py +34 -0
  2. grpc_server_kit/__version__.py +1 -0
  3. grpc_server_kit/aio/__init__.py +44 -0
  4. grpc_server_kit/aio/app.py +368 -0
  5. grpc_server_kit/aio/builder.py +63 -0
  6. grpc_server_kit/aio/dishka/__init__.py +28 -0
  7. grpc_server_kit/aio/dishka/bundle.py +70 -0
  8. grpc_server_kit/aio/dishka/interceptors.py +116 -0
  9. grpc_server_kit/aio/dishka/metrics.py +50 -0
  10. grpc_server_kit/aio/dishka/observability.py +86 -0
  11. grpc_server_kit/aio/dishka/server.py +51 -0
  12. grpc_server_kit/aio/health/__init__.py +36 -0
  13. grpc_server_kit/aio/health/cache.py +89 -0
  14. grpc_server_kit/aio/health/checkers/__init__.py +16 -0
  15. grpc_server_kit/aio/health/checkers/postgres.py +115 -0
  16. grpc_server_kit/aio/health/checkers/redis.py +88 -0
  17. grpc_server_kit/aio/health/checkers/utils.py +97 -0
  18. grpc_server_kit/aio/health/orchestrator.py +122 -0
  19. grpc_server_kit/aio/health/protocols.py +43 -0
  20. grpc_server_kit/aio/health/servicer.py +229 -0
  21. grpc_server_kit/aio/interceptors/__init__.py +62 -0
  22. grpc_server_kit/aio/interceptors/base.py +235 -0
  23. grpc_server_kit/aio/interceptors/context.py +179 -0
  24. grpc_server_kit/aio/interceptors/exception_handler.py +167 -0
  25. grpc_server_kit/aio/interceptors/metadata.py +57 -0
  26. grpc_server_kit/aio/interceptors/metrics.py +104 -0
  27. grpc_server_kit/aio/interceptors/protocols.py +54 -0
  28. grpc_server_kit/aio/interceptors/request_logger.py +103 -0
  29. grpc_server_kit/aio/interceptors/sentry.py +102 -0
  30. grpc_server_kit/aio/interceptors/tracing.py +130 -0
  31. grpc_server_kit/aio/observability/__init__.py +5 -0
  32. grpc_server_kit/aio/observability/tracing.py +48 -0
  33. grpc_server_kit/aio/runtime.py +206 -0
  34. grpc_server_kit/aio/server.py +191 -0
  35. grpc_server_kit/config.py +100 -0
  36. grpc_server_kit/constants.py +34 -0
  37. grpc_server_kit/credentials.py +112 -0
  38. grpc_server_kit/dishka/__init__.py +38 -0
  39. grpc_server_kit/dishka/settings.py +41 -0
  40. grpc_server_kit/interceptors/__init__.py +15 -0
  41. grpc_server_kit/interceptors/constants.py +44 -0
  42. grpc_server_kit/interceptors/utils.py +74 -0
  43. grpc_server_kit/observability/__init__.py +12 -0
  44. grpc_server_kit/observability/metrics.py +116 -0
  45. grpc_server_kit/observability/naming.py +26 -0
  46. grpc_server_kit/observability/protocols.py +86 -0
  47. grpc_server_kit/observability/sentry.py +63 -0
  48. grpc_server_kit/options.py +136 -0
  49. grpc_server_kit/protocols.py +114 -0
  50. grpc_server_kit/py.typed +0 -0
  51. grpc_server_kit/server.py +59 -0
  52. grpc_server_kit/settings.py +151 -0
  53. grpc_server_kit/signals.py +228 -0
  54. grpc_server_kit-0.1.0.dist-info/METADATA +484 -0
  55. grpc_server_kit-0.1.0.dist-info/RECORD +57 -0
  56. grpc_server_kit-0.1.0.dist-info/WHEEL +4 -0
  57. 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
+ ]