httpx-hedged 0.2.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.
- httpx_hedged/__init__.py +38 -0
- httpx_hedged/_bounded.py +45 -0
- httpx_hedged/_config.py +221 -0
- httpx_hedged/_health.py +284 -0
- httpx_hedged/_matcher.py +129 -0
- httpx_hedged/_rate.py +75 -0
- httpx_hedged/_rotation.py +42 -0
- httpx_hedged/_scheduler.py +302 -0
- httpx_hedged/_stats.py +160 -0
- httpx_hedged/budget/__init__.py +7 -0
- httpx_hedged/budget/_token_bucket.py +54 -0
- httpx_hedged/sketch/__init__.py +8 -0
- httpx_hedged/sketch/_ddsketch.py +188 -0
- httpx_hedged/sketch/_windowed.py +92 -0
- httpx_hedged/transport.py +176 -0
- httpx_hedged-0.2.0.dist-info/METADATA +300 -0
- httpx_hedged-0.2.0.dist-info/RECORD +19 -0
- httpx_hedged-0.2.0.dist-info/WHEEL +4 -0
- httpx_hedged-0.2.0.dist-info/licenses/LICENSE +28 -0
httpx_hedged/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Adaptive, per-endpoint request hedging transport for httpx.
|
|
2
|
+
|
|
3
|
+
Learns per-endpoint latency distributions using DDSketch, fires a backup
|
|
4
|
+
request when the primary exceeds its estimated percentile (or a hardcoded
|
|
5
|
+
delay), caps hedge rate with a token bucket, and stops hedging entirely
|
|
6
|
+
(without blocking the primary request) when a host or endpoint circuit
|
|
7
|
+
breaker trips.
|
|
8
|
+
|
|
9
|
+
Inspired by hedge-python (https://github.com/sunhailin-Leo/hedge-python),
|
|
10
|
+
adapted to key hedge state per endpoint rather than per host so a single
|
|
11
|
+
service with many routes of very different latency/RPS profiles doesn't
|
|
12
|
+
have those profiles mixed into one shared estimate.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from httpx_hedged._config import CircuitBreakerConfig, EndpointConfig, HedgeConfig
|
|
18
|
+
from httpx_hedged._health import CircuitBreaker, CircuitState, HealthRegistry
|
|
19
|
+
from httpx_hedged._matcher import Route, UnknownHedgeEndpointError
|
|
20
|
+
from httpx_hedged._stats import Stats, StatsRegistry, StatsSnapshot
|
|
21
|
+
from httpx_hedged.transport import HedgedTransport
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CircuitBreaker",
|
|
25
|
+
"CircuitBreakerConfig",
|
|
26
|
+
"CircuitState",
|
|
27
|
+
"EndpointConfig",
|
|
28
|
+
"HealthRegistry",
|
|
29
|
+
"HedgeConfig",
|
|
30
|
+
"HedgedTransport",
|
|
31
|
+
"Route",
|
|
32
|
+
"Stats",
|
|
33
|
+
"StatsRegistry",
|
|
34
|
+
"StatsSnapshot",
|
|
35
|
+
"UnknownHedgeEndpointError",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
__version__ = "0.2.0"
|
httpx_hedged/_bounded.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""A get-or-create map bounded to a max size, evicting least-recently-used keys.
|
|
2
|
+
|
|
3
|
+
Per-key state (sketches, breakers, stats) is created lazily, keyed by
|
|
4
|
+
endpoint name or a per-host fallback. Nothing ever evicted those keys, so a
|
|
5
|
+
caller that talks to high-cardinality hosts (per-tenant subdomains, or -- in
|
|
6
|
+
the pathological case -- one key per unique URL when a request has no
|
|
7
|
+
parseable host) would grow these maps forever. Bounding them with LRU
|
|
8
|
+
eviction keeps memory bounded without requiring callers to know about it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
DEFAULT_MAX_SIZE = 10_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BoundedRegistry[V]:
|
|
20
|
+
"""Not thread-safe by itself -- callers hold their own lock, as with the
|
|
21
|
+
plain dicts this replaces."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_size: int = DEFAULT_MAX_SIZE) -> None:
|
|
24
|
+
self._max_size = max_size
|
|
25
|
+
self._items: OrderedDict[str, V] = OrderedDict()
|
|
26
|
+
|
|
27
|
+
def get_or_create(self, key: str, factory: Callable[[], V]) -> V:
|
|
28
|
+
value = self._items.get(key)
|
|
29
|
+
if value is not None:
|
|
30
|
+
self._items.move_to_end(key)
|
|
31
|
+
return value
|
|
32
|
+
value = factory()
|
|
33
|
+
self._items[key] = value
|
|
34
|
+
if len(self._items) > self._max_size:
|
|
35
|
+
self._items.popitem(last=False)
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
def get(self, key: str) -> V | None:
|
|
39
|
+
value = self._items.get(key)
|
|
40
|
+
if value is not None:
|
|
41
|
+
self._items.move_to_end(key)
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
def items(self) -> list[tuple[str, V]]:
|
|
45
|
+
return list(self._items.items())
|
httpx_hedged/_config.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Configuration for hedge behavior, and default/per-endpoint override resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _validate_common(config: HedgeConfig | EndpointConfig) -> None:
|
|
9
|
+
"""Validate the fields shared by ``HedgeConfig`` and ``EndpointConfig``.
|
|
10
|
+
|
|
11
|
+
Maybe we should just use pydantic to get this stuff build in...
|
|
12
|
+
|
|
13
|
+
Every check is skipped for a ``None`` value, since ``EndpointConfig``
|
|
14
|
+
fields default to None (meaning "inherit the transport default") and
|
|
15
|
+
are validated for real once ``resolve()`` merges them onto a
|
|
16
|
+
``HedgeConfig``, which has no ``None`` fields.
|
|
17
|
+
"""
|
|
18
|
+
if config.percentile is not None and not 0 < config.percentile < 1:
|
|
19
|
+
raise ValueError(f"percentile must be between 0 and 1, got {config.percentile}")
|
|
20
|
+
if config.budget_percent is not None and config.budget_percent < 0:
|
|
21
|
+
raise ValueError(f"budget_percent must be >= 0, got {config.budget_percent}")
|
|
22
|
+
if config.estimated_rps is not None and config.estimated_rps <= 0:
|
|
23
|
+
raise ValueError(f"estimated_rps must be > 0, got {config.estimated_rps}")
|
|
24
|
+
if config.rps_window_duration is not None and config.rps_window_duration <= 0:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"rps_window_duration must be > 0, got {config.rps_window_duration}"
|
|
27
|
+
)
|
|
28
|
+
if config.min_delay is not None and config.min_delay < 0:
|
|
29
|
+
raise ValueError(f"min_delay must be >= 0, got {config.min_delay}")
|
|
30
|
+
if config.warmup_requests is not None and config.warmup_requests < 0:
|
|
31
|
+
raise ValueError(f"warmup_requests must be >= 0, got {config.warmup_requests}")
|
|
32
|
+
if config.warmup_delay is not None and config.warmup_delay < 0:
|
|
33
|
+
raise ValueError(f"warmup_delay must be >= 0, got {config.warmup_delay}")
|
|
34
|
+
if config.window_duration is not None and config.window_duration <= 0:
|
|
35
|
+
raise ValueError(f"window_duration must be > 0, got {config.window_duration}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class CircuitBreakerConfig:
|
|
40
|
+
"""Configuration for the health circuit breaker that gates hedging.
|
|
41
|
+
|
|
42
|
+
Tripping the breaker only ever suppresses the *hedge* request -- the
|
|
43
|
+
primary request is always sent and its result or exception is always
|
|
44
|
+
returned to the caller normally.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
#: Fraction of failed requests (0-1) that trips the breaker open.
|
|
48
|
+
error_rate_threshold: float = 0.5
|
|
49
|
+
|
|
50
|
+
#: Minimum number of samples in the current window before the breaker
|
|
51
|
+
#: is allowed to trip, avoiding trips on tiny samples.
|
|
52
|
+
min_samples: int = 20
|
|
53
|
+
|
|
54
|
+
#: Rolling window size in seconds for the error-rate estimate.
|
|
55
|
+
window_duration: float = 30.0
|
|
56
|
+
|
|
57
|
+
#: Seconds the breaker stays open before allowing a half-open trial.
|
|
58
|
+
cooldown: float = 30.0
|
|
59
|
+
|
|
60
|
+
#: Number of trial requests allowed through while half-open before
|
|
61
|
+
#: deciding to close or re-open.
|
|
62
|
+
half_open_max_trial: int = 5
|
|
63
|
+
|
|
64
|
+
#: Whether an HTTP 5xx response counts as a failure for
|
|
65
|
+
#: circuit-breaker purposes.
|
|
66
|
+
treat_5xx_as_failure: bool = True
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if not 0 <= self.error_rate_threshold <= 1:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"error_rate_threshold must be between 0 and 1, got "
|
|
72
|
+
f"{self.error_rate_threshold}"
|
|
73
|
+
)
|
|
74
|
+
if self.min_samples < 1:
|
|
75
|
+
raise ValueError(f"min_samples must be >= 1, got {self.min_samples}")
|
|
76
|
+
if self.window_duration <= 0:
|
|
77
|
+
raise ValueError(f"window_duration must be > 0, got {self.window_duration}")
|
|
78
|
+
if self.cooldown < 0:
|
|
79
|
+
raise ValueError(f"cooldown must be >= 0, got {self.cooldown}")
|
|
80
|
+
if self.half_open_max_trial < 1:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"half_open_max_trial must be >= 1, got {self.half_open_max_trial}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(slots=True)
|
|
87
|
+
class HedgeConfig:
|
|
88
|
+
"""Transport-wide default hedge configuration."""
|
|
89
|
+
|
|
90
|
+
#: Sketch quantile used as hedge trigger.
|
|
91
|
+
percentile: float = 0.90
|
|
92
|
+
|
|
93
|
+
#: Max hedge rate as percent of total traffic.
|
|
94
|
+
budget_percent: float = 10.0
|
|
95
|
+
|
|
96
|
+
#: Expected requests per second. If None, the rate is estimated
|
|
97
|
+
#: automatically from observed traffic instead of requiring a manual
|
|
98
|
+
#: guess.
|
|
99
|
+
estimated_rps: float | None = None
|
|
100
|
+
|
|
101
|
+
#: Rolling window in seconds for RPS auto-estimation.
|
|
102
|
+
rps_window_duration: float = 10.0
|
|
103
|
+
|
|
104
|
+
#: Floor on the hedge delay in seconds.
|
|
105
|
+
min_delay: float = 0.001
|
|
106
|
+
|
|
107
|
+
#: Number of initial requests using a fixed delay before the sketch
|
|
108
|
+
#: is trusted.
|
|
109
|
+
warmup_requests: int = 20
|
|
110
|
+
|
|
111
|
+
#: Fixed hedge delay during warmup, in seconds.
|
|
112
|
+
warmup_delay: float = 0.01
|
|
113
|
+
|
|
114
|
+
#: Latency sketch window rotation interval in seconds.
|
|
115
|
+
window_duration: float = 30.0
|
|
116
|
+
|
|
117
|
+
#: Health circuit-breaker configuration.
|
|
118
|
+
circuit_breaker: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig)
|
|
119
|
+
|
|
120
|
+
def __post_init__(self) -> None:
|
|
121
|
+
_validate_common(self)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(slots=True)
|
|
125
|
+
class EndpointConfig:
|
|
126
|
+
"""Per-endpoint override of the transport's default ``HedgeConfig``.
|
|
127
|
+
|
|
128
|
+
Every field defaults to None, meaning "inherit the transport default."
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
#: Hardcode a fixed hedge delay for this endpoint instead of learning
|
|
132
|
+
#: one adaptively from a DDSketch. The endpoint still records latency
|
|
133
|
+
#: into its sketch for observability; it just isn't consulted for the
|
|
134
|
+
#: hedge-delay decision.
|
|
135
|
+
hedge_delay: float | None = None
|
|
136
|
+
|
|
137
|
+
#: Sketch quantile used as hedge trigger.
|
|
138
|
+
percentile: float | None = None
|
|
139
|
+
|
|
140
|
+
#: Max hedge rate as percent of total traffic.
|
|
141
|
+
budget_percent: float | None = None
|
|
142
|
+
|
|
143
|
+
#: Expected requests per second. If None, the rate is estimated
|
|
144
|
+
#: automatically from observed traffic instead of requiring a manual
|
|
145
|
+
#: guess.
|
|
146
|
+
estimated_rps: float | None = None
|
|
147
|
+
|
|
148
|
+
#: Rolling window in seconds for RPS auto-estimation.
|
|
149
|
+
rps_window_duration: float | None = None
|
|
150
|
+
|
|
151
|
+
#: Floor on the hedge delay in seconds.
|
|
152
|
+
min_delay: float | None = None
|
|
153
|
+
|
|
154
|
+
#: Number of initial requests using a fixed delay before the sketch
|
|
155
|
+
#: is trusted.
|
|
156
|
+
warmup_requests: int | None = None
|
|
157
|
+
|
|
158
|
+
#: Fixed hedge delay during warmup, in seconds.
|
|
159
|
+
warmup_delay: float | None = None
|
|
160
|
+
|
|
161
|
+
#: Latency sketch window rotation interval in seconds.
|
|
162
|
+
window_duration: float | None = None
|
|
163
|
+
|
|
164
|
+
#: Health circuit-breaker configuration. When set, replaces the
|
|
165
|
+
#: default breaker config as a whole object rather than being merged
|
|
166
|
+
#: field-by-field -- for a partial override, use
|
|
167
|
+
#: ``dataclasses.replace(default.circuit_breaker, ...)``.
|
|
168
|
+
circuit_breaker: CircuitBreakerConfig | None = None
|
|
169
|
+
|
|
170
|
+
def __post_init__(self) -> None:
|
|
171
|
+
if self.hedge_delay is not None and self.hedge_delay < 0:
|
|
172
|
+
raise ValueError(f"hedge_delay must be >= 0, got {self.hedge_delay}")
|
|
173
|
+
_validate_common(self)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class EffectiveConfig:
|
|
178
|
+
"""Fully-resolved hedge configuration for a single key (no more None fields)."""
|
|
179
|
+
|
|
180
|
+
percentile: float
|
|
181
|
+
budget_percent: float
|
|
182
|
+
estimated_rps: float | None
|
|
183
|
+
rps_window_duration: float
|
|
184
|
+
min_delay: float
|
|
185
|
+
warmup_requests: int
|
|
186
|
+
warmup_delay: float
|
|
187
|
+
window_duration: float
|
|
188
|
+
circuit_breaker: CircuitBreakerConfig
|
|
189
|
+
hedge_delay: float | None = None
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def is_hardcoded(self) -> bool:
|
|
193
|
+
return self.hedge_delay is not None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _pick[T](override: T | None, fallback: T) -> T:
|
|
197
|
+
return override if override is not None else fallback
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def resolve(endpoint: EndpointConfig | None, default: HedgeConfig) -> EffectiveConfig:
|
|
201
|
+
"""Merge a per-endpoint override onto the transport default.
|
|
202
|
+
|
|
203
|
+
For each field, the endpoint's value is used if it is not None,
|
|
204
|
+
otherwise the default's value is used. Cheap enough to call on every
|
|
205
|
+
request rather than caching the result.
|
|
206
|
+
"""
|
|
207
|
+
override = endpoint if endpoint is not None else EndpointConfig()
|
|
208
|
+
return EffectiveConfig(
|
|
209
|
+
percentile=_pick(override.percentile, default.percentile),
|
|
210
|
+
budget_percent=_pick(override.budget_percent, default.budget_percent),
|
|
211
|
+
estimated_rps=_pick(override.estimated_rps, default.estimated_rps),
|
|
212
|
+
rps_window_duration=_pick(
|
|
213
|
+
override.rps_window_duration, default.rps_window_duration
|
|
214
|
+
),
|
|
215
|
+
min_delay=_pick(override.min_delay, default.min_delay),
|
|
216
|
+
warmup_requests=_pick(override.warmup_requests, default.warmup_requests),
|
|
217
|
+
warmup_delay=_pick(override.warmup_delay, default.warmup_delay),
|
|
218
|
+
window_duration=_pick(override.window_duration, default.window_duration),
|
|
219
|
+
circuit_breaker=_pick(override.circuit_breaker, default.circuit_breaker),
|
|
220
|
+
hedge_delay=override.hedge_delay,
|
|
221
|
+
)
|
httpx_hedged/_health.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Health-based circuit breaker that suppresses hedging during outages.
|
|
2
|
+
|
|
3
|
+
hedge-python has no concept of request success/failure at all -- its token
|
|
4
|
+
bucket caps hedge *volume* but has no idea whether the backend is actually
|
|
5
|
+
healthy. This module adds a circuit breaker, tracked independently at both
|
|
6
|
+
the host level and the per-endpoint level, so that either "one endpoint is
|
|
7
|
+
struggling" or "the whole host is struggling" stops hedging without
|
|
8
|
+
requiring the two to be conflated.
|
|
9
|
+
|
|
10
|
+
Tripping the breaker only ever suppresses the *hedge* request. The primary
|
|
11
|
+
request always goes through and its result or exception is always returned
|
|
12
|
+
to the caller -- this is deliberately not a request-blocking circuit
|
|
13
|
+
breaker, only a hedge-suppressing one, so hedging can't pile extra load
|
|
14
|
+
onto an already-failing backend.
|
|
15
|
+
|
|
16
|
+
Known limitation: health is recorded from the winning task's outcome only.
|
|
17
|
+
A cancelled loser's real outcome is unknowable, and losers are cancelled
|
|
18
|
+
deliberately -- not doing so would defeat the breaker's purpose of reducing
|
|
19
|
+
load on a struggling backend.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from enum import Enum, auto
|
|
28
|
+
|
|
29
|
+
from httpx_hedged._bounded import BoundedRegistry
|
|
30
|
+
from httpx_hedged._config import CircuitBreakerConfig
|
|
31
|
+
from httpx_hedged._rotation import RotateAction, next_action
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CircuitState(Enum):
|
|
35
|
+
CLOSED = auto()
|
|
36
|
+
OPEN = auto()
|
|
37
|
+
HALF_OPEN = auto()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _ErrorWindow:
|
|
41
|
+
"""Lazy dual-window success/failure counter, same rotation scheme as sketches."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, window_duration: float) -> None:
|
|
44
|
+
self._window_duration = window_duration
|
|
45
|
+
self._current_total = 0
|
|
46
|
+
self._current_failures = 0
|
|
47
|
+
self._previous_total = 0
|
|
48
|
+
self._previous_failures = 0
|
|
49
|
+
self._window_start = time.monotonic()
|
|
50
|
+
|
|
51
|
+
def _maybe_rotate(self) -> None:
|
|
52
|
+
now = time.monotonic()
|
|
53
|
+
action = next_action(self._window_start, self._window_duration, now)
|
|
54
|
+
if action is RotateAction.NONE:
|
|
55
|
+
return
|
|
56
|
+
if action is RotateAction.ROTATE:
|
|
57
|
+
self._previous_total = self._current_total
|
|
58
|
+
self._previous_failures = self._current_failures
|
|
59
|
+
else: # RESET
|
|
60
|
+
self._previous_total = 0
|
|
61
|
+
self._previous_failures = 0
|
|
62
|
+
self._current_total = 0
|
|
63
|
+
self._current_failures = 0
|
|
64
|
+
self._window_start = now
|
|
65
|
+
|
|
66
|
+
def record(self, ok: bool) -> None:
|
|
67
|
+
self._maybe_rotate()
|
|
68
|
+
self._current_total += 1
|
|
69
|
+
if not ok:
|
|
70
|
+
self._current_failures += 1
|
|
71
|
+
|
|
72
|
+
def sample_count(self) -> int:
|
|
73
|
+
self._maybe_rotate()
|
|
74
|
+
return self._current_total + self._previous_total
|
|
75
|
+
|
|
76
|
+
def error_rate(self) -> float:
|
|
77
|
+
self._maybe_rotate()
|
|
78
|
+
total = self._current_total + self._previous_total
|
|
79
|
+
if total == 0:
|
|
80
|
+
return 0.0
|
|
81
|
+
failures = self._current_failures + self._previous_failures
|
|
82
|
+
return failures / total
|
|
83
|
+
|
|
84
|
+
def reset(self) -> None:
|
|
85
|
+
self._current_total = 0
|
|
86
|
+
self._current_failures = 0
|
|
87
|
+
self._previous_total = 0
|
|
88
|
+
self._previous_failures = 0
|
|
89
|
+
self._window_start = time.monotonic()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CircuitBreaker:
|
|
93
|
+
"""A closed/open/half-open circuit breaker gating whether hedging is allowed.
|
|
94
|
+
|
|
95
|
+
Not itself thread-safe across concurrent mutation from multiple
|
|
96
|
+
threads; callers (``HealthRegistry``) hold their own lock.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
config: Breaker thresholds and timing.
|
|
100
|
+
on_open: Called (with no arguments) each time the breaker
|
|
101
|
+
transitions into the OPEN state, whether from CLOSED or from a
|
|
102
|
+
failed HALF_OPEN trial. Useful for alerting -- see the README's
|
|
103
|
+
observability section for a logging example.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
config: CircuitBreakerConfig,
|
|
109
|
+
on_open: Callable[[], None] | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
self._config = config
|
|
112
|
+
self._on_open = on_open
|
|
113
|
+
self._window = _ErrorWindow(config.window_duration)
|
|
114
|
+
self._state = CircuitState.CLOSED
|
|
115
|
+
self._opened_at: float = 0.0
|
|
116
|
+
self._half_open_trials = 0
|
|
117
|
+
self._half_open_failures = 0
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def state(self) -> CircuitState:
|
|
121
|
+
return self._state
|
|
122
|
+
|
|
123
|
+
def record_result(self, ok: bool) -> None:
|
|
124
|
+
if self._state is CircuitState.HALF_OPEN:
|
|
125
|
+
self._half_open_trials += 1
|
|
126
|
+
if not ok:
|
|
127
|
+
self._half_open_failures += 1
|
|
128
|
+
if self._half_open_trials >= self._config.half_open_max_trial:
|
|
129
|
+
if (
|
|
130
|
+
self._half_open_failures / self._half_open_trials
|
|
131
|
+
>= self._config.error_rate_threshold
|
|
132
|
+
):
|
|
133
|
+
self._reopen()
|
|
134
|
+
else:
|
|
135
|
+
self._close()
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# CLOSED (or OPEN, where a result can still arrive from a primary
|
|
139
|
+
# request even though hedging is suppressed -- keep tracking it).
|
|
140
|
+
self._window.record(ok)
|
|
141
|
+
if (
|
|
142
|
+
self._state is CircuitState.CLOSED
|
|
143
|
+
and self._window.sample_count() >= self._config.min_samples
|
|
144
|
+
and self._window.error_rate() >= self._config.error_rate_threshold
|
|
145
|
+
):
|
|
146
|
+
self._open()
|
|
147
|
+
|
|
148
|
+
def allow_hedge(self) -> bool:
|
|
149
|
+
if self._state is CircuitState.CLOSED:
|
|
150
|
+
return True
|
|
151
|
+
if self._state is CircuitState.OPEN:
|
|
152
|
+
if time.monotonic() - self._opened_at >= self._config.cooldown:
|
|
153
|
+
self._enter_half_open()
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
# HALF_OPEN
|
|
157
|
+
return self._half_open_trials < self._config.half_open_max_trial
|
|
158
|
+
|
|
159
|
+
def _open(self) -> None:
|
|
160
|
+
self._state = CircuitState.OPEN
|
|
161
|
+
self._opened_at = time.monotonic()
|
|
162
|
+
if self._on_open is not None:
|
|
163
|
+
self._on_open()
|
|
164
|
+
|
|
165
|
+
def _reopen(self) -> None:
|
|
166
|
+
self._state = CircuitState.OPEN
|
|
167
|
+
self._opened_at = time.monotonic()
|
|
168
|
+
self._half_open_trials = 0
|
|
169
|
+
self._half_open_failures = 0
|
|
170
|
+
if self._on_open is not None:
|
|
171
|
+
self._on_open()
|
|
172
|
+
|
|
173
|
+
def _enter_half_open(self) -> None:
|
|
174
|
+
self._state = CircuitState.HALF_OPEN
|
|
175
|
+
self._half_open_trials = 0
|
|
176
|
+
self._half_open_failures = 0
|
|
177
|
+
|
|
178
|
+
def _close(self) -> None:
|
|
179
|
+
self._state = CircuitState.CLOSED
|
|
180
|
+
self._half_open_trials = 0
|
|
181
|
+
self._half_open_failures = 0
|
|
182
|
+
self._window.reset()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class HealthRegistry:
|
|
186
|
+
"""Owns one ``CircuitBreaker`` per host and one per endpoint key.
|
|
187
|
+
|
|
188
|
+
Both tiers are consulted independently: a host-level trip disables
|
|
189
|
+
hedging for every endpoint on that host, while an endpoint-level trip
|
|
190
|
+
disables hedging only for that endpoint, leaving sibling endpoints on
|
|
191
|
+
the same host unaffected.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
on_circuit_open: Called each time a breaker (host- or
|
|
195
|
+
endpoint-scoped) transitions into the OPEN state, as
|
|
196
|
+
``on_circuit_open(scope, key)`` where ``scope`` is ``"host"``
|
|
197
|
+
or ``"endpoint"`` and ``key`` is the host name or endpoint key
|
|
198
|
+
that tripped. Intended for alerting -- see the README's
|
|
199
|
+
observability section for a logging example.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self, on_circuit_open: Callable[[str, str], None] | None = None
|
|
204
|
+
) -> None:
|
|
205
|
+
self._lock = threading.Lock()
|
|
206
|
+
self._on_circuit_open = on_circuit_open
|
|
207
|
+
self._host_breakers: BoundedRegistry[CircuitBreaker] = BoundedRegistry()
|
|
208
|
+
self._endpoint_breakers: BoundedRegistry[CircuitBreaker] = BoundedRegistry()
|
|
209
|
+
|
|
210
|
+
def breaker_for_host(
|
|
211
|
+
self, host: str, config: CircuitBreakerConfig
|
|
212
|
+
) -> CircuitBreaker:
|
|
213
|
+
with self._lock:
|
|
214
|
+
return self._get_host_locked(host, config)
|
|
215
|
+
|
|
216
|
+
def breaker_for_endpoint(
|
|
217
|
+
self, key: str, config: CircuitBreakerConfig
|
|
218
|
+
) -> CircuitBreaker:
|
|
219
|
+
with self._lock:
|
|
220
|
+
return self._get_endpoint_locked(key, config)
|
|
221
|
+
|
|
222
|
+
def _get_host_locked(
|
|
223
|
+
self, host: str, config: CircuitBreakerConfig
|
|
224
|
+
) -> CircuitBreaker:
|
|
225
|
+
"""Caller must hold ``self._lock``."""
|
|
226
|
+
return self._host_breakers.get_or_create(
|
|
227
|
+
host, lambda: CircuitBreaker(config, self._on_open_callback("host", host))
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _get_endpoint_locked(
|
|
231
|
+
self, key: str, config: CircuitBreakerConfig
|
|
232
|
+
) -> CircuitBreaker:
|
|
233
|
+
"""Caller must hold ``self._lock``."""
|
|
234
|
+
return self._endpoint_breakers.get_or_create(
|
|
235
|
+
key,
|
|
236
|
+
lambda: CircuitBreaker(config, self._on_open_callback("endpoint", key)),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _on_open_callback(self, scope: str, key: str) -> Callable[[], None] | None:
|
|
240
|
+
if self._on_circuit_open is None:
|
|
241
|
+
return None
|
|
242
|
+
callback = self._on_circuit_open
|
|
243
|
+
return lambda: callback(scope, key)
|
|
244
|
+
|
|
245
|
+
def record_result(
|
|
246
|
+
self,
|
|
247
|
+
host: str,
|
|
248
|
+
key: str,
|
|
249
|
+
host_config: CircuitBreakerConfig,
|
|
250
|
+
key_config: CircuitBreakerConfig,
|
|
251
|
+
ok: bool,
|
|
252
|
+
) -> None:
|
|
253
|
+
# CircuitBreaker is documented as not being thread-safe on its own
|
|
254
|
+
# ("callers hold their own lock") -- the lookup AND the mutation
|
|
255
|
+
# below must happen under one lock acquisition, not just the
|
|
256
|
+
# lookup, or concurrent callers can race on the same breaker's
|
|
257
|
+
# internal counters/state transitions.
|
|
258
|
+
with self._lock:
|
|
259
|
+
host_breaker = self._get_host_locked(host, host_config)
|
|
260
|
+
endpoint_breaker = self._get_endpoint_locked(key, key_config)
|
|
261
|
+
host_breaker.record_result(ok)
|
|
262
|
+
endpoint_breaker.record_result(ok)
|
|
263
|
+
|
|
264
|
+
def hedging_allowed(
|
|
265
|
+
self,
|
|
266
|
+
host: str,
|
|
267
|
+
key: str,
|
|
268
|
+
host_config: CircuitBreakerConfig,
|
|
269
|
+
key_config: CircuitBreakerConfig,
|
|
270
|
+
) -> bool:
|
|
271
|
+
with self._lock:
|
|
272
|
+
host_breaker = self._get_host_locked(host, host_config)
|
|
273
|
+
endpoint_breaker = self._get_endpoint_locked(key, key_config)
|
|
274
|
+
return host_breaker.allow_hedge() and endpoint_breaker.allow_hedge()
|
|
275
|
+
|
|
276
|
+
def host_state(self, host: str) -> CircuitState | None:
|
|
277
|
+
with self._lock:
|
|
278
|
+
breaker = self._host_breakers.get(host)
|
|
279
|
+
return breaker.state if breaker else None
|
|
280
|
+
|
|
281
|
+
def endpoint_state(self, key: str) -> CircuitState | None:
|
|
282
|
+
with self._lock:
|
|
283
|
+
breaker = self._endpoint_breakers.get(key)
|
|
284
|
+
return breaker.state if breaker else None
|