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,97 @@
|
|
|
1
|
+
"""Internal utilities for health checkers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import ParamSpec
|
|
10
|
+
|
|
11
|
+
from grpc_server_kit.constants import DEFAULT_CHECKER_TIMEOUT
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
P = ParamSpec("P")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handle_check_exceptions(
|
|
19
|
+
check_name: str,
|
|
20
|
+
) -> Callable[[Callable[P, Awaitable[bool]]], Callable[P, Awaitable[bool]]]:
|
|
21
|
+
"""Decorator for consistent exception handling in health check functions.
|
|
22
|
+
|
|
23
|
+
This decorator provides uniform error handling for all health check implementations:
|
|
24
|
+
- Logs timeout errors with warning level
|
|
25
|
+
- Propagates CancelledError and KeyboardInterrupt for proper shutdown
|
|
26
|
+
- Catches and logs all other exceptions, returning False (unhealthy)
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
check_name: Human-readable name of the check for logging (e.g. "Database", "Redis").
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Decorator function that wraps health check implementations.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def decorator(func: Callable[P, Awaitable[bool]]) -> Callable[P, Awaitable[bool]]:
|
|
36
|
+
@wraps(func)
|
|
37
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
return await func(*args, **kwargs)
|
|
40
|
+
except TimeoutError:
|
|
41
|
+
timeout = kwargs.get("timeout", "unknown")
|
|
42
|
+
logger.warning(f"{check_name} health check timed out", extra={"check": check_name, "timeout": timeout})
|
|
43
|
+
return False
|
|
44
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
45
|
+
raise
|
|
46
|
+
except (ConnectionError, OSError) as e:
|
|
47
|
+
# Common networking errors for health checks (e.g. Redis down, Postgres unreachable)
|
|
48
|
+
logger.warning(
|
|
49
|
+
f"{check_name} health check failed (network/OS error)",
|
|
50
|
+
extra={"check": check_name, "error": str(e), "error_type": type(e).__name__},
|
|
51
|
+
)
|
|
52
|
+
return False
|
|
53
|
+
except Exception:
|
|
54
|
+
logger.exception(f"{check_name} health check failed with unexpected error", extra={"check": check_name})
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
return wrapper
|
|
58
|
+
|
|
59
|
+
return decorator
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FunctionalHealthChecker:
|
|
63
|
+
"""Generic health checker that wraps a health check function.
|
|
64
|
+
|
|
65
|
+
This class provides a common wrapper for any health check function that follows
|
|
66
|
+
the `check_func(resource, timeout=...)` pattern.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
check_func: Callable[..., Awaitable[bool]],
|
|
72
|
+
resource: object | None = None,
|
|
73
|
+
timeout: float = DEFAULT_CHECKER_TIMEOUT,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Initialize health checker.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
check_func: Asynchronous function that performs the health check.
|
|
79
|
+
resource: The resource (e.g. session maker, client) to check.
|
|
80
|
+
timeout: Maximum time to wait for the check to complete in seconds.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If timeout is not positive.
|
|
84
|
+
"""
|
|
85
|
+
if timeout <= 0:
|
|
86
|
+
raise ValueError(f"timeout must be positive, got {timeout}")
|
|
87
|
+
self._check_func = check_func
|
|
88
|
+
self._resource = resource
|
|
89
|
+
self._timeout = timeout
|
|
90
|
+
|
|
91
|
+
async def check(self) -> bool:
|
|
92
|
+
"""Execute the wrapped health check function.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if healthy, False otherwise.
|
|
96
|
+
"""
|
|
97
|
+
return await self._check_func(self._resource, timeout=self._timeout)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Overall health-check orchestration (async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from grpc_health.v1 import health_pb2
|
|
11
|
+
|
|
12
|
+
from grpc_server_kit.constants import DEFAULT_HEALTH_CHECK_TIMEOUT
|
|
13
|
+
|
|
14
|
+
from .protocols import AsyncHealthCacheProtocol, AsyncHealthChecker
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def check_async_overall_health(
|
|
23
|
+
checkers: list[AsyncHealthChecker] | None = None,
|
|
24
|
+
cache: AsyncHealthCacheProtocol | None = None,
|
|
25
|
+
timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT,
|
|
26
|
+
) -> health_pb2.HealthCheckResponse.ServingStatus:
|
|
27
|
+
"""Check overall service health including all dependencies (async)."""
|
|
28
|
+
if not checkers:
|
|
29
|
+
return health_pb2.HealthCheckResponse.SERVING
|
|
30
|
+
|
|
31
|
+
if cache is not None:
|
|
32
|
+
return await cache.get_or_set(
|
|
33
|
+
check_func=functools.partial(_perform_checks, checkers, timeout=timeout),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return await _perform_checks(checkers, timeout=timeout)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _perform_checks(
|
|
40
|
+
checkers: Sequence[AsyncHealthChecker], timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT
|
|
41
|
+
) -> health_pb2.HealthCheckResponse.ServingStatus:
|
|
42
|
+
"""Perform all health checks in parallel and aggregate results.
|
|
43
|
+
|
|
44
|
+
Uses asyncio.gather with return_exceptions to collect pass/fail per checker
|
|
45
|
+
without using exceptions for control flow.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
checkers: Sequence of health checkers to execute. Must not be empty.
|
|
49
|
+
timeout: Maximum seconds to wait for all health checks to complete.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
SERVING if all checks pass, NOT_SERVING if any check fails or unexpected error occurs.
|
|
53
|
+
"""
|
|
54
|
+
loop = asyncio.get_running_loop()
|
|
55
|
+
start_time = loop.time()
|
|
56
|
+
checker_count = len(checkers)
|
|
57
|
+
checker_names = [type(c).__name__ for c in checkers]
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
async with asyncio.timeout(timeout):
|
|
61
|
+
results = await asyncio.gather(
|
|
62
|
+
*(c.check() for c in checkers),
|
|
63
|
+
return_exceptions=True,
|
|
64
|
+
)
|
|
65
|
+
except (asyncio.CancelledError, KeyboardInterrupt):
|
|
66
|
+
raise
|
|
67
|
+
except TimeoutError:
|
|
68
|
+
elapsed = loop.time() - start_time
|
|
69
|
+
logger.warning(
|
|
70
|
+
"Health checks timed out",
|
|
71
|
+
extra={
|
|
72
|
+
"checker_count": checker_count,
|
|
73
|
+
"checkers": checker_names,
|
|
74
|
+
"timeout": timeout,
|
|
75
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
return health_pb2.HealthCheckResponse.NOT_SERVING
|
|
79
|
+
|
|
80
|
+
failed_checkers: list[str] = []
|
|
81
|
+
unexpected_errors: list[tuple[str, Exception]] = []
|
|
82
|
+
|
|
83
|
+
for checker, outcome in zip(checkers, results, strict=True):
|
|
84
|
+
name = type(checker).__name__
|
|
85
|
+
if isinstance(outcome, BaseException) and not isinstance(outcome, Exception):
|
|
86
|
+
raise outcome
|
|
87
|
+
if outcome is False:
|
|
88
|
+
failed_checkers.append(name)
|
|
89
|
+
elif isinstance(outcome, Exception):
|
|
90
|
+
unexpected_errors.append((name, outcome))
|
|
91
|
+
|
|
92
|
+
elapsed = loop.time() - start_time
|
|
93
|
+
extra = {
|
|
94
|
+
"checker_count": checker_count,
|
|
95
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if failed_checkers:
|
|
99
|
+
logger.warning(
|
|
100
|
+
"Health checks failed",
|
|
101
|
+
extra={**extra, "failed_count": len(failed_checkers), "failed_checkers": failed_checkers},
|
|
102
|
+
)
|
|
103
|
+
return health_pb2.HealthCheckResponse.NOT_SERVING
|
|
104
|
+
|
|
105
|
+
if unexpected_errors:
|
|
106
|
+
# Log each error WITH its own traceback: logger.exception outside an
|
|
107
|
+
# except block has no active exception and would log "NoneType: None".
|
|
108
|
+
for name, error in unexpected_errors:
|
|
109
|
+
logger.error(
|
|
110
|
+
"Health checker raised unexpected error",
|
|
111
|
+
extra={**extra, "checker": name, "error_type": type(error).__name__},
|
|
112
|
+
exc_info=error,
|
|
113
|
+
)
|
|
114
|
+
return health_pb2.HealthCheckResponse.NOT_SERVING
|
|
115
|
+
|
|
116
|
+
# DEBUG, not INFO: this fires for every Check probe / Watch iteration and
|
|
117
|
+
# carries no information when everything is healthy.
|
|
118
|
+
logger.debug(
|
|
119
|
+
"Health checks passed",
|
|
120
|
+
extra={**extra, "checkers": checker_names},
|
|
121
|
+
)
|
|
122
|
+
return health_pb2.HealthCheckResponse.SERVING
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Protocols for health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from grpc_health.v1 import health_pb2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncHealthChecker(Protocol):
|
|
13
|
+
"""Protocol for component health checks (DB, Redis, external service, ...).
|
|
14
|
+
|
|
15
|
+
One shape (``async check() -> bool``) serves the kit's gRPC health servicer
|
|
16
|
+
and any external health registry alike.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
async def check(self) -> bool:
|
|
20
|
+
"""Check component health. Returns True if healthy."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncHealthCacheProtocol(Protocol):
|
|
25
|
+
"""Protocol for a health status cache with TTL support.
|
|
26
|
+
|
|
27
|
+
Implementations must be safe for concurrent coroutine access and must
|
|
28
|
+
ensure that only one concurrent task performs the underlying check on a
|
|
29
|
+
cache miss (double-checked locking or equivalent).
|
|
30
|
+
|
|
31
|
+
Note: Cache instances should not be shared across different event loops.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def get_or_set(
|
|
35
|
+
self,
|
|
36
|
+
check_func: Callable[[], Awaitable[health_pb2.HealthCheckResponse.ServingStatus]],
|
|
37
|
+
now: float | None = None,
|
|
38
|
+
) -> health_pb2.HealthCheckResponse.ServingStatus:
|
|
39
|
+
"""Return the cached health status or perform the check and cache the result."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["AsyncHealthCacheProtocol", "AsyncHealthChecker"]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Dynamic health check servicer with real-time dependency checks (async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
from grpc_health.v1 import health_pb2, health_pb2_grpc
|
|
11
|
+
|
|
12
|
+
from grpc_server_kit.constants import (
|
|
13
|
+
DEFAULT_HEALTH_CHECK_INTERVAL,
|
|
14
|
+
DEFAULT_HEALTH_CHECK_TIMEOUT,
|
|
15
|
+
DEFAULT_HEALTH_HEARTBEAT_INTERVAL,
|
|
16
|
+
)
|
|
17
|
+
from grpc_server_kit.server import is_valid_service_name
|
|
18
|
+
|
|
19
|
+
from .orchestrator import check_async_overall_health
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import AsyncGenerator
|
|
23
|
+
|
|
24
|
+
from .protocols import AsyncHealthCacheProtocol, AsyncHealthChecker
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncDynamicHealthServicer(health_pb2_grpc.HealthServicer):
|
|
30
|
+
"""Async health servicer with dynamic dependency checks.
|
|
31
|
+
|
|
32
|
+
Implements the standard gRPC Health Checking Protocol v1 with real-time
|
|
33
|
+
dependency verification. Supports both Check (unary) and Watch (streaming) modes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
checkers: list[AsyncHealthChecker] | None = None,
|
|
39
|
+
cache: AsyncHealthCacheProtocol | None = None,
|
|
40
|
+
service_names: list[str] | None = None,
|
|
41
|
+
check_interval: float = DEFAULT_HEALTH_CHECK_INTERVAL,
|
|
42
|
+
heartbeat_interval: float = DEFAULT_HEALTH_HEARTBEAT_INTERVAL,
|
|
43
|
+
check_timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize health servicer.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
checkers: Optional list of asynchronous health checkers. If None or empty,
|
|
49
|
+
the service always returns SERVING (no dependencies to check).
|
|
50
|
+
cache: Optional health status cache following AsyncHealthCacheProtocol.
|
|
51
|
+
Strongly recommended with multiple Watch clients: watchers share
|
|
52
|
+
one real dependency check per TTL instead of each running their own.
|
|
53
|
+
service_names: Optional list of additional service names to support.
|
|
54
|
+
The empty string "" is always supported and represents overall health.
|
|
55
|
+
check_interval: Frequency of dependency checks in Watch mode (seconds). Must be positive.
|
|
56
|
+
heartbeat_interval: Interval to send status even if unchanged in Watch (seconds).
|
|
57
|
+
Must be >= check_interval.
|
|
58
|
+
check_timeout: Maximum seconds to wait for each health check run. Must be positive.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If check_interval, heartbeat_interval or check_timeout are invalid.
|
|
62
|
+
"""
|
|
63
|
+
if check_interval <= 0:
|
|
64
|
+
raise ValueError(f"check_interval must be positive, got {check_interval}")
|
|
65
|
+
if heartbeat_interval < check_interval:
|
|
66
|
+
raise ValueError(f"heartbeat_interval ({heartbeat_interval}) must be >= check_interval ({check_interval})")
|
|
67
|
+
if check_timeout <= 0:
|
|
68
|
+
raise ValueError(f"check_timeout must be positive, got {check_timeout}")
|
|
69
|
+
|
|
70
|
+
self._checkers = checkers or []
|
|
71
|
+
self._cache = cache
|
|
72
|
+
self._service_names = set(service_names or [])
|
|
73
|
+
self._service_names.add("") # Always support overall health check
|
|
74
|
+
self._check_interval = check_interval
|
|
75
|
+
self._heartbeat_interval = heartbeat_interval
|
|
76
|
+
self._check_timeout = check_timeout
|
|
77
|
+
|
|
78
|
+
def _validate_service_name(self, service: str) -> bool:
|
|
79
|
+
"""Validate gRPC service name ("" is the overall-health wildcard)."""
|
|
80
|
+
if not service:
|
|
81
|
+
return True
|
|
82
|
+
return is_valid_service_name(service)
|
|
83
|
+
|
|
84
|
+
def _is_service_known(self, service: str) -> bool:
|
|
85
|
+
"""Check if the requested service name is registered."""
|
|
86
|
+
return service in self._service_names
|
|
87
|
+
|
|
88
|
+
async def _perform_health_check(self, service: str = "") -> health_pb2.HealthCheckResponse.ServingStatus:
|
|
89
|
+
"""Perform health check with error handling.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
SERVING if all checks pass, NOT_SERVING on any failure or exception.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
return await check_async_overall_health(
|
|
96
|
+
cache=self._cache,
|
|
97
|
+
checkers=self._checkers,
|
|
98
|
+
timeout=self._check_timeout,
|
|
99
|
+
)
|
|
100
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
101
|
+
raise
|
|
102
|
+
except (RuntimeError, ValueError, TimeoutError) as e:
|
|
103
|
+
# Expected/recoverable errors during health check execution
|
|
104
|
+
logger.warning("Health check execution failed (recoverable)", extra={"service": service, "error": str(e)})
|
|
105
|
+
return health_pb2.HealthCheckResponse.NOT_SERVING
|
|
106
|
+
except Exception:
|
|
107
|
+
logger.exception("Health check failed due to unexpected error", extra={"service": service})
|
|
108
|
+
return health_pb2.HealthCheckResponse.NOT_SERVING
|
|
109
|
+
|
|
110
|
+
def _should_send_update(
|
|
111
|
+
self,
|
|
112
|
+
status: health_pb2.HealthCheckResponse.ServingStatus,
|
|
113
|
+
last_status: health_pb2.HealthCheckResponse.ServingStatus | None,
|
|
114
|
+
last_heartbeat: float,
|
|
115
|
+
now: float,
|
|
116
|
+
) -> bool:
|
|
117
|
+
"""Determine if a status update should be sent in Watch mode.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if update should be sent (status changed or heartbeat interval reached).
|
|
121
|
+
"""
|
|
122
|
+
is_changed = status != last_status
|
|
123
|
+
is_heartbeat = (now - last_heartbeat) >= self._heartbeat_interval
|
|
124
|
+
return is_changed or is_heartbeat
|
|
125
|
+
|
|
126
|
+
async def Check(
|
|
127
|
+
self,
|
|
128
|
+
request: health_pb2.HealthCheckRequest,
|
|
129
|
+
_context: grpc.aio.ServicerContext[health_pb2.HealthCheckRequest, health_pb2.HealthCheckResponse],
|
|
130
|
+
) -> health_pb2.HealthCheckResponse:
|
|
131
|
+
"""Check service health with real-time dependency checks (unary).
|
|
132
|
+
|
|
133
|
+
Implements the Check RPC from gRPC Health Checking Protocol v1.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Health check response with status (SERVING, NOT_SERVING, or SERVICE_UNKNOWN).
|
|
137
|
+
"""
|
|
138
|
+
if not self._validate_service_name(request.service):
|
|
139
|
+
logger.warning("Health check with invalid service name", extra={"service": request.service})
|
|
140
|
+
return health_pb2.HealthCheckResponse(status=health_pb2.HealthCheckResponse.SERVICE_UNKNOWN)
|
|
141
|
+
|
|
142
|
+
if not self._is_service_known(request.service):
|
|
143
|
+
logger.debug("Health check for unknown service", extra={"service": request.service})
|
|
144
|
+
return health_pb2.HealthCheckResponse(status=health_pb2.HealthCheckResponse.SERVICE_UNKNOWN)
|
|
145
|
+
|
|
146
|
+
status = await self._perform_health_check(service=request.service)
|
|
147
|
+
logger.debug(
|
|
148
|
+
"Health check completed",
|
|
149
|
+
extra={
|
|
150
|
+
"service": request.service,
|
|
151
|
+
"status": health_pb2.HealthCheckResponse.ServingStatus.Name(status),
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
return health_pb2.HealthCheckResponse(status=status)
|
|
155
|
+
|
|
156
|
+
async def Watch(
|
|
157
|
+
self,
|
|
158
|
+
request: health_pb2.HealthCheckRequest,
|
|
159
|
+
context: grpc.aio.ServicerContext[health_pb2.HealthCheckRequest, health_pb2.HealthCheckResponse],
|
|
160
|
+
) -> AsyncGenerator[health_pb2.HealthCheckResponse, None]:
|
|
161
|
+
"""Watch service health status with streaming updates.
|
|
162
|
+
|
|
163
|
+
Implements the Watch RPC from gRPC Health Checking Protocol v1.
|
|
164
|
+
Sends updates when status changes or on heartbeat interval. Per the
|
|
165
|
+
protocol a Watch stream never completes normally: internal failures
|
|
166
|
+
abort the stream with INTERNAL so clients can distinguish a broken
|
|
167
|
+
watch from a deliberate server shutdown.
|
|
168
|
+
|
|
169
|
+
Yields:
|
|
170
|
+
Streaming health check responses as status changes.
|
|
171
|
+
"""
|
|
172
|
+
if not self._validate_service_name(request.service):
|
|
173
|
+
logger.warning("Health watch with invalid service name", extra={"service": request.service})
|
|
174
|
+
yield health_pb2.HealthCheckResponse(status=health_pb2.HealthCheckResponse.SERVICE_UNKNOWN)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if not self._is_service_known(request.service):
|
|
178
|
+
logger.debug("Health watch for unknown service", extra={"service": request.service})
|
|
179
|
+
yield health_pb2.HealthCheckResponse(status=health_pb2.HealthCheckResponse.SERVICE_UNKNOWN)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
logger.info(
|
|
183
|
+
"Health watch started",
|
|
184
|
+
extra={
|
|
185
|
+
"service": request.service,
|
|
186
|
+
"check_interval": self._check_interval,
|
|
187
|
+
"heartbeat_interval": self._heartbeat_interval,
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
last_status: health_pb2.HealthCheckResponse.ServingStatus | None = None
|
|
192
|
+
last_heartbeat = 0.0
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
while not context.done():
|
|
196
|
+
status = await self._perform_health_check(service=request.service)
|
|
197
|
+
now = asyncio.get_running_loop().time()
|
|
198
|
+
|
|
199
|
+
if self._should_send_update(status, last_status, last_heartbeat, now):
|
|
200
|
+
last_status = status
|
|
201
|
+
last_heartbeat = now
|
|
202
|
+
yield health_pb2.HealthCheckResponse(status=status)
|
|
203
|
+
|
|
204
|
+
if context.done():
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
await asyncio.sleep(self._check_interval)
|
|
208
|
+
|
|
209
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
210
|
+
logger.info("Health watch cancelled", extra={"service": request.service})
|
|
211
|
+
raise
|
|
212
|
+
except grpc.aio.AbortError:
|
|
213
|
+
raise
|
|
214
|
+
except (RuntimeError, ValueError, TimeoutError) as e:
|
|
215
|
+
logger.warning("Health watch failed (recoverable)", extra={"service": request.service, "error": str(e)})
|
|
216
|
+
await self._abort_watch(context)
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.exception("Health watch failed due to unexpected error", extra={"service": request.service})
|
|
219
|
+
await self._abort_watch(context)
|
|
220
|
+
finally:
|
|
221
|
+
logger.info("Health watch stopped", extra={"service": request.service})
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
async def _abort_watch(
|
|
225
|
+
context: grpc.aio.ServicerContext[health_pb2.HealthCheckRequest, health_pb2.HealthCheckResponse],
|
|
226
|
+
) -> NoReturn:
|
|
227
|
+
"""Terminate a broken Watch stream with an error status (never OK EOF)."""
|
|
228
|
+
await context.abort(grpc.StatusCode.INTERNAL, "Health watch failed")
|
|
229
|
+
raise RuntimeError("ServicerContext.abort() returned instead of raising")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Async gRPC server interceptors.
|
|
2
|
+
|
|
3
|
+
All interceptors are streaming-aware: they wrap the whole RPC (including full
|
|
4
|
+
response streams), so timings, error mapping, and cleanup cover the entire
|
|
5
|
+
call. Build custom interceptors by subclassing
|
|
6
|
+
:class:`~grpc_server_kit.aio.interceptors.base.AsyncServerInterceptor`.
|
|
7
|
+
|
|
8
|
+
Canonical execution order (outermost first):
|
|
9
|
+
1. ``AsyncMetricsInterceptor`` (metrics — measures everything below)
|
|
10
|
+
2. ``AsyncContextInterceptor`` (extract metadata into context vars)
|
|
11
|
+
3. ``AsyncRequestLoggerInterceptor`` (request/response logging)
|
|
12
|
+
4. ``AsyncTracingInterceptor`` (tracing span)
|
|
13
|
+
5. ``AsyncSentryInterceptor`` (error tracking)
|
|
14
|
+
6. ``AsyncExceptionHandlerInterceptor`` (exception → gRPC status mapping)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .base import AsyncServerInterceptor, RpcCall, split_method_name
|
|
18
|
+
from .context import AsyncContextInterceptor, HeaderConfig
|
|
19
|
+
from .exception_handler import (
|
|
20
|
+
GRPC_DEFAULT_ERROR_STATUS_MAP,
|
|
21
|
+
GRPC_SAFE_ERROR_MESSAGES,
|
|
22
|
+
AsyncExceptionHandlerInterceptor,
|
|
23
|
+
ErrorDetailFactory,
|
|
24
|
+
default_error_detail,
|
|
25
|
+
find_mapped_status,
|
|
26
|
+
)
|
|
27
|
+
from .metadata import get_metadata_dict
|
|
28
|
+
from .metrics import AsyncMetricsInterceptor
|
|
29
|
+
from .protocols import (
|
|
30
|
+
ErrorReporterProtocol,
|
|
31
|
+
GrpcServerMetricsProtocol,
|
|
32
|
+
SpanAttributeValue,
|
|
33
|
+
SpanProtocol,
|
|
34
|
+
TracerProtocol,
|
|
35
|
+
)
|
|
36
|
+
from .request_logger import AsyncRequestLoggerInterceptor
|
|
37
|
+
from .sentry import AsyncSentryInterceptor
|
|
38
|
+
from .tracing import AsyncTracingInterceptor
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"GRPC_DEFAULT_ERROR_STATUS_MAP",
|
|
42
|
+
"GRPC_SAFE_ERROR_MESSAGES",
|
|
43
|
+
"AsyncContextInterceptor",
|
|
44
|
+
"AsyncExceptionHandlerInterceptor",
|
|
45
|
+
"AsyncMetricsInterceptor",
|
|
46
|
+
"AsyncRequestLoggerInterceptor",
|
|
47
|
+
"AsyncSentryInterceptor",
|
|
48
|
+
"AsyncServerInterceptor",
|
|
49
|
+
"AsyncTracingInterceptor",
|
|
50
|
+
"ErrorDetailFactory",
|
|
51
|
+
"ErrorReporterProtocol",
|
|
52
|
+
"GrpcServerMetricsProtocol",
|
|
53
|
+
"HeaderConfig",
|
|
54
|
+
"RpcCall",
|
|
55
|
+
"SpanAttributeValue",
|
|
56
|
+
"SpanProtocol",
|
|
57
|
+
"TracerProtocol",
|
|
58
|
+
"default_error_detail",
|
|
59
|
+
"find_mapped_status",
|
|
60
|
+
"get_metadata_dict",
|
|
61
|
+
"split_method_name",
|
|
62
|
+
]
|