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,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)"