grpc-client-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_client_kit/__init__.py +166 -0
- grpc_client_kit/__version__.py +1 -0
- grpc_client_kit/balancers.py +362 -0
- grpc_client_kit/channel.py +511 -0
- grpc_client_kit/client.py +236 -0
- grpc_client_kit/config.py +185 -0
- grpc_client_kit/deadline.py +122 -0
- grpc_client_kit/errors.py +51 -0
- grpc_client_kit/factory.py +482 -0
- grpc_client_kit/health.py +408 -0
- grpc_client_kit/interceptors/__init__.py +523 -0
- grpc_client_kit/interceptors/base.py +476 -0
- grpc_client_kit/interceptors/circuit_breaker.py +408 -0
- grpc_client_kit/interceptors/client_logging.py +329 -0
- grpc_client_kit/interceptors/context.py +135 -0
- grpc_client_kit/interceptors/deadline.py +154 -0
- grpc_client_kit/interceptors/metrics.py +219 -0
- grpc_client_kit/interceptors/outlier.py +78 -0
- grpc_client_kit/interceptors/retry.py +501 -0
- grpc_client_kit/interceptors/timeout.py +141 -0
- grpc_client_kit/interceptors/tracing.py +307 -0
- grpc_client_kit/interceptors/wait_for_ready.py +139 -0
- grpc_client_kit/protocols.py +449 -0
- grpc_client_kit/py.typed +0 -0
- grpc_client_kit/utils.py +99 -0
- grpc_client_kit/validation.py +232 -0
- grpc_client_kit-0.1.0.dist-info/METADATA +560 -0
- grpc_client_kit-0.1.0.dist-info/RECORD +30 -0
- grpc_client_kit-0.1.0.dist-info/WHEEL +4 -0
- grpc_client_kit-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
import grpc.aio
|
|
10
|
+
|
|
11
|
+
from .balancers import LoadBalancer
|
|
12
|
+
from .channel import ChannelKey, ChannelPool
|
|
13
|
+
from .config import GrpcClientConfig
|
|
14
|
+
from .protocols import ChannelProviderProtocol
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GrpcClient[T]:
|
|
20
|
+
"""Factory for gRPC stubs. Manages target selection and channel acquisition from pool.
|
|
21
|
+
|
|
22
|
+
Interceptor Chains:
|
|
23
|
+
A chain is built per target and cached, because gRPC binds interceptors to a channel when
|
|
24
|
+
the channel is created. Stateful interceptors — the circuit breaker above all — therefore
|
|
25
|
+
track one backend each: a single failing member of a load-balanced set can no longer trip
|
|
26
|
+
the breaker for its healthy peers. Pass ``interceptor_factory`` to get that isolation;
|
|
27
|
+
passing a ready ``interceptors`` list instead shares one chain (and one breaker) across
|
|
28
|
+
every target of this client.
|
|
29
|
+
|
|
30
|
+
Ownership Semantics:
|
|
31
|
+
This client DOES NOT own the `ChannelPool` it uses. It is a lightweight wrapper that
|
|
32
|
+
acquires channels from a shared pool. Closing the client (via __aexit__) does NOT
|
|
33
|
+
close the underlying channels or the pool.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
stub_class: type[T],
|
|
39
|
+
config: GrpcClientConfig,
|
|
40
|
+
pool: ChannelProviderProtocol,
|
|
41
|
+
balancer: LoadBalancer | None = None,
|
|
42
|
+
interceptors: list[grpc.aio.ClientInterceptor] | None = None,
|
|
43
|
+
interceptor_factory: Callable[[str], list[grpc.aio.ClientInterceptor]] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize the gRPC client.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
stub_class: The gRPC stub class to instantiate (e.g., MyServiceStub).
|
|
49
|
+
config: Configuration for the client (target, security, etc.).
|
|
50
|
+
pool: The channel pool to acquire channels from.
|
|
51
|
+
balancer: Optional load balancer for multiple targets.
|
|
52
|
+
interceptors: Optional ready chain, shared by every target of this client.
|
|
53
|
+
interceptor_factory: Optional builder called once per target to create a dedicated
|
|
54
|
+
chain for it.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If target configuration is ambiguous or missing, or if both an interceptor
|
|
58
|
+
chain and an interceptor factory are given.
|
|
59
|
+
"""
|
|
60
|
+
self._stub_class = stub_class
|
|
61
|
+
self._config = config
|
|
62
|
+
self._pool = pool
|
|
63
|
+
self._balancer = balancer
|
|
64
|
+
|
|
65
|
+
if interceptors is not None and interceptor_factory is not None:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Ambiguous interceptors: provide either 'interceptors' (one shared chain) "
|
|
68
|
+
"or 'interceptor_factory' (a chain per target), not both."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self._static_interceptors = list(interceptors) if interceptors is not None else None
|
|
72
|
+
self._interceptor_factory = interceptor_factory
|
|
73
|
+
self._chains: dict[str, list[grpc.aio.ClientInterceptor]] = {}
|
|
74
|
+
self._warned_native_retry = False
|
|
75
|
+
# Target, config and chain are fixed per target, so the pool identity is too: computing it
|
|
76
|
+
# (validation, option merging, chain tokens) once per target instead of once per call keeps
|
|
77
|
+
# that work off the hot path.
|
|
78
|
+
self._channel_keys: dict[str, ChannelKey] = {}
|
|
79
|
+
|
|
80
|
+
self._validate_target_configuration()
|
|
81
|
+
|
|
82
|
+
def _validate_target_configuration(self) -> None:
|
|
83
|
+
"""Ensure that either a single target or a balancer is provided, but not both."""
|
|
84
|
+
if self._balancer and self._config.target:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"Ambiguous target: both 'balancer' and 'config.target' provided. "
|
|
87
|
+
"Use 'balancer' for multiple targets or 'config.target' for a single target."
|
|
88
|
+
)
|
|
89
|
+
if not self._balancer and not self._config.target:
|
|
90
|
+
raise ValueError("No target specified: provide either 'balancer' or 'config.target'")
|
|
91
|
+
|
|
92
|
+
def interceptors_for(self, target: str) -> list[grpc.aio.ClientInterceptor]:
|
|
93
|
+
"""Return this client's interceptor chain for one target.
|
|
94
|
+
|
|
95
|
+
The chain is built at most once per target and then reused. That caching is what keeps the
|
|
96
|
+
pool key stable: rebuilding the chain on every call would mint a new channel identity each
|
|
97
|
+
time and the pool would open a channel per RPC.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
target: The target address (host:port) the chain will be bound to.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The interceptor chain for the target, empty when the client has no interceptors.
|
|
104
|
+
"""
|
|
105
|
+
chain = self._chains.get(target)
|
|
106
|
+
if chain is None:
|
|
107
|
+
if self._static_interceptors is not None:
|
|
108
|
+
chain = self._static_interceptors
|
|
109
|
+
elif self._interceptor_factory is not None:
|
|
110
|
+
chain = self._interceptor_factory(target)
|
|
111
|
+
else:
|
|
112
|
+
chain = []
|
|
113
|
+
self._chains[target] = chain
|
|
114
|
+
self._warn_on_native_retry_overlap(chain)
|
|
115
|
+
return chain
|
|
116
|
+
|
|
117
|
+
def _warn_on_native_retry_overlap(self, chain: list[grpc.aio.ClientInterceptor]) -> None:
|
|
118
|
+
"""Warn once when kit retries are stacked on top of native service-config retries.
|
|
119
|
+
|
|
120
|
+
Native ``retryPolicy`` runs inside the channel, below every interceptor, so the two layers
|
|
121
|
+
multiply: kit attempts times native attempts reach the server — a retry storm the kit's own
|
|
122
|
+
logs and metrics cannot see. The channel options pass through untouched on purpose (native
|
|
123
|
+
retries *without* kit retries are a fully supported configuration); only the combination is
|
|
124
|
+
worth a warning.
|
|
125
|
+
"""
|
|
126
|
+
if self._warned_native_retry:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
options = self._config.channel_options() or []
|
|
130
|
+
service_config = next((value for name, value in options if name == "grpc.service_config"), None)
|
|
131
|
+
if service_config is None or "retryPolicy" not in str(service_config):
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
from .interceptors.base import logical_interceptor # noqa: PLC0415 - avoids import cycle at module load
|
|
135
|
+
from .interceptors.retry import AsyncRetryInterceptor # noqa: PLC0415
|
|
136
|
+
|
|
137
|
+
if any(isinstance(logical_interceptor(entry), AsyncRetryInterceptor) for entry in chain):
|
|
138
|
+
self._warned_native_retry = True
|
|
139
|
+
logger.warning(
|
|
140
|
+
"Both kit retries and a native retryPolicy are configured for %s: the two layers "
|
|
141
|
+
"multiply (kit attempts x native attempts reach the server). Configure one source "
|
|
142
|
+
"of retries — drop the kit RetryConfig, or remove retryPolicy from grpc.service_config.",
|
|
143
|
+
self._config.target or "the balanced targets",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def circuit_breaker_states(self) -> dict[str, dict[str, Any]]:
|
|
147
|
+
"""Snapshot the circuit breakers of every chain this client has built.
|
|
148
|
+
|
|
149
|
+
During an incident this is the question an operator asks first — "is the breaker open, or
|
|
150
|
+
is the backend down?" — and it must be answerable without spelunking through the chain.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Mapping of target to that target's breaker snapshot (method to status). Targets whose
|
|
154
|
+
chain has no breaker, or no chain built yet, are absent.
|
|
155
|
+
"""
|
|
156
|
+
from .interceptors.base import logical_interceptor # noqa: PLC0415 - avoids import cycle at module load
|
|
157
|
+
from .interceptors.circuit_breaker import AsyncCircuitBreakerInterceptor # noqa: PLC0415
|
|
158
|
+
|
|
159
|
+
snapshot: dict[str, dict[str, Any]] = {}
|
|
160
|
+
for target, chain in self._chains.items():
|
|
161
|
+
for entry in chain:
|
|
162
|
+
owner = logical_interceptor(entry)
|
|
163
|
+
if isinstance(owner, AsyncCircuitBreakerInterceptor):
|
|
164
|
+
snapshot[target] = dict(await owner.get_states())
|
|
165
|
+
break
|
|
166
|
+
return snapshot
|
|
167
|
+
|
|
168
|
+
async def connect(self) -> T:
|
|
169
|
+
"""Acquires a channel from the pool and returns a new stub instance.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
An instance of the stub_class configured with a pooled channel.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
NoHealthyTargetsError: If balancer is used and no healthy targets are available.
|
|
176
|
+
ValueError: If target is not specified.
|
|
177
|
+
"""
|
|
178
|
+
target: str | None
|
|
179
|
+
if self._balancer:
|
|
180
|
+
target = await self._balancer.select_target()
|
|
181
|
+
else:
|
|
182
|
+
target = self._config.target
|
|
183
|
+
|
|
184
|
+
if target is None:
|
|
185
|
+
# This should be caught by _validate_target_configuration but adding check for mypy
|
|
186
|
+
raise ValueError("Target is not specified")
|
|
187
|
+
|
|
188
|
+
interceptors = self.interceptors_for(target)
|
|
189
|
+
if isinstance(self._pool, ChannelPool):
|
|
190
|
+
key = self._channel_keys.get(target)
|
|
191
|
+
if key is None:
|
|
192
|
+
key = self._pool.make_key(
|
|
193
|
+
target,
|
|
194
|
+
insecure=self._config.insecure,
|
|
195
|
+
credentials=self._config.credentials,
|
|
196
|
+
# Explicit options plus whatever the connectivity tuning adds; built the same
|
|
197
|
+
# way every time, so the pool keeps recognising the channel it already has.
|
|
198
|
+
options=self._config.channel_options(),
|
|
199
|
+
compression=self._config.compression,
|
|
200
|
+
interceptors=interceptors,
|
|
201
|
+
)
|
|
202
|
+
self._channel_keys[target] = key
|
|
203
|
+
channel = await self._pool.get_channel(target, interceptors=interceptors, key=key)
|
|
204
|
+
else:
|
|
205
|
+
# A custom provider speaks only the protocol surface, which has no key parameter.
|
|
206
|
+
channel = await self._pool.get_channel(
|
|
207
|
+
target,
|
|
208
|
+
insecure=self._config.insecure,
|
|
209
|
+
credentials=self._config.credentials,
|
|
210
|
+
options=self._config.channel_options(),
|
|
211
|
+
compression=self._config.compression,
|
|
212
|
+
interceptors=interceptors,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Mypy might complain about calling a generic type as a class
|
|
216
|
+
return self._stub_class(channel) # type: ignore[call-arg]
|
|
217
|
+
|
|
218
|
+
async def __aenter__(self) -> T:
|
|
219
|
+
"""Async context manager entry. Calls connect()."""
|
|
220
|
+
return await self.connect()
|
|
221
|
+
|
|
222
|
+
async def __aexit__(
|
|
223
|
+
self,
|
|
224
|
+
exc_type: type[BaseException] | None,
|
|
225
|
+
exc_val: BaseException | None,
|
|
226
|
+
exc_tb: Any | None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Context manager exit.
|
|
229
|
+
|
|
230
|
+
NOTE: This does NOT close the underlying channel, as channels are managed by the ChannelPool.
|
|
231
|
+
The pool owns channel lifetimes; idle connections are parked by gRPC core, not closed.
|
|
232
|
+
"""
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = ["GrpcClient"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Client configuration: what a channel is opened with, and how it is kept alive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
import grpc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _milliseconds(seconds: float) -> int:
|
|
14
|
+
"""Convert a duration in seconds into the milliseconds gRPC channel arguments are given in."""
|
|
15
|
+
return round(seconds * 1000)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ConnectivityConfig:
|
|
20
|
+
"""How a channel keeps its connection alive, and how fast it comes back after losing it.
|
|
21
|
+
|
|
22
|
+
These are gRPC channel arguments, which means two things. They are set once, when the channel is
|
|
23
|
+
created, and cannot be changed afterwards; and they are spelled as an untyped list of
|
|
24
|
+
``("grpc.some_thing_ms", 30000)`` pairs that every caller currently has to remember, in the
|
|
25
|
+
right unit, by heart. This is that list, named, in seconds, and validated.
|
|
26
|
+
|
|
27
|
+
Keepalive pings detect a connection that has silently gone away — a dropped NAT mapping, a load
|
|
28
|
+
balancer that closed one side, a peer that vanished — which TCP alone can leave undetected until
|
|
29
|
+
a call has already hung on it. The reconnect backoff decides how long a channel that has lost
|
|
30
|
+
its connection waits before dialing again, and gRPC's own upper bound for that (two minutes) is
|
|
31
|
+
a long time for a backend that restarts in seconds.
|
|
32
|
+
|
|
33
|
+
Opt-in, deliberately. Pinging is a conversation the server has to agree to: it enforces its own
|
|
34
|
+
minimum interval and answers a client that pings too often with ``GOAWAY`` and
|
|
35
|
+
``ENHANCE_YOUR_CALM``, so a client library that switched this on for everybody would be a way to
|
|
36
|
+
get connections dropped by servers that were never configured for it. The defaults here are what
|
|
37
|
+
a caller gets once they have decided to tune the channel at all: conservative enough for a
|
|
38
|
+
server left at its own defaults, which permits pings on a connection with calls in flight.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
keepalive_time: Seconds of inactivity before a keepalive ping is sent. ``None`` leaves
|
|
42
|
+
gRPC's default, which is not to ping at all.
|
|
43
|
+
keepalive_timeout: Seconds to wait for the ping to be answered before the connection is
|
|
44
|
+
considered dead.
|
|
45
|
+
permit_without_calls: Whether to keep pinging while no call is in flight. Off by default:
|
|
46
|
+
this is the case servers police most tightly, and an idle connection that dies is
|
|
47
|
+
usually cheaper to re-establish than to keep watching.
|
|
48
|
+
max_pings_without_data: How many pings may be sent on a connection carrying no data before
|
|
49
|
+
the client stops; 0 means no limit.
|
|
50
|
+
initial_reconnect_backoff: Seconds to wait before the first reconnect attempt.
|
|
51
|
+
min_reconnect_backoff: Lower bound for the wait between reconnect attempts. ``None`` leaves
|
|
52
|
+
gRPC's default, which also bounds how long a single connect attempt may take.
|
|
53
|
+
max_reconnect_backoff: Upper bound for the wait between reconnect attempts, which is what
|
|
54
|
+
decides how long a client keeps failing after a backend is already back.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
keepalive_time: float | None = 30.0
|
|
58
|
+
keepalive_timeout: float | None = 10.0
|
|
59
|
+
permit_without_calls: bool = False
|
|
60
|
+
max_pings_without_data: int | None = 2
|
|
61
|
+
initial_reconnect_backoff: float | None = 1.0
|
|
62
|
+
min_reconnect_backoff: float | None = None
|
|
63
|
+
max_reconnect_backoff: float | None = 30.0
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
"""Validate the configuration.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If a duration is not positive, if the ping allowance is negative, or if the
|
|
70
|
+
reconnect bounds contradict each other.
|
|
71
|
+
"""
|
|
72
|
+
durations = {
|
|
73
|
+
"keepalive_time": self.keepalive_time,
|
|
74
|
+
"keepalive_timeout": self.keepalive_timeout,
|
|
75
|
+
"initial_reconnect_backoff": self.initial_reconnect_backoff,
|
|
76
|
+
"min_reconnect_backoff": self.min_reconnect_backoff,
|
|
77
|
+
"max_reconnect_backoff": self.max_reconnect_backoff,
|
|
78
|
+
}
|
|
79
|
+
for name, value in durations.items():
|
|
80
|
+
# None is how a field says "leave gRPC's default"; 0 would be a different setting
|
|
81
|
+
# altogether, and not one any of these arguments has a meaning for.
|
|
82
|
+
if value is not None and value <= 0:
|
|
83
|
+
raise ValueError(f"{name} must be positive, or None to keep the gRPC default")
|
|
84
|
+
|
|
85
|
+
if self.max_pings_without_data is not None and self.max_pings_without_data < 0:
|
|
86
|
+
raise ValueError("max_pings_without_data must be non-negative (0 means no limit)")
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
self.min_reconnect_backoff is not None
|
|
90
|
+
and self.max_reconnect_backoff is not None
|
|
91
|
+
and self.min_reconnect_backoff > self.max_reconnect_backoff
|
|
92
|
+
):
|
|
93
|
+
raise ValueError("min_reconnect_backoff must not exceed max_reconnect_backoff")
|
|
94
|
+
|
|
95
|
+
def to_options(self) -> list[tuple[str, Any]]:
|
|
96
|
+
"""Return these settings as gRPC channel arguments.
|
|
97
|
+
|
|
98
|
+
The order is the declaration order of the fields and never varies, which is what keeps a
|
|
99
|
+
channel's identity stable: options are part of the channel pool's key (see
|
|
100
|
+
`channel.ChannelKey`), so a list assembled differently from one call to the next would open
|
|
101
|
+
a new channel each time.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The channel arguments, in seconds converted to the milliseconds gRPC expects. Fields
|
|
105
|
+
left at ``None`` contribute nothing, so gRPC keeps its own default for them.
|
|
106
|
+
"""
|
|
107
|
+
options: list[tuple[str, Any]] = []
|
|
108
|
+
|
|
109
|
+
if self.keepalive_time is not None:
|
|
110
|
+
options.append(("grpc.keepalive_time_ms", _milliseconds(self.keepalive_time)))
|
|
111
|
+
if self.keepalive_timeout is not None:
|
|
112
|
+
options.append(("grpc.keepalive_timeout_ms", _milliseconds(self.keepalive_timeout)))
|
|
113
|
+
|
|
114
|
+
options.append(("grpc.keepalive_permit_without_calls", int(self.permit_without_calls)))
|
|
115
|
+
|
|
116
|
+
if self.max_pings_without_data is not None:
|
|
117
|
+
options.append(("grpc.http2.max_pings_without_data", self.max_pings_without_data))
|
|
118
|
+
if self.initial_reconnect_backoff is not None:
|
|
119
|
+
options.append(("grpc.initial_reconnect_backoff_ms", _milliseconds(self.initial_reconnect_backoff)))
|
|
120
|
+
if self.min_reconnect_backoff is not None:
|
|
121
|
+
options.append(("grpc.min_reconnect_backoff_ms", _milliseconds(self.min_reconnect_backoff)))
|
|
122
|
+
if self.max_reconnect_backoff is not None:
|
|
123
|
+
options.append(("grpc.max_reconnect_backoff_ms", _milliseconds(self.max_reconnect_backoff)))
|
|
124
|
+
|
|
125
|
+
return options
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(slots=True)
|
|
129
|
+
class GrpcClientConfig:
|
|
130
|
+
"""Core configuration for establishing a gRPC channel connection.
|
|
131
|
+
|
|
132
|
+
Attributes:
|
|
133
|
+
target: The target address (host:port) or a gRPC-compatible resolver URI.
|
|
134
|
+
insecure: Whether to use an insecure channel (default: False).
|
|
135
|
+
credentials: SSL/TLS credentials for secure channels.
|
|
136
|
+
options: A list of key-value pairs to configure the gRPC channel. Anything set here wins
|
|
137
|
+
over `connectivity`.
|
|
138
|
+
compression: The compression algorithm to use for the channel.
|
|
139
|
+
connectivity: Keepalive and reconnect tuning, spelled in seconds instead of as raw channel
|
|
140
|
+
arguments. ``None`` leaves every one of those arguments at gRPC's own default.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
target: str | None = None
|
|
144
|
+
insecure: bool = False
|
|
145
|
+
credentials: grpc.ChannelCredentials | None = None
|
|
146
|
+
options: list[tuple[str, Any]] | None = None
|
|
147
|
+
compression: grpc.Compression | None = None
|
|
148
|
+
connectivity: ConnectivityConfig | None = None
|
|
149
|
+
|
|
150
|
+
def __post_init__(self) -> None:
|
|
151
|
+
"""Validate configuration."""
|
|
152
|
+
if self.insecure and self.credentials:
|
|
153
|
+
raise ValueError("Cannot provide credentials for an insecure channel")
|
|
154
|
+
|
|
155
|
+
def channel_options(self) -> list[tuple[str, Any]] | None:
|
|
156
|
+
"""Return the full option list a channel is opened with.
|
|
157
|
+
|
|
158
|
+
Explicit `options` come first and untouched, followed by the arguments `connectivity` stands
|
|
159
|
+
for — minus any whose key the caller already used. Filtering those out is what makes "an
|
|
160
|
+
explicit option wins" true in the only sense gRPC can honour it: the argument is passed once,
|
|
161
|
+
with the caller's value, so there is no duplicate for gRPC to resolve one way or the other.
|
|
162
|
+
|
|
163
|
+
The result is a pure function of the configuration, in a fixed order, which matters because
|
|
164
|
+
the pool keys channels by their options among other things (see `channel.ChannelKey`): two
|
|
165
|
+
clients configured alike share a channel, and one client asking twice does not open two.
|
|
166
|
+
Without `connectivity` the caller's own list is handed back as it is — ``None`` included — so
|
|
167
|
+
a client that does not tune anything keeps exactly the channel identity it always had.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The options to open the channel with, or None when there are none at all.
|
|
171
|
+
"""
|
|
172
|
+
if self.connectivity is None:
|
|
173
|
+
return self.options
|
|
174
|
+
|
|
175
|
+
explicit = self.options or []
|
|
176
|
+
already_set = {key for key, _ in explicit}
|
|
177
|
+
derived = [(key, value) for key, value in self.connectivity.to_options() if key not in already_set]
|
|
178
|
+
|
|
179
|
+
if not derived:
|
|
180
|
+
return self.options
|
|
181
|
+
|
|
182
|
+
return [*explicit, *derived]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = ["ConnectivityConfig", "GrpcClientConfig"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The deadline budget of the request in flight, and the ambient context that carries it.
|
|
2
|
+
|
|
3
|
+
`deadline-budget` deliberately has **no implicit context**: a `BudgetContext` is created by whoever
|
|
4
|
+
starts the request and handed from one call site to the next by argument. That is the right default
|
|
5
|
+
for a budget library and the wrong shape for a client kit — an interceptor sits far below the code
|
|
6
|
+
that knows what the budget is, and none of the layers in between would carry it. Nothing propagates
|
|
7
|
+
a deadline between services today; this module supplies the one piece that was missing, a contextvar
|
|
8
|
+
holding the current budget, plus the two functions that write and read it.
|
|
9
|
+
|
|
10
|
+
Installing a budget is the caller's job, and the chain only closes when they do it::
|
|
11
|
+
|
|
12
|
+
from deadline_budget import BudgetContext
|
|
13
|
+
from grpc_client_kit import use_budget
|
|
14
|
+
|
|
15
|
+
with use_budget(BudgetContext.create(total_seconds=5.0)):
|
|
16
|
+
await client.fetch_user(user_id) # issued with what is left of those 5 seconds
|
|
17
|
+
|
|
18
|
+
Without that block every call behaves exactly as it did before: the interceptor finds no budget and
|
|
19
|
+
touches nothing. The kit provides the mechanism, not the magic — a service that never installs a
|
|
20
|
+
budget propagates nothing, however many interceptors it runs.
|
|
21
|
+
|
|
22
|
+
A `ContextVar` is per-task, which gives the semantics a deadline needs: a task started while a
|
|
23
|
+
budget is installed inherits it, so a fan-out shares one deadline, while a budget installed inside
|
|
24
|
+
one task is invisible to its siblings and cannot leak out of it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from contextvars import ContextVar, Token
|
|
32
|
+
from typing import Protocol, runtime_checkable
|
|
33
|
+
|
|
34
|
+
# HAS_DEADLINE_BUDGET tells whether `deadline-budget` (the `deadline` extra) is importable. When it
|
|
35
|
+
# is False the deadline interceptor is left out of the chain and everything here still works: the
|
|
36
|
+
# contextvar is typed structurally, so it never needs the library to describe what it holds.
|
|
37
|
+
try:
|
|
38
|
+
from deadline_budget import DeadlineExceededError
|
|
39
|
+
|
|
40
|
+
HAS_DEADLINE_BUDGET = True
|
|
41
|
+
except ImportError: # pragma: no cover - only reachable without the "deadline" extra
|
|
42
|
+
HAS_DEADLINE_BUDGET = False
|
|
43
|
+
|
|
44
|
+
class DeadlineExceededError(Exception): # type: ignore[no-redef]
|
|
45
|
+
"""Stand-in that keeps this module importable, and its ``except`` clauses evaluable."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class DeadlineBudgetProtocol(Protocol):
|
|
50
|
+
"""What the kit needs of a request budget: the shape of `deadline_budget.BudgetContext`.
|
|
51
|
+
|
|
52
|
+
Typing against the shape rather than against the class is what keeps the library optional — the
|
|
53
|
+
kit never imports `deadline-budget` to describe a budget — and it leaves the door open for a
|
|
54
|
+
caller who tracks deadlines their own way.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def timeout_for_call(self, call_name: str, reserve_for_next: float = 0.0) -> float:
|
|
58
|
+
"""Return the timeout the named call may use, bounded by what is left of the budget.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
call_name: Name the per-call caps are keyed by.
|
|
62
|
+
reserve_for_next: Seconds to keep back for the steps that follow this call.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The timeout in seconds.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
DeadlineExceededError: If the budget is already exhausted.
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def remaining(self) -> float:
|
|
73
|
+
"""Return the seconds left, which is negative once the deadline has passed."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def expired(self) -> bool:
|
|
77
|
+
"""Return whether the budget is exhausted."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# The budget of the request being served, or None when nobody installed one. Read on every outgoing
|
|
82
|
+
# call by `interceptors.deadline.AsyncDeadlineBudgetInterceptor`.
|
|
83
|
+
_CURRENT_BUDGET: ContextVar[DeadlineBudgetProtocol | None] = ContextVar("grpc_client_kit_budget", default=None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def current_budget() -> DeadlineBudgetProtocol | None:
|
|
87
|
+
"""Return the budget installed for the current task, or None if there is none.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The budget `use_budget` last installed in this context, or None.
|
|
91
|
+
"""
|
|
92
|
+
return _CURRENT_BUDGET.get()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@contextmanager
|
|
96
|
+
def use_budget(budget: DeadlineBudgetProtocol | None) -> Iterator[DeadlineBudgetProtocol | None]:
|
|
97
|
+
"""Install `budget` as the current one for the duration of the block.
|
|
98
|
+
|
|
99
|
+
The previous value is restored on the way out, whether the block ends normally or by exception,
|
|
100
|
+
so nesting works and an inner budget cannot outlive its block. Passing None is the way to detach
|
|
101
|
+
an inherited budget — for background work that must not die with the request that spawned it.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
budget: The budget to install, or None to run this block without one.
|
|
105
|
+
|
|
106
|
+
Yields:
|
|
107
|
+
The budget that was installed, for convenience at the call site.
|
|
108
|
+
"""
|
|
109
|
+
token: Token[DeadlineBudgetProtocol | None] = _CURRENT_BUDGET.set(budget)
|
|
110
|
+
try:
|
|
111
|
+
yield budget
|
|
112
|
+
finally:
|
|
113
|
+
_CURRENT_BUDGET.reset(token)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"HAS_DEADLINE_BUDGET",
|
|
118
|
+
"DeadlineBudgetProtocol",
|
|
119
|
+
"DeadlineExceededError",
|
|
120
|
+
"current_budget",
|
|
121
|
+
"use_budget",
|
|
122
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The common ancestor of every failure the kit raises on its own authority.
|
|
2
|
+
|
|
3
|
+
A caller talking to a gRPC service sees two very different families of failure: the server said no
|
|
4
|
+
(an ordinary `grpc.aio.AioRpcError` carrying the server's status), and the *kit* said no before or
|
|
5
|
+
instead of the network — an open circuit breaker, an exhausted deadline budget, a balancer with no
|
|
6
|
+
healthy target left, a health checker that was never started. Telling them apart matters: the
|
|
7
|
+
first is an answer, the second is local state, and retrying or alerting on them differs.
|
|
8
|
+
``except GrpcClientKitError`` is the one handler that catches exactly the second family.
|
|
9
|
+
|
|
10
|
+
Kit failures that stand in for an RPC outcome — the breaker's rejection, the exhausted budget —
|
|
11
|
+
additionally derive from `grpc.aio.AioRpcError`, so an existing ``except AioRpcError`` (and every
|
|
12
|
+
logging, metrics and tracing layer) keeps seeing them as the call failures they are.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GrpcClientKitError(Exception):
|
|
19
|
+
"""Base class of every failure raised by the kit itself rather than by a server."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HealthCheckerNotRunningError(GrpcClientKitError, RuntimeError):
|
|
23
|
+
"""Raised when health state is requested but no check loop can ever produce it.
|
|
24
|
+
|
|
25
|
+
Lives here rather than in `health` so that catching it does not require the ``[health]``
|
|
26
|
+
extra: the caller of a balancer meets this error, and a balancer works on a bare install.
|
|
27
|
+
Also a `RuntimeError` — being asked before ``start()`` is a lifecycle mistake in the calling
|
|
28
|
+
code, and existing ``except RuntimeError`` handlers keep working.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
target: The target whose health was requested.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, target: str) -> None:
|
|
35
|
+
"""Initialize the error.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
target: The target whose health was requested.
|
|
39
|
+
"""
|
|
40
|
+
self.target = target
|
|
41
|
+
super().__init__(
|
|
42
|
+
f"HealthChecker is not running, so the health of '{target}' is unknown. "
|
|
43
|
+
"Call start() before routing traffic (a factory that owns the checker must be "
|
|
44
|
+
"entered with 'async with')."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"GrpcClientKitError",
|
|
50
|
+
"HealthCheckerNotRunningError",
|
|
51
|
+
]
|