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.
@@ -0,0 +1,166 @@
1
+ """Batteries-optional async gRPC client toolkit.
2
+
3
+ Channel pooling with health monitoring, client-side load balancing
4
+ (round-robin, random, weighted), resilience (retries, timeouts, circuit
5
+ breakers) and observability (logging, OpenTelemetry tracing, metrics) — with
6
+ every integration behind an extra.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from .__version__ import __version__
15
+ from .balancers import (
16
+ LoadBalancer,
17
+ LoadBalancerConfig,
18
+ LoadBalancingStrategy,
19
+ NoHealthyTargetsError,
20
+ RandomLoadBalancer,
21
+ RoundRobinLoadBalancer,
22
+ WeightedLoadBalancer,
23
+ create_balancer,
24
+ )
25
+ from .channel import ChannelKey, ChannelPool
26
+ from .client import GrpcClient
27
+ from .config import ConnectivityConfig, GrpcClientConfig
28
+ from .deadline import DeadlineBudgetProtocol, current_budget, use_budget
29
+ from .errors import GrpcClientKitError, HealthCheckerNotRunningError
30
+ from .factory import GrpcClientFactory
31
+ from .interceptors import (
32
+ AsyncCircuitBreakerInterceptor,
33
+ AsyncClientContextInterceptor,
34
+ AsyncLoggingInterceptor,
35
+ AsyncRetryInterceptor,
36
+ AsyncTimeoutInterceptor,
37
+ AsyncWaitForReadyInterceptor,
38
+ CircuitBreakerConfig,
39
+ CircuitBreakerOpenError,
40
+ DeadlineBudgetConfig,
41
+ InterceptorChainBuilder,
42
+ ObservabilityConfig,
43
+ RetryConfig,
44
+ TimeoutConfig,
45
+ WaitForReadyConfig,
46
+ build_interceptors,
47
+ )
48
+ from .interceptors.base import (
49
+ AsyncAroundClientInterceptor,
50
+ AsyncClientInterceptor,
51
+ ClientCall,
52
+ flatten_interceptors,
53
+ logical_interceptor,
54
+ )
55
+ from .interceptors.circuit_breaker import CircuitBreakerStatus, CircuitState
56
+ from .interceptors.deadline import DeadlineBudgetExhaustedError
57
+ from .protocols import (
58
+ ChannelPoolSettingsProtocol,
59
+ ChannelProviderProtocol,
60
+ CircuitBreakerMetricsProtocol,
61
+ CircuitBreakerSettingsProtocol,
62
+ GrpcClientMetricsProtocol,
63
+ GrpcClientSettingsProtocol,
64
+ HealthCheckerProtocol,
65
+ HealthCheckerSettingsProtocol,
66
+ HealthStatusCallbackProtocol,
67
+ LoadBalancerSettingsProtocol,
68
+ RetryMetricsProtocol,
69
+ RetrySettingsProtocol,
70
+ TimeoutSettingsProtocol,
71
+ )
72
+ from .utils import metadata_to_dict
73
+
74
+ if TYPE_CHECKING:
75
+ from .health import HealthChecker
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+ # The public surface, deliberately curated. The extension seam (AsyncAroundClientInterceptor,
80
+ # ClientCall, flatten_interceptors) is first-class: it is what custom interceptors are written
81
+ # against. Pool internals (ChannelWrapper, chain_token) are deliberately NOT here — exporting them
82
+ # would freeze the pool's implementation into the compatibility contract.
83
+ __all__ = [
84
+ "AsyncAroundClientInterceptor",
85
+ "AsyncCircuitBreakerInterceptor",
86
+ "AsyncClientContextInterceptor",
87
+ "AsyncClientInterceptor",
88
+ "AsyncLoggingInterceptor",
89
+ "AsyncRetryInterceptor",
90
+ "AsyncTimeoutInterceptor",
91
+ "AsyncWaitForReadyInterceptor",
92
+ "ChannelKey",
93
+ "ChannelPool",
94
+ "ChannelPoolSettingsProtocol",
95
+ "ChannelProviderProtocol",
96
+ "CircuitBreakerConfig",
97
+ "CircuitBreakerMetricsProtocol",
98
+ "CircuitBreakerOpenError",
99
+ "CircuitBreakerSettingsProtocol",
100
+ "CircuitBreakerStatus",
101
+ "CircuitState",
102
+ "ClientCall",
103
+ "ConnectivityConfig",
104
+ "DeadlineBudgetConfig",
105
+ "DeadlineBudgetExhaustedError",
106
+ "DeadlineBudgetProtocol",
107
+ "GrpcClient",
108
+ "GrpcClientConfig",
109
+ "GrpcClientFactory",
110
+ "GrpcClientKitError",
111
+ "GrpcClientMetricsProtocol",
112
+ "GrpcClientSettingsProtocol",
113
+ "HealthChecker",
114
+ "HealthCheckerNotRunningError",
115
+ "HealthCheckerProtocol",
116
+ "HealthCheckerSettingsProtocol",
117
+ "HealthStatusCallbackProtocol",
118
+ "InterceptorChainBuilder",
119
+ "LoadBalancer",
120
+ "LoadBalancerConfig",
121
+ "LoadBalancerSettingsProtocol",
122
+ "LoadBalancingStrategy",
123
+ "NoHealthyTargetsError",
124
+ "ObservabilityConfig",
125
+ "RandomLoadBalancer",
126
+ "RetryConfig",
127
+ "RetryMetricsProtocol",
128
+ "RetrySettingsProtocol",
129
+ "RoundRobinLoadBalancer",
130
+ "TimeoutConfig",
131
+ "TimeoutSettingsProtocol",
132
+ "WaitForReadyConfig",
133
+ "WeightedLoadBalancer",
134
+ "__version__",
135
+ "build_interceptors",
136
+ "create_balancer",
137
+ "current_budget",
138
+ "flatten_interceptors",
139
+ "logical_interceptor",
140
+ "metadata_to_dict",
141
+ "use_budget",
142
+ ]
143
+
144
+
145
+ def __getattr__(name: str) -> Any:
146
+ """Resolve extras-gated exports on first access.
147
+
148
+ ``HealthChecker`` needs the [health] extra, so importing this package must not import it: a
149
+ module-level import would make ``import grpc_client_kit`` fail on a bare install.
150
+
151
+ Args:
152
+ name: The attribute being looked up.
153
+
154
+ Returns:
155
+ The resolved attribute.
156
+
157
+ Raises:
158
+ AttributeError: If the package has no such attribute.
159
+ ImportError: If the attribute needs an extra that is not installed.
160
+ """
161
+ if name == "HealthChecker":
162
+ from .factory import _load_health_checker # noqa: PLC0415 - lazy: needs the [health] extra
163
+
164
+ return _load_health_checker()
165
+
166
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0" # x-release-please-version
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import random
6
+ import time
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+
11
+ from .errors import GrpcClientKitError
12
+ from .protocols import HealthCheckerProtocol
13
+ from .validation import validate_target
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class NoHealthyTargetsError(GrpcClientKitError):
19
+ """Raised when no healthy targets are available."""
20
+
21
+ def __init__(self, targets: list[str]) -> None:
22
+ self.targets = targets
23
+ super().__init__(f"No healthy targets available among {targets}")
24
+
25
+
26
+ class LoadBalancingStrategy(StrEnum):
27
+ """Load balancing strategies."""
28
+
29
+ ROUND_ROBIN = "round_robin"
30
+ RANDOM = "random"
31
+ WEIGHTED = "weighted"
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class LoadBalancerConfig:
36
+ """Configuration for load balancing."""
37
+
38
+ strategy: LoadBalancingStrategy = LoadBalancingStrategy.ROUND_ROBIN
39
+ weights: dict[str, float] | None = None
40
+
41
+
42
+ class LoadBalancer(ABC):
43
+ """Base class for gRPC client load balancers.
44
+
45
+ Provides a standard interface for selecting a target from a list of addresses,
46
+ with support for health filtering.
47
+ """
48
+
49
+ def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
50
+ """Initialize the load balancer.
51
+
52
+ Args:
53
+ targets: List of target addresses (host:port).
54
+ health_checker: Optional health checker for filtering unhealthy targets.
55
+
56
+ Raises:
57
+ ValueError: If targets list is empty or any target is invalid.
58
+ """
59
+ if not targets:
60
+ raise ValueError("Targets list cannot be empty. At least one host:port target must be provided.")
61
+
62
+ for t in targets:
63
+ validate_target(t)
64
+
65
+ self._targets = list(targets) # Copy list to prevent external mutation
66
+ self._health_checker = health_checker
67
+ # Passive verdicts: target -> monotonic deadline until which it is avoided. An active
68
+ # checker learns about a dead backend one probe interval late; a real call learns
69
+ # immediately, and report_failure() is how that knowledge reaches the balancer.
70
+ self._quarantine: dict[str, float] = {}
71
+
72
+ def report_failure(self, target: str, quarantine: float = 5.0) -> None:
73
+ """Quarantine a target that a real call just found unreachable.
74
+
75
+ Active probing has an inherent window: with the default check interval a dead backend
76
+ keeps receiving its share of traffic for up to that interval, every call burning a full
77
+ client deadline. A call that got ``UNAVAILABLE`` is fresher evidence than any probe, so
78
+ it takes the target out of the rotation immediately, for `quarantine` seconds — long
79
+ enough for the next probe (or a recovered backend) to have the casting vote.
80
+
81
+ Args:
82
+ target: The target address the failed call was routed to.
83
+ quarantine: Seconds to keep the target out of the rotation.
84
+ """
85
+ self._quarantine[target] = time.monotonic() + quarantine
86
+ logger.debug("Target %s quarantined for %.1fs after a failed call", target, quarantine)
87
+
88
+ def _without_quarantined(self, candidates: list[str]) -> list[str]:
89
+ """Drop quarantined targets from `candidates` — unless that would drop them all.
90
+
91
+ When every candidate is quarantined the quarantine is ignored: degraded service beats
92
+ refusing to route at all, and the next failure simply renews the verdict.
93
+ """
94
+ now = time.monotonic()
95
+ expired = [target for target, deadline in self._quarantine.items() if now >= deadline]
96
+ for target in expired:
97
+ del self._quarantine[target]
98
+
99
+ kept = [target for target in candidates if target not in self._quarantine]
100
+ return kept or candidates
101
+
102
+ @abstractmethod
103
+ async def select_target(self) -> str:
104
+ """Select a target from the available targets.
105
+
106
+ Returns:
107
+ The selected target address (host:port).
108
+
109
+ Raises:
110
+ NoHealthyTargetsError: If all targets are unhealthy.
111
+ """
112
+ ...
113
+
114
+
115
+ class RoundRobinLoadBalancer(LoadBalancer):
116
+ """Round-robin load balancer with health check support.
117
+
118
+ Selects targets in a fixed cyclic order, skipping unhealthy ones.
119
+ """
120
+
121
+ def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
122
+ """Initialize the Round-Robin balancer.
123
+
124
+ Args:
125
+ targets: List of target addresses.
126
+ health_checker: Optional health checker.
127
+ """
128
+ super().__init__(targets, health_checker)
129
+ self._index = 0
130
+ self._lock = asyncio.Lock()
131
+
132
+ async def select_target(self) -> str:
133
+ """Select the next target in the round-robin sequence."""
134
+ if not self._health_checker:
135
+ candidates = set(self._without_quarantined(self._targets))
136
+ async with self._lock:
137
+ for _ in range(len(self._targets)):
138
+ target = self._targets[self._index]
139
+ self._index = (self._index + 1) % len(self._targets)
140
+ if target in candidates:
141
+ logger.debug("Selected target %s using round-robin", target)
142
+ return target
143
+ # Unreachable in practice — _without_quarantined never empties the candidates —
144
+ # but a plain pick beats an exception if it ever is.
145
+ return self._targets[self._index]
146
+
147
+ # With health checker - pre-fetch health statuses outside the lock to avoid blocking
148
+ # but keep it in a small window to maintain some accuracy.
149
+ health_statuses = await asyncio.gather(
150
+ *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
151
+ )
152
+ healthy = [target for target, status in zip(self._targets, health_statuses, strict=True) if status is True]
153
+ candidates = set(self._without_quarantined(healthy))
154
+
155
+ num_targets = len(self._targets)
156
+ async with self._lock:
157
+ for _ in range(num_targets):
158
+ target = self._targets[self._index]
159
+ self._index = (self._index + 1) % num_targets
160
+
161
+ if target in candidates:
162
+ logger.debug("Selected target %s using round-robin (health_checker=True)", target)
163
+ return target
164
+
165
+ # All unhealthy - raise error
166
+ raise NoHealthyTargetsError(self._targets)
167
+
168
+
169
+ class RandomLoadBalancer(LoadBalancer):
170
+ """Random load balancer with health check support.
171
+
172
+ Selects a random target from the list of healthy targets.
173
+ Caches healthy targets for 1 second to improve performance.
174
+ """
175
+
176
+ def __init__(self, targets: list[str], health_checker: HealthCheckerProtocol | None = None) -> None:
177
+ """Initialize the Random balancer.
178
+
179
+ Args:
180
+ targets: List of target addresses.
181
+ health_checker: Optional health checker.
182
+ """
183
+ super().__init__(targets, health_checker)
184
+ self._healthy_targets: list[str] = []
185
+ self._last_health_update = 0.0
186
+ self._health_cache_ttl = 1.0 # 1 second
187
+ self._lock = asyncio.Lock()
188
+
189
+ async def select_target(self) -> str:
190
+ """Select a random healthy target."""
191
+ if not self._health_checker:
192
+ target = random.choice(self._without_quarantined(self._targets)) # noqa: S311
193
+ logger.debug("Selected target %s using random", target)
194
+ return target
195
+
196
+ # Use cached healthy targets if possible to avoid frequent gathers
197
+ async with self._lock:
198
+ now = time.time()
199
+ if now - self._last_health_update > self._health_cache_ttl:
200
+ # Filter healthy targets in parallel
201
+ health_statuses = await asyncio.gather(
202
+ *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
203
+ )
204
+
205
+ self._healthy_targets = [
206
+ t for t, status in zip(self._targets, health_statuses, strict=True) if status is True
207
+ ]
208
+ self._last_health_update = now
209
+
210
+ if not self._healthy_targets:
211
+ raise NoHealthyTargetsError(self._targets)
212
+
213
+ # Quarantine is applied on every pick, not cached: a passive verdict may arrive (and
214
+ # expire) well within the health cache's TTL.
215
+ target = random.choice(self._without_quarantined(self._healthy_targets)) # noqa: S311
216
+ logger.debug("Selected target %s using random (total_healthy=%d)", target, len(self._healthy_targets))
217
+ return target
218
+
219
+
220
+ class WeightedLoadBalancer(LoadBalancer):
221
+ """Weighted random load balancer with health check support.
222
+
223
+ Selects targets based on provided weights, giving higher preference to
224
+ targets with larger weights. Skips unhealthy targets.
225
+ """
226
+
227
+ def __init__(
228
+ self,
229
+ targets: list[str],
230
+ weights: dict[str, float],
231
+ health_checker: HealthCheckerProtocol | None = None,
232
+ ) -> None:
233
+ """Initialize the Weighted balancer.
234
+
235
+ Args:
236
+ targets: List of target addresses.
237
+ weights: Mapping of target to its weight (default 1.0).
238
+ health_checker: Optional health checker.
239
+ """
240
+ super().__init__(targets, health_checker)
241
+ self._weights_dict = weights
242
+
243
+ # Validate weights
244
+ for t in targets:
245
+ weight = weights.get(t, 1.0)
246
+ if weight < 0:
247
+ raise ValueError(f"Weight for target {t} cannot be negative: {weight}")
248
+
249
+ if sum(weights.get(t, 1.0) for t in targets) <= 0:
250
+ raise ValueError("Sum of weights must be positive")
251
+
252
+ # Pre-calculate weights list for targets to avoid repeated dict lookups
253
+ self._cached_weights = [weights.get(t, 1.0) for t in targets]
254
+
255
+ # Health status caching (similar to RandomLoadBalancer)
256
+ self._healthy_targets: list[str] = []
257
+ self._healthy_weights: list[float] = []
258
+ self._last_health_update = 0.0
259
+ self._health_cache_ttl = 1.0 # 1 second
260
+ self._lock = asyncio.Lock()
261
+
262
+ def _weighted_pick(self, candidates: list[str]) -> str:
263
+ """Pick among `candidates` by weight, falling back to uniform when all weights are zero."""
264
+ weights = [self._weights_dict.get(target, 1.0) for target in candidates]
265
+ if sum(weights) <= 0:
266
+ return random.choice(candidates) # noqa: S311
267
+ return random.choices(candidates, weights=weights, k=1)[0] # noqa: S311
268
+
269
+ async def select_target(self) -> str:
270
+ """Select a target using weighted random selection among healthy ones."""
271
+ if not self._health_checker:
272
+ target = self._weighted_pick(self._without_quarantined(self._targets))
273
+ logger.debug(
274
+ "Selected target %s using weighted random (weight=%.2f)",
275
+ target,
276
+ self._weights_dict.get(target, 1.0),
277
+ )
278
+ return target
279
+
280
+ # Use cached healthy targets if possible
281
+ async with self._lock:
282
+ now = time.time()
283
+ if now - self._last_health_update > self._health_cache_ttl:
284
+ # Filter healthy targets and their weights in parallel
285
+ health_statuses = await asyncio.gather(
286
+ *(self._health_checker.is_healthy(t) for t in self._targets), return_exceptions=True
287
+ )
288
+
289
+ self._healthy_targets = []
290
+ self._healthy_weights = []
291
+
292
+ for t, status in zip(self._targets, health_statuses, strict=True):
293
+ if status is True:
294
+ self._healthy_targets.append(t)
295
+ self._healthy_weights.append(self._weights_dict.get(t, 1.0))
296
+
297
+ self._last_health_update = now
298
+
299
+ if not self._healthy_targets:
300
+ raise NoHealthyTargetsError(self._targets)
301
+
302
+ # Quarantine is applied on every pick, not cached: a passive verdict may arrive (and
303
+ # expire) well within the health cache's TTL.
304
+ target = self._weighted_pick(self._without_quarantined(self._healthy_targets))
305
+
306
+ logger.debug(
307
+ "Selected target %s using weighted random (weight=%.2f, total_healthy=%d)",
308
+ target,
309
+ self._weights_dict.get(target, 1.0),
310
+ len(self._healthy_targets),
311
+ )
312
+ return target
313
+
314
+
315
+ def create_balancer(
316
+ targets: list[str],
317
+ config: LoadBalancerConfig | None = None,
318
+ health_checker: HealthCheckerProtocol | None = None,
319
+ ) -> LoadBalancer:
320
+ """Factory function to create a load balancer from targets and config.
321
+
322
+ By default, creates a Round-Robin balancer if no config is provided.
323
+
324
+ Args:
325
+ targets: List of target addresses (host:port)
326
+ config: Balancer configuration (strategy and weights)
327
+ health_checker: Optional health checker for filtering unhealthy targets
328
+
329
+ Returns:
330
+ A concrete LoadBalancer instance
331
+
332
+ Raises:
333
+ ValueError: If targets list is empty or config is invalid.
334
+ """
335
+ if not targets:
336
+ raise ValueError("Targets list cannot be empty for load balancer")
337
+
338
+ strategy = config.strategy if config else LoadBalancingStrategy.ROUND_ROBIN
339
+
340
+ match strategy:
341
+ case LoadBalancingStrategy.RANDOM:
342
+ return RandomLoadBalancer(targets, health_checker)
343
+ case LoadBalancingStrategy.WEIGHTED:
344
+ if not config or not config.weights:
345
+ raise ValueError(f"Weights must be provided for {strategy} strategy")
346
+ return WeightedLoadBalancer(targets, config.weights, health_checker)
347
+ case LoadBalancingStrategy.ROUND_ROBIN:
348
+ return RoundRobinLoadBalancer(targets, health_checker)
349
+ case _:
350
+ raise ValueError(f"Unknown load balancing strategy: {strategy}")
351
+
352
+
353
+ __all__ = [
354
+ "LoadBalancer",
355
+ "LoadBalancerConfig",
356
+ "LoadBalancingStrategy",
357
+ "NoHealthyTargetsError",
358
+ "RandomLoadBalancer",
359
+ "RoundRobinLoadBalancer",
360
+ "WeightedLoadBalancer",
361
+ "create_balancer",
362
+ ]