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,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
|
+
]
|