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,41 @@
1
+ """Dishka provider exposing gRPC server settings + service name to the container.
2
+
3
+ Shared (sync) provider: it only holds an already-built settings object, so it lives at
4
+ the top level rather than under ``aio``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dishka import Provider, Scope, provide
10
+
11
+ from grpc_server_kit.protocols import GrpcServerSettingsProtocol, GrpcServiceName
12
+
13
+
14
+ class GrpcServerSettingsProvider(Provider):
15
+ """Provide the gRPC server settings (as ``GrpcServerSettingsProtocol``) and service name.
16
+
17
+ Construct with the service's settings object (anything structurally satisfying
18
+ ``GrpcServerSettingsProtocol`` — e.g. ``BaseGrpcServerSettings``) and the fully-qualified
19
+ gRPC service name. Replaces the per-service settings-aliasing boilerplate that the other
20
+ kit providers depend on.
21
+ """
22
+
23
+ scope = Scope.APP
24
+
25
+ def __init__(self, settings: GrpcServerSettingsProtocol, *, service_name: str) -> None:
26
+ super().__init__()
27
+ self._settings = settings
28
+ self._service_name = service_name
29
+
30
+ @provide
31
+ def settings(self) -> GrpcServerSettingsProtocol:
32
+ """Provide the gRPC server settings."""
33
+ return self._settings
34
+
35
+ @provide
36
+ def service_name(self) -> GrpcServiceName:
37
+ """Provide the fully-qualified gRPC service name (typed DI key)."""
38
+ return GrpcServiceName(self._service_name)
39
+
40
+
41
+ __all__ = ["GrpcServerSettingsProvider"]
@@ -0,0 +1,15 @@
1
+ """Shared, transport-agnostic interceptor helpers.
2
+
3
+ The async server interceptors live under :mod:`grpc_server_kit.aio.interceptors`; this
4
+ package holds only the shared constants and utilities they build on.
5
+ """
6
+
7
+ from .constants import SKIPPED_HEALTH_METHODS, X_REQUEST_ID
8
+ from .utils import is_server_error, resolve_status_code
9
+
10
+ __all__ = [
11
+ "SKIPPED_HEALTH_METHODS",
12
+ "X_REQUEST_ID",
13
+ "is_server_error",
14
+ "resolve_status_code",
15
+ ]
@@ -0,0 +1,44 @@
1
+ """Constants shared across gRPC interceptors."""
2
+
3
+ from grpc_server_kit.constants import HEALTH_SERVICE_NAME
4
+
5
+ # gRPC Health methods to skip in metrics, logging, tracing, and error tracking
6
+ # (derived from the single canonical health service name).
7
+ SKIPPED_HEALTH_METHODS = frozenset(
8
+ {
9
+ f"/{HEALTH_SERVICE_NAME}/Check",
10
+ f"/{HEALTH_SERVICE_NAME}/Watch",
11
+ }
12
+ )
13
+
14
+ # Common header names
15
+ X_REQUEST_ID = "x-request-id"
16
+
17
+ # Context variable names
18
+ REQUEST_ID_KEY = "request_id"
19
+ METHOD_KEY = "method"
20
+
21
+ # OpenTelemetry semantic-convention attributes
22
+ OTEL_RPC_SYSTEM = "rpc.system"
23
+ OTEL_RPC_METHOD = "rpc.method"
24
+ OTEL_RPC_SERVICE = "rpc.service"
25
+ OTEL_RPC_GRPC_STATUS_CODE = "rpc.grpc.status_code"
26
+ OTEL_REQUEST_ID = "request.id"
27
+
28
+ # Sentry tag names
29
+ SENTRY_GRPC_METHOD = "grpc_method"
30
+ SENTRY_REQUEST_ID = "request_id"
31
+
32
+ __all__ = [
33
+ "METHOD_KEY",
34
+ "OTEL_REQUEST_ID",
35
+ "OTEL_RPC_GRPC_STATUS_CODE",
36
+ "OTEL_RPC_METHOD",
37
+ "OTEL_RPC_SERVICE",
38
+ "OTEL_RPC_SYSTEM",
39
+ "REQUEST_ID_KEY",
40
+ "SENTRY_GRPC_METHOD",
41
+ "SENTRY_REQUEST_ID",
42
+ "SKIPPED_HEALTH_METHODS",
43
+ "X_REQUEST_ID",
44
+ ]
@@ -0,0 +1,74 @@
1
+ """Utility functions for gRPC interceptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import grpc
8
+
9
+
10
+ def is_server_error(code: grpc.StatusCode) -> bool:
11
+ """Check if the gRPC status code is a server-side error (5xx equivalent).
12
+
13
+ Server-side errors include internal failures, timeouts, and unavailability.
14
+ If the code is not one of these, it is either OK or a client-side error (4xx equivalent).
15
+ """
16
+ return code in (
17
+ grpc.StatusCode.INTERNAL,
18
+ grpc.StatusCode.UNKNOWN,
19
+ grpc.StatusCode.DATA_LOSS,
20
+ grpc.StatusCode.UNIMPLEMENTED,
21
+ grpc.StatusCode.UNAVAILABLE,
22
+ grpc.StatusCode.DEADLINE_EXCEEDED,
23
+ )
24
+
25
+
26
+ def resolve_status_code(
27
+ exc: BaseException | None,
28
+ context: Any,
29
+ default: grpc.StatusCode = grpc.StatusCode.UNKNOWN,
30
+ ) -> grpc.StatusCode:
31
+ """Best-effort gRPC status code for a finished or failed RPC.
32
+
33
+ Prefers the exception's own ``code()`` (client-style ``RpcError``); falls
34
+ back to the servicer context's code (set by ``abort()`` / ``set_code()``)
35
+ when the exception carries no code or only a generic ``UNKNOWN``; returns
36
+ ``default`` when neither yields a :class:`grpc.StatusCode`.
37
+ """
38
+ exc_code = _status_code_of_exception(exc)
39
+ if exc_code is not None and exc_code is not grpc.StatusCode.UNKNOWN:
40
+ return exc_code
41
+
42
+ ctx_code = _status_code_of_context(context)
43
+ if ctx_code is not None and ctx_code is not grpc.StatusCode.OK:
44
+ return ctx_code
45
+ if exc_code is not None:
46
+ # UNKNOWN from the exception and nothing better on the context.
47
+ return exc_code
48
+ if ctx_code is not None:
49
+ return ctx_code
50
+
51
+ return default
52
+
53
+
54
+ def _status_code_of_exception(exc: BaseException | None) -> grpc.StatusCode | None:
55
+ if exc is None:
56
+ return None
57
+ code_func = getattr(exc, "code", None)
58
+ if callable(code_func):
59
+ code = code_func()
60
+ if isinstance(code, grpc.StatusCode):
61
+ return code
62
+ return None
63
+
64
+
65
+ def _status_code_of_context(context: Any) -> grpc.StatusCode | None:
66
+ code_func = getattr(context, "code", None)
67
+ if not callable(code_func):
68
+ return None
69
+ try:
70
+ code = code_func()
71
+ except Exception:
72
+ # Non-conforming context objects fall through to the default.
73
+ return None
74
+ return code if isinstance(code, grpc.StatusCode) else None
@@ -0,0 +1,12 @@
1
+ """Optional observability wiring for the gRPC server.
2
+
3
+ Submodules are import-gated by their own extras so importing this package never
4
+ pulls heavy dependencies:
5
+
6
+ - ``grpc_server_kit.observability.protocols`` — SDK-free seam protocols (core)
7
+ - ``grpc_server_kit.observability.metrics`` — Prometheus metrics (``[metrics]`` extra)
8
+ - ``grpc_server_kit.observability.sentry`` — Sentry adapter (``[sentry]`` extra)
9
+
10
+ OpenTelemetry instrumentation for the async server lives at
11
+ ``grpc_server_kit.aio.observability.tracing`` (``[tracing]`` extra).
12
+ """
@@ -0,0 +1,116 @@
1
+ """Prometheus metric definitions for the gRPC server (``[metrics]`` extra).
2
+
3
+ ``GrpcServerMetrics`` structurally satisfies
4
+ :class:`grpc_server_kit.aio.interceptors.protocols.GrpcServerMetricsProtocol`, so it can
5
+ be passed straight to ``AsyncMetricsInterceptor``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from grpc_server_kit.observability.naming import make_metric_name
11
+
12
+ try:
13
+ from prometheus_client import REGISTRY, CollectorRegistry, Counter, Histogram
14
+
15
+ HAS_PROMETHEUS = True
16
+ except ImportError: # pragma: no cover - exercised only when the [metrics] extra is absent
17
+ HAS_PROMETHEUS = False
18
+
19
+ __all__ = [
20
+ "DEFAULT_GRPC_BUCKETS",
21
+ "GrpcServerMetrics",
22
+ "get_grpc_server_metrics",
23
+ "make_metric_name",
24
+ ]
25
+
26
+ # Default histogram buckets for gRPC request duration (seconds).
27
+ DEFAULT_GRPC_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
28
+
29
+ _MISSING_MESSAGE = "Install grpc-server-kit[metrics] (prometheus-client) to use gRPC server metrics"
30
+
31
+
32
+ class GrpcServerMetrics:
33
+ """gRPC server metrics.
34
+
35
+ Metrics:
36
+ - ``grpc_requests_total`` (Counter) — labels: service, method, status, grpc_code
37
+ - ``grpc_request_duration_seconds`` (Histogram) — labels: service, method
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ prefix: str | None = None,
43
+ buckets: tuple[float, ...] = DEFAULT_GRPC_BUCKETS,
44
+ registry: CollectorRegistry | None = None,
45
+ ) -> None:
46
+ """Initialize gRPC server metrics.
47
+
48
+ Args:
49
+ prefix: Optional metric name prefix.
50
+ buckets: Histogram buckets for request duration.
51
+ registry: Collector registry to register with (defaults to the global one).
52
+
53
+ Raises:
54
+ ImportError: If prometheus-client is not installed.
55
+ """
56
+ if not HAS_PROMETHEUS:
57
+ raise ImportError(_MISSING_MESSAGE)
58
+
59
+ self.buckets: tuple[float, ...] = tuple(buckets)
60
+ reg = registry if registry is not None else REGISTRY
61
+ self.requests_total = Counter(
62
+ make_metric_name("grpc_requests_total", prefix),
63
+ "Total number of gRPC requests",
64
+ ["service", "method", "status", "grpc_code"],
65
+ registry=reg,
66
+ )
67
+ self.request_duration = Histogram(
68
+ make_metric_name("grpc_request_duration_seconds", prefix),
69
+ "gRPC request duration in seconds",
70
+ ["service", "method"],
71
+ buckets=list(buckets),
72
+ registry=reg,
73
+ )
74
+
75
+ def record_request(
76
+ self,
77
+ service: str,
78
+ method: str,
79
+ status: str,
80
+ grpc_code: str,
81
+ duration: float,
82
+ ) -> None:
83
+ """Record a completed gRPC request."""
84
+ self.requests_total.labels(service=service, method=method, status=status, grpc_code=grpc_code).inc()
85
+ self.request_duration.labels(service=service, method=method).observe(duration)
86
+
87
+
88
+ _SERVER_METRICS_CACHE: dict[str | None, GrpcServerMetrics] = {}
89
+
90
+
91
+ def get_grpc_server_metrics(
92
+ prefix: str | None = None,
93
+ buckets: tuple[float, ...] | None = None,
94
+ ) -> GrpcServerMetrics:
95
+ """Get (or lazily create) a cached ``GrpcServerMetrics`` for the given prefix.
96
+
97
+ Caching by prefix avoids Prometheus "duplicated timeseries" errors when the helper
98
+ is called more than once for the same prefix.
99
+
100
+ Raises:
101
+ ValueError: If the prefix is already cached with DIFFERENT buckets —
102
+ silently returning the old instance would record latencies into the
103
+ wrong histogram bounds with no error.
104
+ """
105
+ requested = tuple(buckets) if buckets is not None else DEFAULT_GRPC_BUCKETS
106
+ cached = _SERVER_METRICS_CACHE.get(prefix)
107
+ if cached is not None:
108
+ if buckets is not None and requested != cached.buckets:
109
+ raise ValueError(
110
+ f"GrpcServerMetrics for prefix {prefix!r} already exists with buckets "
111
+ f"{cached.buckets}; cannot re-create it with {requested}"
112
+ )
113
+ return cached
114
+ metrics = GrpcServerMetrics(prefix=prefix, buckets=requested)
115
+ _SERVER_METRICS_CACHE[prefix] = metrics
116
+ return metrics
@@ -0,0 +1,26 @@
1
+ """Metric naming helpers (pure stdlib, backend-neutral)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _PREFIX_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
8
+
9
+
10
+ def make_metric_name(name: str, prefix: str | None = None) -> str:
11
+ """Combine ``prefix`` and ``name`` per metric identifier conventions.
12
+
13
+ Raises:
14
+ ValueError: If the prefix is not a valid metric name fragment.
15
+ """
16
+ if prefix:
17
+ if not _PREFIX_PATTERN.match(prefix):
18
+ raise ValueError(
19
+ f"Invalid metric prefix: {prefix!r}. Prefixes must start with a letter or "
20
+ "underscore and contain only letters, numbers, or underscores."
21
+ )
22
+ return f"{prefix}_{name}"
23
+ return name
24
+
25
+
26
+ __all__ = ["make_metric_name"]
@@ -0,0 +1,86 @@
1
+ """Observability seam protocols (duck-typing, SDK-free).
2
+
3
+ Pure structural protocols that the kit's interceptors call without knowing the
4
+ concrete backend. The names are vendor-neutral: an OpenTelemetry tracer, the
5
+ Sentry SDK adapter, or any test double with the same shape fits. Downstream
6
+ frameworks (e.g. servicewright) adapt to these shapes — protocols match
7
+ structurally, so no import dependency is required in either direction.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from contextlib import AbstractContextManager
13
+ from types import TracebackType
14
+ from typing import Protocol, runtime_checkable
15
+
16
+ type SpanAttributeValue = str | int | float | bool | list[str] | list[int] | list[float] | list[bool]
17
+
18
+
19
+ @runtime_checkable
20
+ class SpanProtocol(Protocol):
21
+ """A tracing span context manager."""
22
+
23
+ def set_attribute(self, key: str, value: SpanAttributeValue) -> None:
24
+ """Attach a typed attribute to the span."""
25
+ ...
26
+
27
+ def record_exception(self, exception: Exception) -> None:
28
+ """Record an exception against the span."""
29
+ ...
30
+
31
+ def __enter__(self) -> SpanProtocol: ...
32
+
33
+ def __exit__(
34
+ self,
35
+ exc_type: type[BaseException] | None,
36
+ exc_value: BaseException | None,
37
+ traceback: TracebackType | None,
38
+ ) -> None: ...
39
+
40
+
41
+ @runtime_checkable
42
+ class TracerProtocol(Protocol):
43
+ """Mints spans for the active trace."""
44
+
45
+ def start_as_current_span(self, name: str) -> SpanProtocol:
46
+ """Start a span and make it current for its ``with`` block."""
47
+ ...
48
+
49
+
50
+ class ErrorReporterProtocol(Protocol):
51
+ """Error-reporting seam (vendor-neutral; any tracker with this shape fits)."""
52
+
53
+ def isolate(self) -> AbstractContextManager[object]:
54
+ """Return a context manager forking an isolated per-unit scope.
55
+
56
+ Tags and breadcrumbs set inside the block must not leak into other
57
+ concurrent units of work. Backends without scope isolation may return
58
+ ``contextlib.nullcontext()``.
59
+ """
60
+ ...
61
+
62
+ def capture_exception(self, error: Exception) -> None:
63
+ """Report an exception to the error tracker."""
64
+ ...
65
+
66
+ def add_breadcrumb(
67
+ self,
68
+ message: str,
69
+ category: str = "default",
70
+ level: str = "info",
71
+ data: dict[str, object] | None = None,
72
+ ) -> None:
73
+ """Attach a breadcrumb to the current scope."""
74
+ ...
75
+
76
+ def set_tags(self, **tags: str) -> None:
77
+ """Set tags on the current scope."""
78
+ ...
79
+
80
+
81
+ __all__ = [
82
+ "ErrorReporterProtocol",
83
+ "SpanAttributeValue",
84
+ "SpanProtocol",
85
+ "TracerProtocol",
86
+ ]
@@ -0,0 +1,63 @@
1
+ """Sentry adapter implementing the kit's ``ErrorReporterProtocol`` over ``sentry-sdk``.
2
+
3
+ Requires the ``[sentry]`` extra. Pass a ``SentrySdkAdapter`` instance to
4
+ ``AsyncSentryInterceptor`` to report unexpected gRPC errors to Sentry.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from contextlib import AbstractContextManager
13
+
14
+ try:
15
+ import sentry_sdk
16
+
17
+ HAS_SENTRY = True
18
+ except ImportError: # pragma: no cover - exercised only when the [sentry] extra is absent
19
+ HAS_SENTRY = False
20
+
21
+ __all__ = ["SentrySdkAdapter"]
22
+
23
+ _MISSING_MESSAGE = "Install grpc-server-kit[sentry] (sentry-sdk) to use the Sentry adapter"
24
+
25
+
26
+ class SentrySdkAdapter:
27
+ """Adapts the global ``sentry-sdk`` API to ``ErrorReporterProtocol``."""
28
+
29
+ def __init__(self) -> None:
30
+ """Initialize the adapter.
31
+
32
+ Raises:
33
+ ImportError: If sentry-sdk is not installed.
34
+ """
35
+ if not HAS_SENTRY:
36
+ raise ImportError(_MISSING_MESSAGE)
37
+
38
+ def isolate(self) -> AbstractContextManager[object]:
39
+ """Fork an isolated Sentry scope for one unit of work.
40
+
41
+ Without this, concurrent asyncio tasks share one isolation scope and
42
+ contaminate each other's tags/breadcrumbs.
43
+ """
44
+ return sentry_sdk.isolation_scope()
45
+
46
+ def add_breadcrumb(
47
+ self,
48
+ message: str,
49
+ category: str = "default",
50
+ level: str = "info",
51
+ data: dict[str, object] | None = None,
52
+ ) -> None:
53
+ """Add a breadcrumb to the current Sentry scope."""
54
+ sentry_sdk.add_breadcrumb(message=message, category=category, level=level, data=data or {})
55
+
56
+ def capture_exception(self, error: Exception) -> None:
57
+ """Capture an exception in Sentry."""
58
+ sentry_sdk.capture_exception(error)
59
+
60
+ def set_tags(self, **tags: str) -> None:
61
+ """Set tags on the current Sentry scope."""
62
+ for key, value in tags.items():
63
+ sentry_sdk.set_tag(key, value)
@@ -0,0 +1,136 @@
1
+ """gRPC server options and compression configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .constants import (
6
+ DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA,
7
+ DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
8
+ DEFAULT_INITIAL_WINDOW_SIZE,
9
+ DEFAULT_KEEPALIVE_TIME_MS,
10
+ DEFAULT_KEEPALIVE_TIMEOUT_MS,
11
+ DEFAULT_MAX_METADATA_SIZE,
12
+ DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH,
13
+ DEFAULT_MAX_SEND_MESSAGE_LENGTH,
14
+ )
15
+ from .protocols import GrpcSettingsProtocol
16
+
17
+ type GrpcOption = tuple[str, int]
18
+ type GrpcOptions = list[GrpcOption]
19
+
20
+ COMPRESSION_ALGORITHMS = {
21
+ "none": 0,
22
+ "deflate": 1,
23
+ "gzip": 2,
24
+ }
25
+
26
+
27
+ def build_grpc_options(settings: GrpcSettingsProtocol) -> GrpcOptions:
28
+ """Build gRPC server options from settings.
29
+
30
+ Settings fields set to ``None`` fall back to the kit's defaults; invalid
31
+ values fail loudly — a misconfigured server must not start.
32
+
33
+ Args:
34
+ settings: gRPC configuration settings following GrpcSettingsProtocol
35
+
36
+ Returns:
37
+ List of tuples containing gRPC channel options
38
+
39
+ Raises:
40
+ ValueError: If any option value is negative or the compression
41
+ algorithm is not supported.
42
+ """
43
+ options: GrpcOptions = [
44
+ # Message size
45
+ (
46
+ "grpc.max_send_message_length",
47
+ _require_option(
48
+ settings.max_send_message_length, "max_send_message_length", DEFAULT_MAX_SEND_MESSAGE_LENGTH
49
+ ),
50
+ ),
51
+ (
52
+ "grpc.max_receive_message_length",
53
+ _require_option(
54
+ settings.max_receive_message_length, "max_receive_message_length", DEFAULT_MAX_RECEIVE_MESSAGE_LENGTH
55
+ ),
56
+ ),
57
+ (
58
+ "grpc.max_metadata_size",
59
+ _require_option(settings.max_metadata_size, "max_metadata_size", DEFAULT_MAX_METADATA_SIZE),
60
+ ),
61
+ # Keepalive
62
+ (
63
+ "grpc.keepalive_time_ms",
64
+ _require_option(settings.keepalive_time_ms, "keepalive_time_ms", DEFAULT_KEEPALIVE_TIME_MS),
65
+ ),
66
+ (
67
+ "grpc.keepalive_timeout_ms",
68
+ _require_option(settings.keepalive_timeout_ms, "keepalive_timeout_ms", DEFAULT_KEEPALIVE_TIMEOUT_MS),
69
+ ),
70
+ ("grpc.keepalive_permit_without_calls", int(settings.keepalive_permit_without_calls)),
71
+ (
72
+ "grpc.http2.min_recv_ping_interval_without_data_ms",
73
+ _require_option(
74
+ settings.http2_min_recv_ping_interval_without_data_ms,
75
+ "http2_min_recv_ping_interval_without_data_ms",
76
+ DEFAULT_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
77
+ ),
78
+ ),
79
+ (
80
+ "grpc.http2.max_pings_without_data",
81
+ _require_option(
82
+ settings.http2_max_pings_without_data,
83
+ "http2_max_pings_without_data",
84
+ DEFAULT_HTTP2_MAX_PINGS_WITHOUT_DATA,
85
+ ),
86
+ ),
87
+ # Flow control
88
+ (
89
+ "grpc.http2.initial_stream_window_size",
90
+ _require_option(
91
+ settings.initial_stream_window_size, "initial_stream_window_size", DEFAULT_INITIAL_WINDOW_SIZE
92
+ ),
93
+ ),
94
+ (
95
+ "grpc.http2.initial_connection_window_size",
96
+ _require_option(
97
+ settings.initial_connection_window_size, "initial_connection_window_size", DEFAULT_INITIAL_WINDOW_SIZE
98
+ ),
99
+ ),
100
+ ]
101
+
102
+ # Optional: connection limits (omitted entirely when not configured)
103
+ for option_name, value, setting_name in (
104
+ ("grpc.max_concurrent_streams", settings.max_concurrent_rpcs, "max_concurrent_rpcs"),
105
+ ("grpc.max_connection_idle_ms", settings.max_connection_idle_ms, "max_connection_idle_ms"),
106
+ ("grpc.max_connection_age_ms", settings.max_connection_age_ms, "max_connection_age_ms"),
107
+ ("grpc.max_connection_age_grace_ms", settings.max_connection_age_grace_ms, "max_connection_age_grace_ms"),
108
+ ):
109
+ if value is not None:
110
+ options.append((option_name, _require_non_negative(value, setting_name)))
111
+
112
+ # Compression
113
+ if settings.compression_algorithm:
114
+ algo = settings.compression_algorithm.lower()
115
+ if algo not in COMPRESSION_ALGORITHMS:
116
+ supported = ", ".join(sorted(COMPRESSION_ALGORITHMS))
117
+ raise ValueError(
118
+ f"Unsupported compression_algorithm: {settings.compression_algorithm!r}. Supported: {supported}"
119
+ )
120
+ options.append(("grpc.default_compression_algorithm", COMPRESSION_ALGORITHMS[algo]))
121
+
122
+ return options
123
+
124
+
125
+ def _require_option(value: int | None, name: str, default: int) -> int:
126
+ """Return ``default`` when unset; reject negative values loudly."""
127
+ if value is None:
128
+ return default
129
+ return _require_non_negative(value, name)
130
+
131
+
132
+ def _require_non_negative(value: int, name: str) -> int:
133
+ """Validate that a configured option value is non-negative."""
134
+ if value < 0:
135
+ raise ValueError(f"{name} must be non-negative, got {value}")
136
+ return value