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