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,116 @@
|
|
|
1
|
+
"""Dishka provider assembling the canonical gRPC server interceptor chain (``[dishka]`` extra).
|
|
2
|
+
|
|
3
|
+
Replaces the per-service hand-written ``create_interceptors`` list. The order is fixed and
|
|
4
|
+
encodes the documented rationale: metrics outermost (measures everything below); the
|
|
5
|
+
exception handler maps raw exceptions to gRPC statuses; Sentry sits INSIDE the exception
|
|
6
|
+
handler so it observes raw handler exceptions BEFORE they are converted into
|
|
7
|
+
``grpc.aio.AbortError`` (outside it would never capture anything); the request-scoped
|
|
8
|
+
Dishka interceptor is innermost. Individual interceptors can be toggled off and extra
|
|
9
|
+
ones slotted at the outer/inner ends.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
import grpc
|
|
17
|
+
from dishka import AsyncContainer, Provider, Scope, provide
|
|
18
|
+
from dishka.integrations.grpcio import DishkaAioInterceptor
|
|
19
|
+
|
|
20
|
+
from grpc_server_kit.aio.interceptors import (
|
|
21
|
+
GRPC_DEFAULT_ERROR_STATUS_MAP,
|
|
22
|
+
AsyncContextInterceptor,
|
|
23
|
+
AsyncExceptionHandlerInterceptor,
|
|
24
|
+
AsyncMetricsInterceptor,
|
|
25
|
+
AsyncRequestLoggerInterceptor,
|
|
26
|
+
AsyncSentryInterceptor,
|
|
27
|
+
AsyncTracingInterceptor,
|
|
28
|
+
ErrorDetailFactory,
|
|
29
|
+
HeaderConfig,
|
|
30
|
+
)
|
|
31
|
+
from grpc_server_kit.aio.interceptors.exception_handler import find_mapped_status
|
|
32
|
+
from grpc_server_kit.aio.interceptors.protocols import (
|
|
33
|
+
ErrorReporterProtocol,
|
|
34
|
+
GrpcServerMetricsProtocol,
|
|
35
|
+
TracerProtocol,
|
|
36
|
+
)
|
|
37
|
+
from grpc_server_kit.interceptors.utils import is_server_error
|
|
38
|
+
from grpc_server_kit.protocols import GrpcServiceName
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GrpcServerInterceptorsProvider(Provider):
|
|
42
|
+
"""Provide the ordered ``list[grpc.aio.ServerInterceptor]`` for the server.
|
|
43
|
+
|
|
44
|
+
Depends on the metrics/sentry/tracer seams (``... | None``) — register the matching
|
|
45
|
+
seam providers (or use :func:`grpc_server_kit.aio.dishka.grpc_server_providers`).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
scope = Scope.APP
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
error_status_map: dict[type[Exception], grpc.StatusCode] | None = None,
|
|
54
|
+
detail_factory: ErrorDetailFactory | None = None,
|
|
55
|
+
header_configs: Sequence[HeaderConfig] | None = None,
|
|
56
|
+
include_metrics: bool = True,
|
|
57
|
+
include_context: bool = True,
|
|
58
|
+
include_request_logger: bool = True,
|
|
59
|
+
include_tracing: bool = True,
|
|
60
|
+
include_sentry: bool = True,
|
|
61
|
+
include_exception_handler: bool = True,
|
|
62
|
+
extra_outer: Sequence[grpc.aio.ServerInterceptor] | None = None,
|
|
63
|
+
extra_inner: Sequence[grpc.aio.ServerInterceptor] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self._error_status_map = error_status_map
|
|
67
|
+
self._detail_factory = detail_factory
|
|
68
|
+
self._header_configs = list(header_configs) if header_configs is not None else []
|
|
69
|
+
self._include_metrics = include_metrics
|
|
70
|
+
self._include_context = include_context
|
|
71
|
+
self._include_request_logger = include_request_logger
|
|
72
|
+
self._include_tracing = include_tracing
|
|
73
|
+
self._include_sentry = include_sentry
|
|
74
|
+
self._include_exception_handler = include_exception_handler
|
|
75
|
+
self._extra_outer = list(extra_outer or [])
|
|
76
|
+
self._extra_inner = list(extra_inner or [])
|
|
77
|
+
|
|
78
|
+
@provide
|
|
79
|
+
def interceptors(
|
|
80
|
+
self,
|
|
81
|
+
container: AsyncContainer,
|
|
82
|
+
service_name: GrpcServiceName,
|
|
83
|
+
metrics: GrpcServerMetricsProtocol | None,
|
|
84
|
+
sentry: ErrorReporterProtocol | None,
|
|
85
|
+
tracer: TracerProtocol | None,
|
|
86
|
+
) -> list[grpc.aio.ServerInterceptor]:
|
|
87
|
+
"""Build the interceptor chain (outermost first; Dishka request-scope interceptor last)."""
|
|
88
|
+
chain: list[grpc.aio.ServerInterceptor] = list(self._extra_outer)
|
|
89
|
+
if self._include_metrics:
|
|
90
|
+
chain.append(AsyncMetricsInterceptor(metrics=metrics, service_name=service_name))
|
|
91
|
+
if self._include_context:
|
|
92
|
+
chain.append(AsyncContextInterceptor(header_configs=self._header_configs))
|
|
93
|
+
if self._include_request_logger:
|
|
94
|
+
chain.append(AsyncRequestLoggerInterceptor())
|
|
95
|
+
if self._include_tracing:
|
|
96
|
+
chain.append(AsyncTracingInterceptor(service_name=service_name, tracer=tracer))
|
|
97
|
+
if self._include_exception_handler:
|
|
98
|
+
chain.append(AsyncExceptionHandlerInterceptor(self._error_status_map, detail_factory=self._detail_factory))
|
|
99
|
+
if self._include_sentry:
|
|
100
|
+
# Inside the exception handler: sees raw handler exceptions before
|
|
101
|
+
# they are mapped into AbortError (which Sentry must not capture).
|
|
102
|
+
# The capture filter reuses the SAME status map, so client-mapped
|
|
103
|
+
# errors (e.g. ValueError -> INVALID_ARGUMENT) are not reported.
|
|
104
|
+
merged_map = dict(GRPC_DEFAULT_ERROR_STATUS_MAP)
|
|
105
|
+
merged_map.update(self._error_status_map or {})
|
|
106
|
+
|
|
107
|
+
def _server_errors_only(exc: Exception, _map: dict = merged_map) -> bool: # type: ignore[type-arg]
|
|
108
|
+
return is_server_error(find_mapped_status(type(exc), _map))
|
|
109
|
+
|
|
110
|
+
chain.append(AsyncSentryInterceptor(sentry=sentry, capture_filter=_server_errors_only))
|
|
111
|
+
chain.extend(self._extra_inner)
|
|
112
|
+
chain.append(DishkaAioInterceptor(container)) # innermost: opens the REQUEST scope per RPC
|
|
113
|
+
return chain
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["GrpcServerInterceptorsProvider"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Dishka provider for gRPC server Prometheus metrics (``[metrics]`` extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from dishka import Provider, Scope, provide
|
|
8
|
+
|
|
9
|
+
from grpc_server_kit.aio.interceptors.protocols import GrpcServerMetricsProtocol
|
|
10
|
+
from grpc_server_kit.observability.metrics import HAS_PROMETHEUS, GrpcServerMetrics, get_grpc_server_metrics
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PrometheusGrpcServerMetricsProvider(Provider):
|
|
16
|
+
"""Provide ``GrpcServerMetrics`` (and its protocol view) for the metrics interceptor.
|
|
17
|
+
|
|
18
|
+
Degrades gracefully like the sibling Sentry/tracer seam providers: when
|
|
19
|
+
``enabled`` is False OR the ``[metrics]`` extra is absent, provides ``None``
|
|
20
|
+
(the metrics interceptor then no-ops), so the provider can always be
|
|
21
|
+
registered.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
scope = Scope.APP
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, enabled: bool = True, prefix: str | None = None) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._enabled = enabled
|
|
29
|
+
self._prefix = prefix
|
|
30
|
+
|
|
31
|
+
@provide
|
|
32
|
+
def metrics(self) -> GrpcServerMetrics | None:
|
|
33
|
+
"""Provide the concrete metrics instance (cached by prefix), or None when disabled/absent."""
|
|
34
|
+
if not self._enabled:
|
|
35
|
+
return None
|
|
36
|
+
if not HAS_PROMETHEUS:
|
|
37
|
+
logger.warning(
|
|
38
|
+
"gRPC server metrics are enabled but prometheus-client is not installed; "
|
|
39
|
+
"metrics are disabled (install grpc-server-kit[metrics] to enable them)"
|
|
40
|
+
)
|
|
41
|
+
return None
|
|
42
|
+
return get_grpc_server_metrics(self._prefix)
|
|
43
|
+
|
|
44
|
+
@provide
|
|
45
|
+
def metrics_protocol(self, metrics: GrpcServerMetrics | None) -> GrpcServerMetricsProtocol | None:
|
|
46
|
+
"""Provide the protocol-typed view consumed by ``AsyncMetricsInterceptor``."""
|
|
47
|
+
return metrics
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["PrometheusGrpcServerMetricsProvider"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Dishka providers for the Sentry / Tracer interceptor seams.
|
|
2
|
+
|
|
3
|
+
These supply the protocol-typed ``... | None`` objects the Sentry/Tracing interceptors
|
|
4
|
+
accept; they DO NOT initialize the SDKs (``sentry_sdk.init`` / OpenTelemetry provider
|
|
5
|
+
setup are process-wide concerns owned by the application runtime, not DI). Each degrades
|
|
6
|
+
to ``None`` when its extra is absent or disabled (the interceptor then no-ops).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from opentelemetry import trace
|
|
15
|
+
|
|
16
|
+
HAS_OTEL = True
|
|
17
|
+
except ImportError: # pragma: no cover - exercised only when the [tracing] extra is absent
|
|
18
|
+
HAS_OTEL = False
|
|
19
|
+
|
|
20
|
+
from dishka import Provider, Scope, provide
|
|
21
|
+
|
|
22
|
+
from grpc_server_kit.aio.interceptors.protocols import ErrorReporterProtocol, TracerProtocol
|
|
23
|
+
from grpc_server_kit.observability.sentry import HAS_SENTRY, SentrySdkAdapter
|
|
24
|
+
from grpc_server_kit.protocols import GrpcServiceName
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SentryAdapterProvider(Provider):
|
|
28
|
+
"""Provide an ``ErrorReporterProtocol`` seam for ``AsyncSentryInterceptor`` (None when disabled/absent)."""
|
|
29
|
+
|
|
30
|
+
scope = Scope.APP
|
|
31
|
+
|
|
32
|
+
def __init__(self, *, enabled: bool = True) -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self._enabled = enabled
|
|
35
|
+
|
|
36
|
+
@provide
|
|
37
|
+
def sentry(self) -> ErrorReporterProtocol | None:
|
|
38
|
+
"""Provide the Sentry adapter, or None when disabled or ``[sentry]`` is absent."""
|
|
39
|
+
if not self._enabled or not HAS_SENTRY:
|
|
40
|
+
return None
|
|
41
|
+
return SentrySdkAdapter()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _OtelTracerAdapter:
|
|
45
|
+
"""Adapt an OTel tracer to ``TracerProtocol`` with SDK auto-recording disabled.
|
|
46
|
+
|
|
47
|
+
OTel's ``start_as_current_span`` defaults to ``record_exception=True`` /
|
|
48
|
+
``set_status_on_exception=True``, which would record a junk ``AbortError``
|
|
49
|
+
event (and force ERROR status) for every deliberate abort passing through
|
|
50
|
+
the span, and double-record genuine server errors. The kit's tracing
|
|
51
|
+
interceptor records exceptions selectively, so the SDK automation is off.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, tracer: Any) -> None:
|
|
55
|
+
self._tracer = tracer
|
|
56
|
+
|
|
57
|
+
def start_as_current_span(self, name: str) -> Any:
|
|
58
|
+
return self._tracer.start_as_current_span(
|
|
59
|
+
name,
|
|
60
|
+
record_exception=False,
|
|
61
|
+
set_status_on_exception=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TracerAdapterProvider(Provider):
|
|
66
|
+
"""Provide a ``TracerProtocol`` seam for ``AsyncTracingInterceptor`` (None when disabled/absent).
|
|
67
|
+
|
|
68
|
+
When enabled and ``[tracing]`` is installed, supplies the process OpenTelemetry tracer
|
|
69
|
+
named after the service.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
scope = Scope.APP
|
|
73
|
+
|
|
74
|
+
def __init__(self, *, enabled: bool = True) -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self._enabled = enabled
|
|
77
|
+
|
|
78
|
+
@provide
|
|
79
|
+
def tracer(self, service_name: GrpcServiceName) -> TracerProtocol | None:
|
|
80
|
+
"""Provide the OTel tracer, or None when disabled or ``[tracing]`` is absent."""
|
|
81
|
+
if not self._enabled or not HAS_OTEL:
|
|
82
|
+
return None
|
|
83
|
+
return cast("TracerProtocol", _OtelTracerAdapter(trace.get_tracer(service_name)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["SentryAdapterProvider", "TracerAdapterProvider"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Dishka provider building the configured ``AsyncServer`` (``[dishka]`` extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
import grpc
|
|
8
|
+
from dishka import Provider, Scope, provide
|
|
9
|
+
|
|
10
|
+
from grpc_server_kit.aio.builder import AsyncGrpcServerBuilder
|
|
11
|
+
from grpc_server_kit.aio.server import AsyncServer
|
|
12
|
+
from grpc_server_kit.protocols import GrpcServerSettingsProtocol, GrpcServiceName
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncGrpcServerProvider(Provider):
|
|
16
|
+
"""Provide an ``AsyncServer`` wired with interceptors, options, and reflection/channelz.
|
|
17
|
+
|
|
18
|
+
The server is returned WITHOUT servicers registered: register your servicers on
|
|
19
|
+
the resolved server after resolving it (servicer registration is service-specific and
|
|
20
|
+
usually needs other APP-scoped singletons). Reflection (when ``settings.enable_reflection``)
|
|
21
|
+
defaults to advertising only the service name — pass ``reflection_service_names``
|
|
22
|
+
explicitly to advertise more (e.g. the health service, once you register its servicer).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
scope = Scope.APP
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, reflection_service_names: Sequence[str] | None = None) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._reflection_service_names = list(reflection_service_names) if reflection_service_names else None
|
|
30
|
+
|
|
31
|
+
@provide
|
|
32
|
+
def server(
|
|
33
|
+
self,
|
|
34
|
+
settings: GrpcServerSettingsProtocol,
|
|
35
|
+
interceptors: list[grpc.aio.ServerInterceptor],
|
|
36
|
+
service_name: GrpcServiceName,
|
|
37
|
+
) -> AsyncServer:
|
|
38
|
+
"""Build the server from settings + the injected interceptor chain."""
|
|
39
|
+
builder = AsyncGrpcServerBuilder(settings).with_interceptors(interceptors)
|
|
40
|
+
if settings.enable_reflection:
|
|
41
|
+
# Only names whose servicers this bundle actually registers may be
|
|
42
|
+
# advertised by default — advertising the health service without a
|
|
43
|
+
# registered health servicer would reflect an UNIMPLEMENTED service.
|
|
44
|
+
names = self._reflection_service_names or [str(service_name)]
|
|
45
|
+
builder.with_reflection(names)
|
|
46
|
+
if settings.enable_channelz:
|
|
47
|
+
builder.with_channelz()
|
|
48
|
+
return builder.build()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["AsyncGrpcServerProvider"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""gRPC Health Checking Protocol v1 implementation (async).
|
|
2
|
+
|
|
3
|
+
Gated behind the ``[health]`` extra (``grpcio-health-checking``). The Postgres and
|
|
4
|
+
Redis checkers additionally require the ``[postgres]`` / ``[redis]`` extras.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .cache import HealthCache
|
|
8
|
+
from .checkers import (
|
|
9
|
+
DatabaseHealthChecker,
|
|
10
|
+
FunctionalHealthChecker,
|
|
11
|
+
RedisClientProtocol,
|
|
12
|
+
RedisHealthChecker,
|
|
13
|
+
SessionMakerProtocol,
|
|
14
|
+
check_async_database_health,
|
|
15
|
+
check_async_redis_health,
|
|
16
|
+
handle_check_exceptions,
|
|
17
|
+
)
|
|
18
|
+
from .orchestrator import check_async_overall_health
|
|
19
|
+
from .protocols import AsyncHealthCacheProtocol, AsyncHealthChecker
|
|
20
|
+
from .servicer import AsyncDynamicHealthServicer
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AsyncDynamicHealthServicer",
|
|
24
|
+
"AsyncHealthCacheProtocol",
|
|
25
|
+
"AsyncHealthChecker",
|
|
26
|
+
"DatabaseHealthChecker",
|
|
27
|
+
"FunctionalHealthChecker",
|
|
28
|
+
"HealthCache",
|
|
29
|
+
"RedisClientProtocol",
|
|
30
|
+
"RedisHealthChecker",
|
|
31
|
+
"SessionMakerProtocol",
|
|
32
|
+
"check_async_database_health",
|
|
33
|
+
"check_async_overall_health",
|
|
34
|
+
"check_async_redis_health",
|
|
35
|
+
"handle_check_exceptions",
|
|
36
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Health status cache (async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from grpc_health.v1 import health_pb2
|
|
9
|
+
|
|
10
|
+
from grpc_server_kit.constants import DEFAULT_HEALTH_CACHE_TTL
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HealthCache:
|
|
17
|
+
"""TTL cache for health status with single-flight semantics.
|
|
18
|
+
|
|
19
|
+
:meth:`get_or_set` returns the cached status while it is fresh; on a cache
|
|
20
|
+
miss it runs ``check_func`` under a lock (double-checked locking), so only
|
|
21
|
+
one concurrent task performs the actual health check.
|
|
22
|
+
|
|
23
|
+
Note: This cache is designed for single event loop usage. Do not share
|
|
24
|
+
instances across different event loops.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, ttl: float = DEFAULT_HEALTH_CACHE_TTL) -> None:
|
|
28
|
+
"""Initialize health cache.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
ttl: Time-to-live for cached status in seconds. Must be positive.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If ttl is not positive.
|
|
35
|
+
"""
|
|
36
|
+
if ttl <= 0:
|
|
37
|
+
raise ValueError(f"TTL must be positive, got {ttl}")
|
|
38
|
+
self._ttl = ttl
|
|
39
|
+
self._status: health_pb2.HealthCheckResponse.ServingStatus | None = None
|
|
40
|
+
self._last_check: float = 0.0
|
|
41
|
+
self._lock: asyncio.Lock | None = None
|
|
42
|
+
|
|
43
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
44
|
+
# Created lazily so the cache can be constructed outside the event loop.
|
|
45
|
+
if self._lock is None:
|
|
46
|
+
self._lock = asyncio.Lock()
|
|
47
|
+
return self._lock
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _now(now: float | None) -> float:
|
|
51
|
+
return now if now is not None else asyncio.get_running_loop().time()
|
|
52
|
+
|
|
53
|
+
def _get_fresh(self, now: float) -> health_pb2.HealthCheckResponse.ServingStatus | None:
|
|
54
|
+
if self._status is None:
|
|
55
|
+
return None
|
|
56
|
+
if (now - self._last_check) < self._ttl:
|
|
57
|
+
return self._status
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
async def get_or_set(
|
|
61
|
+
self,
|
|
62
|
+
check_func: Callable[[], Awaitable[health_pb2.HealthCheckResponse.ServingStatus]],
|
|
63
|
+
now: float | None = None,
|
|
64
|
+
) -> health_pb2.HealthCheckResponse.ServingStatus:
|
|
65
|
+
"""Get the cached status or perform the check and cache the result.
|
|
66
|
+
|
|
67
|
+
The clock is re-read after waiting on the lock and again after the
|
|
68
|
+
check completes: waiters queued behind a slow check must validate
|
|
69
|
+
freshness against the check's COMPLETION time, otherwise a check
|
|
70
|
+
slower than the TTL produces entries that are expired at birth and the
|
|
71
|
+
cache degenerates into serialized re-checks.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
check_func: Coroutine function performing the actual health check.
|
|
75
|
+
now: Optional fixed time (event-loop clock) — intended for tests.
|
|
76
|
+
When omitted, the clock is re-read at each step.
|
|
77
|
+
"""
|
|
78
|
+
if (cached := self._get_fresh(self._now(now))) is not None:
|
|
79
|
+
return cached
|
|
80
|
+
|
|
81
|
+
async with self._get_lock():
|
|
82
|
+
if (cached := self._get_fresh(self._now(now))) is not None:
|
|
83
|
+
return cached
|
|
84
|
+
|
|
85
|
+
status = await check_func()
|
|
86
|
+
self._status = status
|
|
87
|
+
# Stamp AFTER the check so the entry's age starts at completion.
|
|
88
|
+
self._last_check = self._now(now)
|
|
89
|
+
return status
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Health checkers (async)."""
|
|
2
|
+
|
|
3
|
+
from .postgres import DatabaseHealthChecker, SessionMakerProtocol, check_async_database_health
|
|
4
|
+
from .redis import RedisClientProtocol, RedisHealthChecker, check_async_redis_health
|
|
5
|
+
from .utils import FunctionalHealthChecker, handle_check_exceptions
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DatabaseHealthChecker",
|
|
9
|
+
"FunctionalHealthChecker",
|
|
10
|
+
"RedisClientProtocol",
|
|
11
|
+
"RedisHealthChecker",
|
|
12
|
+
"SessionMakerProtocol",
|
|
13
|
+
"check_async_database_health",
|
|
14
|
+
"check_async_redis_health",
|
|
15
|
+
"handle_check_exceptions",
|
|
16
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Database health checker (async).
|
|
2
|
+
|
|
3
|
+
Depends only on raw SQLAlchemy (the ``[postgres]`` extra). Compatible with any
|
|
4
|
+
async session maker following the ``async_sessionmaker`` pattern.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol
|
|
13
|
+
|
|
14
|
+
from grpc_server_kit.constants import DEFAULT_CHECKER_TIMEOUT
|
|
15
|
+
|
|
16
|
+
from .utils import FunctionalHealthChecker, handle_check_exceptions
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from sqlalchemy import text
|
|
23
|
+
|
|
24
|
+
SQLALCHEMY_AVAILABLE = True
|
|
25
|
+
except ImportError: # pragma: no cover - exercised only when the [postgres] extra is absent
|
|
26
|
+
SQLALCHEMY_AVAILABLE = False
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
DEFAULT_DB_CHECK_TIMEOUT = DEFAULT_CHECKER_TIMEOUT
|
|
31
|
+
|
|
32
|
+
# Session cleanup (rollback/close) gets its own short deadline: a dead
|
|
33
|
+
# connection must not hold the health check past its budget, and a slow pool
|
|
34
|
+
# return must not convert a successful probe into a timeout.
|
|
35
|
+
_CLEANUP_TIMEOUT = 1.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SessionMakerProtocol(Protocol):
|
|
39
|
+
"""Protocol for SQLAlchemy async session makers.
|
|
40
|
+
|
|
41
|
+
Follows the standard async_sessionmaker pattern from SQLAlchemy. Compatible
|
|
42
|
+
with any callable that returns an async context manager providing an AsyncSession.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __call__(self) -> contextlib.AbstractAsyncContextManager[AsyncSession]:
|
|
46
|
+
"""Create a new async database session."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DatabaseHealthChecker(FunctionalHealthChecker):
|
|
51
|
+
"""Health checker for SQLAlchemy async databases.
|
|
52
|
+
|
|
53
|
+
Performs a connectivity check by executing a simple ``SELECT 1`` query with a
|
|
54
|
+
configurable timeout.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, session_maker: SessionMakerProtocol, timeout: float = DEFAULT_DB_CHECK_TIMEOUT) -> None:
|
|
58
|
+
"""Initialize database health checker.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
session_maker: SQLAlchemy async session maker.
|
|
62
|
+
timeout: Maximum time to wait for database response in seconds.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If timeout is not positive.
|
|
66
|
+
"""
|
|
67
|
+
super().__init__(check_async_database_health, session_maker, timeout)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@handle_check_exceptions("Database")
|
|
71
|
+
async def check_async_database_health(
|
|
72
|
+
session_maker: SessionMakerProtocol | None,
|
|
73
|
+
timeout: float = DEFAULT_DB_CHECK_TIMEOUT,
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""Check async database connectivity with timeout.
|
|
76
|
+
|
|
77
|
+
Executes a simple ``SELECT 1`` query to verify the database is reachable and
|
|
78
|
+
responsive. The timeout covers session acquisition and the query; session
|
|
79
|
+
cleanup runs under its own short deadline so a dead connection cannot hang
|
|
80
|
+
the check and a slow cleanup cannot fail a successful probe.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
session_maker: SQLAlchemy session maker to check. If None, returns True (no database configured).
|
|
84
|
+
timeout: Maximum time to wait for database response in seconds.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if database is healthy or session_maker is None, False otherwise.
|
|
88
|
+
|
|
89
|
+
Note:
|
|
90
|
+
If SQLAlchemy is not installed, this function logs a debug message and returns True,
|
|
91
|
+
allowing the service to run without database health checks.
|
|
92
|
+
"""
|
|
93
|
+
if session_maker is None:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
if not SQLALCHEMY_AVAILABLE:
|
|
97
|
+
logger.debug("SQLAlchemy not installed, skipping database health check")
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
session_cm = session_maker()
|
|
101
|
+
async with asyncio.timeout(timeout):
|
|
102
|
+
session = await session_cm.__aenter__()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
async with asyncio.timeout(timeout):
|
|
106
|
+
await session.execute(text("SELECT 1"))
|
|
107
|
+
return True
|
|
108
|
+
finally:
|
|
109
|
+
try:
|
|
110
|
+
async with asyncio.timeout(_CLEANUP_TIMEOUT):
|
|
111
|
+
await session_cm.__aexit__(None, None, None)
|
|
112
|
+
except asyncio.CancelledError:
|
|
113
|
+
raise
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.debug("Database health-check session cleanup failed", exc_info=True)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Redis health checker (async).
|
|
2
|
+
|
|
3
|
+
The checker is fully duck-typed: it only requires the passed client to expose an
|
|
4
|
+
async ``ping()`` (the redis-py async interface), so this module imports nothing
|
|
5
|
+
Redis-specific. Install the ``[redis]`` extra to get ``redis`` for your client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Awaitable
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
from grpc_server_kit.constants import DEFAULT_CHECKER_TIMEOUT
|
|
16
|
+
|
|
17
|
+
from .utils import FunctionalHealthChecker, handle_check_exceptions
|
|
18
|
+
|
|
19
|
+
DEFAULT_REDIS_CHECK_TIMEOUT = DEFAULT_CHECKER_TIMEOUT
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RedisClientProtocol(Protocol):
|
|
23
|
+
"""Protocol for a Redis client compatible with the redis-py async interface.
|
|
24
|
+
|
|
25
|
+
Supports both single instance and clustered Redis clients (any object exposing
|
|
26
|
+
an async ``ping``).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def ping(self, **kwargs: object) -> Awaitable[bool | dict[str, bool]] | bool | dict[str, bool]:
|
|
30
|
+
"""Ping the Redis server to check connectivity.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
For a single instance: True if ping was successful.
|
|
34
|
+
For clustered clients: a dict mapping node names to their ping results.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def aclose(self) -> None:
|
|
39
|
+
"""Close the Redis client."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RedisHealthChecker(FunctionalHealthChecker):
|
|
44
|
+
"""Health checker for Redis connectivity.
|
|
45
|
+
|
|
46
|
+
Performs a connectivity check by pinging Redis with a configurable timeout.
|
|
47
|
+
Compatible with both single instance and clustered Redis deployments.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, redis_client: RedisClientProtocol, timeout: float = DEFAULT_REDIS_CHECK_TIMEOUT) -> None:
|
|
51
|
+
"""Initialize Redis health checker.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
redis_client: Redis client instance implementing RedisClientProtocol.
|
|
55
|
+
timeout: Maximum time to wait for Redis response in seconds.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If timeout is not positive.
|
|
59
|
+
"""
|
|
60
|
+
super().__init__(check_async_redis_health, redis_client, timeout)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@handle_check_exceptions("Redis")
|
|
64
|
+
async def check_async_redis_health(
|
|
65
|
+
redis_client: RedisClientProtocol | None,
|
|
66
|
+
timeout: float = DEFAULT_REDIS_CHECK_TIMEOUT,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Check async Redis connectivity with a timeout via PING.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
redis_client: Redis client to check. If None, returns True (no Redis configured).
|
|
72
|
+
timeout: Maximum time to wait for Redis response in seconds.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if Redis is healthy or redis_client is None, False otherwise.
|
|
76
|
+
"""
|
|
77
|
+
if redis_client is None:
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
async with asyncio.timeout(timeout):
|
|
81
|
+
result = redis_client.ping()
|
|
82
|
+
if inspect.isawaitable(result):
|
|
83
|
+
result = await result
|
|
84
|
+
if isinstance(result, dict):
|
|
85
|
+
# Clustered clients return per-node results; every node must be up.
|
|
86
|
+
# An empty dict means no reachable nodes — unhealthy.
|
|
87
|
+
return bool(result) and all(result.values())
|
|
88
|
+
return bool(result)
|