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,235 @@
|
|
|
1
|
+
"""Streaming-aware base class for async gRPC server interceptors.
|
|
2
|
+
|
|
3
|
+
The kit's interceptors wrap *whole* RPCs — including response-streaming ones —
|
|
4
|
+
so timings, error mapping, and cleanup cover the full stream lifetime, not just
|
|
5
|
+
handler creation. Subclasses implement a single async-generator hook,
|
|
6
|
+
:meth:`AsyncServerInterceptor.around_call`, which the base class turns into a
|
|
7
|
+
context manager around each of the four gRPC call kinds (unary-unary,
|
|
8
|
+
unary-stream, stream-unary, stream-stream).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import abc
|
|
14
|
+
import contextlib
|
|
15
|
+
import dataclasses
|
|
16
|
+
import functools
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
import grpc
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Collection
|
|
23
|
+
|
|
24
|
+
__all__ = ["AsyncServerInterceptor", "RpcCall", "split_method_name"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
28
|
+
class RpcCall:
|
|
29
|
+
"""One RPC as seen by an interceptor.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
method_name: Full RPC name, e.g. ``"/pkg.Service/Method"``.
|
|
33
|
+
request: The request message (unary request) or an async iterator of
|
|
34
|
+
request messages (streaming request).
|
|
35
|
+
context: The gRPC servicer context of this RPC.
|
|
36
|
+
request_streaming: True for stream-unary / stream-stream calls.
|
|
37
|
+
response_streaming: True for unary-stream / stream-stream calls.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
method_name: str
|
|
41
|
+
request: Any
|
|
42
|
+
context: grpc.aio.ServicerContext[Any, Any]
|
|
43
|
+
request_streaming: bool
|
|
44
|
+
response_streaming: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def split_method_name(method_name: str) -> tuple[str, str]:
|
|
48
|
+
"""Split ``"/pkg.Service/Method"`` into ``("pkg.Service", "Method")``.
|
|
49
|
+
|
|
50
|
+
Returns empty strings for parts that cannot be parsed.
|
|
51
|
+
"""
|
|
52
|
+
service, _, method = method_name.removeprefix("/").partition("/")
|
|
53
|
+
return service, method
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextlib.asynccontextmanager
|
|
57
|
+
async def _null_around() -> AsyncIterator[None]:
|
|
58
|
+
yield
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_factory_and_behavior(
|
|
62
|
+
handler: grpc.RpcMethodHandler,
|
|
63
|
+
) -> tuple[Callable[..., grpc.RpcMethodHandler], Callable[..., Any]]:
|
|
64
|
+
"""Return the matching ``*_rpc_method_handler`` factory and the handler behavior."""
|
|
65
|
+
if handler.unary_unary:
|
|
66
|
+
return grpc.unary_unary_rpc_method_handler, handler.unary_unary
|
|
67
|
+
if handler.unary_stream:
|
|
68
|
+
return grpc.unary_stream_rpc_method_handler, handler.unary_stream
|
|
69
|
+
if handler.stream_unary:
|
|
70
|
+
return grpc.stream_unary_rpc_method_handler, handler.stream_unary
|
|
71
|
+
if handler.stream_stream:
|
|
72
|
+
return grpc.stream_stream_rpc_method_handler, handler.stream_stream
|
|
73
|
+
raise NotImplementedError("RPC handler implements no known behavior")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AsyncServerInterceptor(grpc.aio.ServerInterceptor, abc.ABC):
|
|
77
|
+
"""Base class for async server interceptors covering all four RPC kinds.
|
|
78
|
+
|
|
79
|
+
Subclasses implement :meth:`around_call` — an async generator that yields
|
|
80
|
+
exactly once:
|
|
81
|
+
|
|
82
|
+
- code before ``yield`` runs before the RPC handler is invoked;
|
|
83
|
+
- the RPC (including full consumption of a streaming response) happens at
|
|
84
|
+
the ``yield`` point;
|
|
85
|
+
- code after ``yield`` (or in a ``finally`` block) runs after the RPC has
|
|
86
|
+
fully completed.
|
|
87
|
+
|
|
88
|
+
An exception raised by the handler — at any point of a streaming response —
|
|
89
|
+
propagates through the ``yield``, so ordinary ``try/except`` around it
|
|
90
|
+
covers the whole call. Note that :meth:`grpc.aio.ServicerContext.abort`
|
|
91
|
+
raises :class:`grpc.aio.AbortError` (not ``grpc.RpcError``); interceptors
|
|
92
|
+
that must not treat an intentional abort as a failure should re-raise it.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
skip_methods: Full RPC method names (e.g. ``"/grpc.health.v1.Health/Check"``)
|
|
96
|
+
this interceptor must not wrap at all — handlers for these methods
|
|
97
|
+
pass through with zero interception overhead.
|
|
98
|
+
|
|
99
|
+
Example::
|
|
100
|
+
|
|
101
|
+
class TimingInterceptor(AsyncServerInterceptor):
|
|
102
|
+
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
103
|
+
start = time.perf_counter()
|
|
104
|
+
try:
|
|
105
|
+
yield
|
|
106
|
+
finally:
|
|
107
|
+
print(call.method_name, time.perf_counter() - start)
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, *, skip_methods: Collection[str] = ()) -> None:
|
|
111
|
+
super().__init__()
|
|
112
|
+
self._skip_methods = frozenset(skip_methods)
|
|
113
|
+
|
|
114
|
+
@abc.abstractmethod
|
|
115
|
+
def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
116
|
+
"""Wrap one RPC (async generator: setup before ``yield``, teardown after)."""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
@functools.cached_property
|
|
120
|
+
def _around_factory(self) -> Callable[[RpcCall], contextlib.AbstractAsyncContextManager[None]]:
|
|
121
|
+
# Built once per interceptor instance; asynccontextmanager itself
|
|
122
|
+
# produces a fresh context manager per call.
|
|
123
|
+
return contextlib.asynccontextmanager(self.around_call)
|
|
124
|
+
|
|
125
|
+
@functools.cached_property
|
|
126
|
+
def _wrapped_handlers(self) -> dict[str, tuple[grpc.RpcMethodHandler, grpc.RpcMethodHandler]]:
|
|
127
|
+
# Per-method cache of (source handler, wrapped handler). Registered
|
|
128
|
+
# handlers are stable objects, so the identity check on the source
|
|
129
|
+
# handler keeps the cache correct even for dynamic generic handlers.
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
def around(self, call: RpcCall) -> contextlib.AbstractAsyncContextManager[None]:
|
|
133
|
+
"""Return the per-RPC context manager built from :meth:`around_call`.
|
|
134
|
+
|
|
135
|
+
Skipped methods get a no-op context manager. Useful for composing
|
|
136
|
+
interceptors manually and for unit tests.
|
|
137
|
+
"""
|
|
138
|
+
if call.method_name in self._skip_methods:
|
|
139
|
+
return _null_around()
|
|
140
|
+
return self._around_factory(call)
|
|
141
|
+
|
|
142
|
+
async def intercept_service(
|
|
143
|
+
self,
|
|
144
|
+
continuation: Callable[[grpc.HandlerCallDetails], Awaitable[grpc.RpcMethodHandler | None]],
|
|
145
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
146
|
+
) -> grpc.RpcMethodHandler | None:
|
|
147
|
+
"""Wrap the resolved RPC handler with :meth:`around_call` (grpc plumbing)."""
|
|
148
|
+
handler = await continuation(handler_call_details)
|
|
149
|
+
if handler is None:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
raw_method = handler_call_details.method
|
|
153
|
+
method_name = raw_method.decode("utf-8", errors="replace") if isinstance(raw_method, bytes) else raw_method
|
|
154
|
+
|
|
155
|
+
if method_name in self._skip_methods:
|
|
156
|
+
return handler
|
|
157
|
+
|
|
158
|
+
cached = self._wrapped_handlers.get(method_name)
|
|
159
|
+
if cached is not None and cached[0] is handler:
|
|
160
|
+
return cached[1]
|
|
161
|
+
|
|
162
|
+
factory, behavior = _get_factory_and_behavior(handler)
|
|
163
|
+
|
|
164
|
+
wrapped_behavior: Callable[..., Any]
|
|
165
|
+
if handler.response_streaming:
|
|
166
|
+
wrapped_behavior = self._wrap_streaming_response(behavior, method_name, bool(handler.request_streaming))
|
|
167
|
+
else:
|
|
168
|
+
wrapped_behavior = self._wrap_unary_response(behavior, method_name, bool(handler.request_streaming))
|
|
169
|
+
|
|
170
|
+
wrapped = factory(
|
|
171
|
+
wrapped_behavior,
|
|
172
|
+
request_deserializer=handler.request_deserializer,
|
|
173
|
+
response_serializer=handler.response_serializer,
|
|
174
|
+
)
|
|
175
|
+
self._wrapped_handlers[method_name] = (handler, wrapped)
|
|
176
|
+
return wrapped
|
|
177
|
+
|
|
178
|
+
def _wrap_unary_response(
|
|
179
|
+
self,
|
|
180
|
+
behavior: Callable[..., Any],
|
|
181
|
+
method_name: str,
|
|
182
|
+
request_streaming: bool,
|
|
183
|
+
) -> Callable[..., Any]:
|
|
184
|
+
async def invoke(request_or_iterator: Any, context: grpc.aio.ServicerContext[Any, Any]) -> Any:
|
|
185
|
+
call = RpcCall(
|
|
186
|
+
method_name=method_name,
|
|
187
|
+
request=request_or_iterator,
|
|
188
|
+
context=context,
|
|
189
|
+
request_streaming=request_streaming,
|
|
190
|
+
response_streaming=False,
|
|
191
|
+
)
|
|
192
|
+
async with self.around(call):
|
|
193
|
+
response = await behavior(request_or_iterator, context)
|
|
194
|
+
return response
|
|
195
|
+
|
|
196
|
+
return invoke
|
|
197
|
+
|
|
198
|
+
def _wrap_streaming_response(
|
|
199
|
+
self,
|
|
200
|
+
behavior: Callable[..., Any],
|
|
201
|
+
method_name: str,
|
|
202
|
+
request_streaming: bool,
|
|
203
|
+
) -> Callable[..., Any]:
|
|
204
|
+
async def invoke(
|
|
205
|
+
request_or_iterator: Any,
|
|
206
|
+
context: grpc.aio.ServicerContext[Any, Any],
|
|
207
|
+
) -> AsyncIterator[Any]:
|
|
208
|
+
call = RpcCall(
|
|
209
|
+
method_name=method_name,
|
|
210
|
+
request=request_or_iterator,
|
|
211
|
+
context=context,
|
|
212
|
+
request_streaming=request_streaming,
|
|
213
|
+
response_streaming=True,
|
|
214
|
+
)
|
|
215
|
+
async with self.around(call):
|
|
216
|
+
result = behavior(request_or_iterator, context)
|
|
217
|
+
if not hasattr(result, "__aiter__"):
|
|
218
|
+
# Handlers written against the read/write API are plain
|
|
219
|
+
# coroutines (returning None); interceptor-wrapped handlers
|
|
220
|
+
# may also be coroutines resolving to an async generator.
|
|
221
|
+
result = await result
|
|
222
|
+
if result is not None:
|
|
223
|
+
try:
|
|
224
|
+
async for response in result:
|
|
225
|
+
yield response
|
|
226
|
+
finally:
|
|
227
|
+
# Close the inner generator NOW (client gone / early
|
|
228
|
+
# break included) so servicer finally-blocks and DI
|
|
229
|
+
# teardown run inside the RPC, not later under the
|
|
230
|
+
# event loop's async-generator finalizer.
|
|
231
|
+
aclose = getattr(result, "aclose", None)
|
|
232
|
+
if aclose is not None:
|
|
233
|
+
await aclose()
|
|
234
|
+
|
|
235
|
+
return invoke
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Context interceptor: extract gRPC metadata into context variables / structlog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, NoReturn
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
|
|
11
|
+
from .base import AsyncServerInterceptor, RpcCall
|
|
12
|
+
from .metadata import get_metadata_dict
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import AsyncIterator, Callable
|
|
16
|
+
|
|
17
|
+
import structlog
|
|
18
|
+
else:
|
|
19
|
+
try:
|
|
20
|
+
import structlog
|
|
21
|
+
except ImportError:
|
|
22
|
+
structlog = None
|
|
23
|
+
|
|
24
|
+
__all__ = ["AsyncContextInterceptor", "HeaderConfig"]
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class HeaderConfig:
|
|
31
|
+
"""Configuration for a single header-to-context-variable mapping."""
|
|
32
|
+
|
|
33
|
+
header_name: str
|
|
34
|
+
context_var_name: str
|
|
35
|
+
context_setter: Callable[[str], Callable[[], None] | Any] | None = None
|
|
36
|
+
default_factory: Callable[[], str] | None = None
|
|
37
|
+
validator: Callable[[str], bool] | None = None
|
|
38
|
+
required: bool = False
|
|
39
|
+
allow_empty: bool = False
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
"""Validate configuration."""
|
|
43
|
+
if not self.header_name:
|
|
44
|
+
raise ValueError("header_name cannot be empty")
|
|
45
|
+
if not self.context_var_name:
|
|
46
|
+
raise ValueError("context_var_name cannot be empty")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsyncContextInterceptor(AsyncServerInterceptor):
|
|
50
|
+
"""Interceptor that extracts gRPC metadata and binds it to context variables.
|
|
51
|
+
|
|
52
|
+
This interceptor should be early in the chain so subsequent interceptors and
|
|
53
|
+
the handler see the bound context variables. It supports extracting standard
|
|
54
|
+
headers like ``x-request-id`` as well as custom header mappings. Bindings
|
|
55
|
+
stay in place for the whole RPC — including the full response stream of
|
|
56
|
+
streaming calls — and are removed afterwards.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
header_configs: list[HeaderConfig],
|
|
62
|
+
bind_method_name: bool = True,
|
|
63
|
+
bind_structlog: bool = True,
|
|
64
|
+
method_key: str = "grpc_method",
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Initialize interceptor with header configurations.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
header_configs: List of header configurations to extract and bind.
|
|
70
|
+
bind_method_name: Whether to bind the gRPC method name to context.
|
|
71
|
+
bind_structlog: Whether to bind extracted variables to structlog context
|
|
72
|
+
(no-op if structlog is not installed).
|
|
73
|
+
method_key: Key for the gRPC method name in context.
|
|
74
|
+
"""
|
|
75
|
+
super().__init__()
|
|
76
|
+
self._header_configs = header_configs
|
|
77
|
+
self._bind_method_name = bind_method_name
|
|
78
|
+
self._bind_structlog = bind_structlog
|
|
79
|
+
self._method_key = method_key
|
|
80
|
+
|
|
81
|
+
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
82
|
+
"""Bind context variables from metadata for the duration of the RPC."""
|
|
83
|
+
metadata = get_metadata_dict(call.context)
|
|
84
|
+
bound_vars, removers = await self._process_headers(metadata, call.context, call.method_name)
|
|
85
|
+
|
|
86
|
+
if self._bind_method_name:
|
|
87
|
+
bound_vars[self._method_key] = call.method_name
|
|
88
|
+
|
|
89
|
+
self._bind_context(bound_vars)
|
|
90
|
+
try:
|
|
91
|
+
yield
|
|
92
|
+
finally:
|
|
93
|
+
self._unbind_context(bound_vars, removers)
|
|
94
|
+
|
|
95
|
+
async def _process_headers(
|
|
96
|
+
self,
|
|
97
|
+
metadata: dict[str, str],
|
|
98
|
+
context: grpc.aio.ServicerContext[Any, Any],
|
|
99
|
+
method_name: str,
|
|
100
|
+
) -> tuple[dict[str, str], list[Callable[[], None]]]:
|
|
101
|
+
"""Process configured headers and extract values."""
|
|
102
|
+
bound_vars: dict[str, str] = {}
|
|
103
|
+
removers: list[Callable[[], None]] = []
|
|
104
|
+
|
|
105
|
+
for config in self._header_configs:
|
|
106
|
+
value = self._get_header_value(config, metadata)
|
|
107
|
+
|
|
108
|
+
if value is None:
|
|
109
|
+
if config.required:
|
|
110
|
+
await self._abort_request(
|
|
111
|
+
context, method_name, config.header_name, f"Required header '{config.header_name}' not found"
|
|
112
|
+
)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if not value and not config.allow_empty:
|
|
116
|
+
if config.required:
|
|
117
|
+
await self._abort_request(
|
|
118
|
+
context, method_name, config.header_name, f"Header '{config.header_name}' cannot be empty"
|
|
119
|
+
)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if config.validator and not config.validator(value):
|
|
123
|
+
if config.required:
|
|
124
|
+
await self._abort_request(
|
|
125
|
+
context, method_name, config.header_name, f"Invalid value for header '{config.header_name}'"
|
|
126
|
+
)
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if config.context_setter:
|
|
130
|
+
# The setter may return a cleanup callable (remover)
|
|
131
|
+
remover = config.context_setter(value)
|
|
132
|
+
if callable(remover):
|
|
133
|
+
removers.append(remover)
|
|
134
|
+
|
|
135
|
+
bound_vars[config.context_var_name] = value
|
|
136
|
+
|
|
137
|
+
logger.debug("Extracted context variables", extra=bound_vars)
|
|
138
|
+
return bound_vars, removers
|
|
139
|
+
|
|
140
|
+
async def _abort_request(
|
|
141
|
+
self,
|
|
142
|
+
context: grpc.aio.ServicerContext[Any, Any],
|
|
143
|
+
method_name: str,
|
|
144
|
+
header_name: str,
|
|
145
|
+
details: str,
|
|
146
|
+
) -> NoReturn:
|
|
147
|
+
"""Log and abort a gRPC request (abort raises ``grpc.aio.AbortError``)."""
|
|
148
|
+
logger.warning(
|
|
149
|
+
"gRPC request context extraction failed",
|
|
150
|
+
extra={"method": method_name, "header": header_name, "details": details},
|
|
151
|
+
)
|
|
152
|
+
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, details)
|
|
153
|
+
# grpc.aio contexts never return from abort(); guard against non-conforming ones.
|
|
154
|
+
raise RuntimeError("ServicerContext.abort() returned instead of raising")
|
|
155
|
+
|
|
156
|
+
def _get_header_value(self, config: HeaderConfig, metadata: dict[str, str]) -> str | None:
|
|
157
|
+
"""Get header value from metadata or default factory."""
|
|
158
|
+
value = metadata.get(config.header_name)
|
|
159
|
+
if value is None and config.default_factory:
|
|
160
|
+
return config.default_factory()
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
def _bind_context(self, bound_vars: dict[str, str]) -> None:
|
|
164
|
+
"""Bind variables to structlog context."""
|
|
165
|
+
if self._bind_structlog and structlog is not None:
|
|
166
|
+
structlog.contextvars.bind_contextvars(**bound_vars)
|
|
167
|
+
|
|
168
|
+
def _unbind_context(self, bound_vars: dict[str, str], removers: list[Callable[[], None]]) -> None:
|
|
169
|
+
"""Unbind variables from structlog context and call cleanup removers."""
|
|
170
|
+
if self._bind_structlog and structlog is not None and bound_vars:
|
|
171
|
+
structlog.contextvars.unbind_contextvars(*bound_vars.keys())
|
|
172
|
+
|
|
173
|
+
# Call cleanup removers in reverse order
|
|
174
|
+
while removers:
|
|
175
|
+
remover = removers.pop()
|
|
176
|
+
try:
|
|
177
|
+
remover()
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.exception("Failed to call context cleanup remover")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Exception handler interceptor: map exceptions to gRPC errors (framework-agnostic).
|
|
2
|
+
|
|
3
|
+
This interceptor is intentionally decoupled from any error/domain model. It maps an
|
|
4
|
+
exception type to a ``grpc.StatusCode`` (walking the MRO) and aborts the request with
|
|
5
|
+
a details string produced by a pluggable ``detail_factory``. The default factory emits
|
|
6
|
+
a safe, non-leaking message per status code.
|
|
7
|
+
|
|
8
|
+
To expose a structured error model (e.g. a domain ``DomainError.to_json()``), inject a
|
|
9
|
+
custom ``error_status_map`` and ``detail_factory`` — no subclassing required.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
17
|
+
|
|
18
|
+
import grpc
|
|
19
|
+
|
|
20
|
+
from grpc_server_kit.interceptors.utils import is_server_error
|
|
21
|
+
|
|
22
|
+
from .base import AsyncServerInterceptor, RpcCall
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import AsyncIterator, Callable
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"GRPC_DEFAULT_ERROR_STATUS_MAP",
|
|
29
|
+
"GRPC_SAFE_ERROR_MESSAGES",
|
|
30
|
+
"AsyncExceptionHandlerInterceptor",
|
|
31
|
+
"ErrorDetailFactory",
|
|
32
|
+
"default_error_detail",
|
|
33
|
+
"find_mapped_status",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Pluggable factory: given the raised exception and the resolved status code, return the
|
|
39
|
+
# gRPC ``details`` string to send to the client (e.g. a plain message or serialized JSON).
|
|
40
|
+
type ErrorDetailFactory = Callable[[Exception, grpc.StatusCode], str]
|
|
41
|
+
|
|
42
|
+
# Default mapping for common built-in exceptions (no domain/framework types).
|
|
43
|
+
# Deliberately excludes TypeError: it almost always signals a server-side
|
|
44
|
+
# programming bug, which must surface as INTERNAL (logged with a stack trace),
|
|
45
|
+
# not be blamed on the client as INVALID_ARGUMENT.
|
|
46
|
+
GRPC_DEFAULT_ERROR_STATUS_MAP: dict[type[Exception], grpc.StatusCode] = {
|
|
47
|
+
ValueError: grpc.StatusCode.INVALID_ARGUMENT,
|
|
48
|
+
PermissionError: grpc.StatusCode.PERMISSION_DENIED,
|
|
49
|
+
NotImplementedError: grpc.StatusCode.UNIMPLEMENTED,
|
|
50
|
+
TimeoutError: grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
51
|
+
FileNotFoundError: grpc.StatusCode.NOT_FOUND,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Safe, non-leaking messages per status code (used by the default detail factory).
|
|
55
|
+
GRPC_SAFE_ERROR_MESSAGES: dict[grpc.StatusCode, str] = {
|
|
56
|
+
grpc.StatusCode.INVALID_ARGUMENT: "Invalid request data",
|
|
57
|
+
grpc.StatusCode.PERMISSION_DENIED: "Permission denied",
|
|
58
|
+
grpc.StatusCode.UNIMPLEMENTED: "Method is not implemented",
|
|
59
|
+
grpc.StatusCode.NOT_FOUND: "Resource not found",
|
|
60
|
+
grpc.StatusCode.ALREADY_EXISTS: "Resource already exists",
|
|
61
|
+
grpc.StatusCode.FAILED_PRECONDITION: "Failed precondition",
|
|
62
|
+
grpc.StatusCode.UNAUTHENTICATED: "Unauthenticated",
|
|
63
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED: "Resource exhausted",
|
|
64
|
+
grpc.StatusCode.DEADLINE_EXCEEDED: "Deadline exceeded",
|
|
65
|
+
grpc.StatusCode.UNAVAILABLE: "Service unavailable",
|
|
66
|
+
grpc.StatusCode.ABORTED: "Operation aborted",
|
|
67
|
+
grpc.StatusCode.INTERNAL: "Internal server error",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_DEFAULT_FALLBACK_MESSAGE = "Request processing failed"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def default_error_detail(exc: Exception, status: grpc.StatusCode) -> str:
|
|
74
|
+
"""Default detail factory: a safe, non-leaking message for the resolved status code.
|
|
75
|
+
|
|
76
|
+
Never exposes the exception's own message/internals to the client.
|
|
77
|
+
"""
|
|
78
|
+
return GRPC_SAFE_ERROR_MESSAGES.get(status, _DEFAULT_FALLBACK_MESSAGE)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_mapped_status(
|
|
82
|
+
exc_type: type[BaseException],
|
|
83
|
+
error_status_map: dict[type[Exception], grpc.StatusCode],
|
|
84
|
+
) -> grpc.StatusCode:
|
|
85
|
+
"""Resolve the gRPC status an exception type maps to (walking the MRO).
|
|
86
|
+
|
|
87
|
+
Unmapped types resolve to ``INTERNAL``. Shared by the exception handler and
|
|
88
|
+
by capture filters that must classify errors with the SAME mapping.
|
|
89
|
+
"""
|
|
90
|
+
for base in exc_type.__mro__:
|
|
91
|
+
if base in error_status_map:
|
|
92
|
+
return error_status_map[base]
|
|
93
|
+
return grpc.StatusCode.INTERNAL
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AsyncExceptionHandlerInterceptor(AsyncServerInterceptor):
|
|
97
|
+
"""Interceptor that catches exceptions and converts them to gRPC errors.
|
|
98
|
+
|
|
99
|
+
Rules:
|
|
100
|
+
- ``grpc.aio.AbortError`` (the handler already aborted with its own status) and
|
|
101
|
+
``grpc.RpcError`` are re-raised as-is.
|
|
102
|
+
- ``CancelledError`` / ``KeyboardInterrupt`` / ``SystemExit`` are re-raised.
|
|
103
|
+
- Any other exception is mapped to a ``grpc.StatusCode`` (via the MRO) and the request
|
|
104
|
+
is aborted with details from the ``detail_factory``. Unmapped exceptions default to
|
|
105
|
+
``INTERNAL`` with a safe message (no internal details leaked).
|
|
106
|
+
|
|
107
|
+
Covers streaming RPCs: exceptions raised while producing a response stream are
|
|
108
|
+
mapped and aborted the same way as unary failures.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
error_status_map: dict[type[Exception], grpc.StatusCode] | None = None,
|
|
114
|
+
*,
|
|
115
|
+
detail_factory: ErrorDetailFactory | None = None,
|
|
116
|
+
merge_defaults: bool = True,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize interceptor.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
error_status_map: Mapping of exception types to gRPC status codes. User entries
|
|
122
|
+
take precedence over the built-in defaults.
|
|
123
|
+
detail_factory: Callable producing the gRPC ``details`` string from
|
|
124
|
+
``(exception, status_code)``. Defaults to :func:`default_error_detail`.
|
|
125
|
+
merge_defaults: When True (default), merge ``error_status_map`` over the built-in
|
|
126
|
+
defaults; when False, use only the provided map.
|
|
127
|
+
"""
|
|
128
|
+
super().__init__()
|
|
129
|
+
base = dict(GRPC_DEFAULT_ERROR_STATUS_MAP) if merge_defaults else {}
|
|
130
|
+
if error_status_map:
|
|
131
|
+
base.update(error_status_map)
|
|
132
|
+
self._error_status_map = base
|
|
133
|
+
self._detail_factory = detail_factory or default_error_detail
|
|
134
|
+
|
|
135
|
+
async def around_call(self, call: RpcCall) -> AsyncIterator[None]:
|
|
136
|
+
"""Run the RPC; map any unexpected exception to a gRPC abort."""
|
|
137
|
+
try:
|
|
138
|
+
yield
|
|
139
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
140
|
+
raise
|
|
141
|
+
except grpc.aio.AbortError:
|
|
142
|
+
# The handler (or an inner interceptor) already aborted this RPC with
|
|
143
|
+
# its own status — pass it through untouched.
|
|
144
|
+
raise
|
|
145
|
+
except grpc.RpcError:
|
|
146
|
+
raise
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
await self._abort_with_mapped_status(exc, call)
|
|
149
|
+
|
|
150
|
+
async def _abort_with_mapped_status(self, exc: Exception, call: RpcCall) -> NoReturn:
|
|
151
|
+
"""Map the exception to a gRPC status and abort the request."""
|
|
152
|
+
status_code = find_mapped_status(type(exc), self._error_status_map)
|
|
153
|
+
details = self._detail_factory(exc, status_code)
|
|
154
|
+
log_extra = {
|
|
155
|
+
"error_type": type(exc).__name__,
|
|
156
|
+
"method": call.method_name,
|
|
157
|
+
"status_code": status_code.name,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if is_server_error(status_code):
|
|
161
|
+
logger.exception("gRPC request failed (server error)", extra=log_extra)
|
|
162
|
+
else:
|
|
163
|
+
logger.info("gRPC request failed (client error)", extra=log_extra)
|
|
164
|
+
|
|
165
|
+
await call.context.abort(status_code, details)
|
|
166
|
+
# grpc.aio contexts never return from abort(); guard against non-conforming ones.
|
|
167
|
+
raise RuntimeError("ServicerContext.abort() returned instead of raising")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Per-request invocation-metadata parsing with a context-local cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import grpc
|
|
10
|
+
|
|
11
|
+
__all__ = ["get_metadata_dict"]
|
|
12
|
+
|
|
13
|
+
# Max bytes to represent as hex before truncating (avoids memory spike on large binary metadata).
|
|
14
|
+
_METADATA_HEX_TRUNCATE_BYTES = 512
|
|
15
|
+
|
|
16
|
+
# Context-local cache: each RPC runs in its own asyncio task (and therefore its
|
|
17
|
+
# own execution context), so a ContextVar isolates entries per request without a
|
|
18
|
+
# global registry or explicit cleanup. The cached context object is kept to
|
|
19
|
+
# guard against reuse across nested calls within one task.
|
|
20
|
+
_metadata_cache: contextvars.ContextVar[tuple[Any, dict[str, str]] | None] = contextvars.ContextVar(
|
|
21
|
+
"grpc_server_kit_metadata_cache",
|
|
22
|
+
default=None,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_metadata_dict(context: grpc.aio.ServicerContext[Any, Any]) -> dict[str, str]:
|
|
27
|
+
"""Return invocation metadata as a dictionary, cached for the current RPC.
|
|
28
|
+
|
|
29
|
+
Avoids repeated ``dict()`` conversions of the metadata across interceptors.
|
|
30
|
+
All values are converted to strings (bytes are decoded as UTF-8; undecodable
|
|
31
|
+
bytes are hex-encoded and truncated).
|
|
32
|
+
"""
|
|
33
|
+
cached = _metadata_cache.get()
|
|
34
|
+
if cached is not None and cached[0] is context:
|
|
35
|
+
return cached[1]
|
|
36
|
+
|
|
37
|
+
raw_metadata = context.invocation_metadata()
|
|
38
|
+
metadata: dict[str, str] = {}
|
|
39
|
+
if raw_metadata is not None:
|
|
40
|
+
for key, value in raw_metadata:
|
|
41
|
+
if isinstance(value, bytes):
|
|
42
|
+
try:
|
|
43
|
+
metadata[key] = value.decode("utf-8")
|
|
44
|
+
except UnicodeDecodeError:
|
|
45
|
+
metadata[key] = _bytes_to_safe_str(value)
|
|
46
|
+
else:
|
|
47
|
+
metadata[key] = value
|
|
48
|
+
|
|
49
|
+
_metadata_cache.set((context, metadata))
|
|
50
|
+
return metadata
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _bytes_to_safe_str(value: bytes) -> str:
|
|
54
|
+
"""Convert bytes to string for metadata; avoid unbounded .hex() on large payloads."""
|
|
55
|
+
if len(value) <= _METADATA_HEX_TRUNCATE_BYTES:
|
|
56
|
+
return value.hex()
|
|
57
|
+
return value[:_METADATA_HEX_TRUNCATE_BYTES].hex() + "...(truncated)"
|