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/_matcher.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Endpoint pattern registry: matches requests to per-endpoint hedge config.
|
|
2
|
+
|
|
3
|
+
hedge-python keys its latency sketch per *host* only. A service with many
|
|
4
|
+
routes of very different latency/RPS profiles (e.g. ``GET /api/v1/foo`` vs.
|
|
5
|
+
``GET /api/v1/bar``) would have all of their latencies mixed into one
|
|
6
|
+
sketch. This module lets callers register per-route ``EndpointConfig``
|
|
7
|
+
objects up front, matched against every request's method and path -- all
|
|
8
|
+
still funneled through a single ``httpx.AsyncBaseTransport`` / connection
|
|
9
|
+
pool, so supporting many endpoints does not multiply connection pools the
|
|
10
|
+
way ``httpx`` ``mounts={}`` would.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from httpx_hedged._config import EndpointConfig
|
|
21
|
+
|
|
22
|
+
_SEGMENT_PARAM_RE = re.compile(r"^\{[A-Za-z_]\w*\}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnknownHedgeEndpointError(KeyError):
|
|
26
|
+
"""Raised when a request's ``hedge_endpoint`` extension names an unknown route."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Route:
|
|
31
|
+
"""A registered method + path pattern and the config it maps to.
|
|
32
|
+
|
|
33
|
+
``path_pattern`` segments may contain ``{name}`` placeholders or a bare
|
|
34
|
+
``*`` to match any single path segment (e.g. ``/api/v1/users/{id}``).
|
|
35
|
+
Multi-segment globs are not supported.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
method: str
|
|
39
|
+
path_pattern: str
|
|
40
|
+
config: EndpointConfig
|
|
41
|
+
name: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compile_pattern(path_pattern: str) -> re.Pattern[str]:
|
|
45
|
+
segments = path_pattern.split("/")
|
|
46
|
+
compiled_segments = []
|
|
47
|
+
for segment in segments:
|
|
48
|
+
if segment == "*" or _SEGMENT_PARAM_RE.match(segment):
|
|
49
|
+
compiled_segments.append("[^/]+")
|
|
50
|
+
else:
|
|
51
|
+
compiled_segments.append(re.escape(segment))
|
|
52
|
+
return re.compile("/".join(compiled_segments))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class _CompiledRoute:
|
|
57
|
+
method: str
|
|
58
|
+
regex: re.Pattern[str]
|
|
59
|
+
config: EndpointConfig
|
|
60
|
+
name: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EndpointMatcher:
|
|
64
|
+
"""Registry of routes, matched in registration order (first match wins)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._routes: list[_CompiledRoute] = []
|
|
68
|
+
self._by_name: dict[str, _CompiledRoute] = {}
|
|
69
|
+
|
|
70
|
+
def register(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path_pattern: str,
|
|
74
|
+
config: EndpointConfig,
|
|
75
|
+
*,
|
|
76
|
+
name: str | None = None,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Register a route pattern. Returns the resolved endpoint name.
|
|
79
|
+
|
|
80
|
+
Raises ValueError if ``name`` (or the default derived name) is
|
|
81
|
+
already registered.
|
|
82
|
+
"""
|
|
83
|
+
method = method.upper()
|
|
84
|
+
resolved_name = name if name is not None else f"{method} {path_pattern}"
|
|
85
|
+
if resolved_name in self._by_name:
|
|
86
|
+
raise ValueError(f"endpoint name already registered: {resolved_name!r}")
|
|
87
|
+
|
|
88
|
+
compiled = _CompiledRoute(
|
|
89
|
+
method=method,
|
|
90
|
+
regex=_compile_pattern(path_pattern),
|
|
91
|
+
config=config,
|
|
92
|
+
name=resolved_name,
|
|
93
|
+
)
|
|
94
|
+
self._routes.append(compiled)
|
|
95
|
+
self._by_name[resolved_name] = compiled
|
|
96
|
+
return resolved_name
|
|
97
|
+
|
|
98
|
+
def match(self, request: httpx.Request) -> Route | None:
|
|
99
|
+
"""Match a request to a registered route.
|
|
100
|
+
|
|
101
|
+
If the request carries ``extensions["hedge_endpoint"]``, that name
|
|
102
|
+
is looked up directly (bypassing pattern matching); an unknown name
|
|
103
|
+
raises ``UnknownHedgeEndpointError`` rather than silently falling
|
|
104
|
+
back, so typos fail loudly. Otherwise, routes are tried in
|
|
105
|
+
registration order and the first method+path match wins. Returns
|
|
106
|
+
None if nothing matches.
|
|
107
|
+
"""
|
|
108
|
+
override = request.extensions.get("hedge_endpoint")
|
|
109
|
+
if override is not None:
|
|
110
|
+
compiled = self._by_name.get(override)
|
|
111
|
+
if compiled is None:
|
|
112
|
+
raise UnknownHedgeEndpointError(override)
|
|
113
|
+
return Route(
|
|
114
|
+
compiled.method, compiled.regex.pattern, compiled.config, compiled.name
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
method = request.method.upper()
|
|
118
|
+
path = request.url.path
|
|
119
|
+
for compiled in self._routes:
|
|
120
|
+
if compiled.method not in ("*", method):
|
|
121
|
+
continue
|
|
122
|
+
if compiled.regex.fullmatch(path):
|
|
123
|
+
return Route(
|
|
124
|
+
compiled.method,
|
|
125
|
+
compiled.regex.pattern,
|
|
126
|
+
compiled.config,
|
|
127
|
+
compiled.name,
|
|
128
|
+
)
|
|
129
|
+
return None
|
httpx_hedged/_rate.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Rolling request-rate estimation, used to auto-size the hedge token bucket.
|
|
2
|
+
|
|
3
|
+
hedge-python requires callers to hand-tune a single ``estimated_rps`` value
|
|
4
|
+
per host. That is workable for one host, but this library tracks state per
|
|
5
|
+
*endpoint*, and a service with many endpoints of very different traffic
|
|
6
|
+
volumes would require hand-tuning a guess for each one -- exactly the kind
|
|
7
|
+
of per-endpoint configuration burden this rewrite is trying to avoid. By
|
|
8
|
+
default we estimate the rate automatically instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from httpx_hedged._rotation import RotateAction, next_action
|
|
17
|
+
|
|
18
|
+
_DEFAULT_WINDOW_DURATION = (
|
|
19
|
+
10.0 # seconds; shorter than the latency sketch window since RPS shifts faster
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RollingRateCounter:
|
|
24
|
+
"""Estimates requests-per-second over a rolling window.
|
|
25
|
+
|
|
26
|
+
Uses the same lazy current/previous rotation scheme as
|
|
27
|
+
``WindowedSketch``, but counts requests rather than latencies.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, window_duration: float = _DEFAULT_WINDOW_DURATION) -> None:
|
|
31
|
+
if window_duration <= 0:
|
|
32
|
+
window_duration = _DEFAULT_WINDOW_DURATION
|
|
33
|
+
self._window_duration = window_duration
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._current = 0
|
|
36
|
+
self._previous = 0
|
|
37
|
+
self._window_start = time.monotonic()
|
|
38
|
+
|
|
39
|
+
def _maybe_rotate_locked(self) -> None:
|
|
40
|
+
now = time.monotonic()
|
|
41
|
+
action = next_action(self._window_start, self._window_duration, now)
|
|
42
|
+
if action is RotateAction.NONE:
|
|
43
|
+
return
|
|
44
|
+
if action is RotateAction.ROTATE:
|
|
45
|
+
self._previous = self._current
|
|
46
|
+
self._current = 0
|
|
47
|
+
else: # RESET
|
|
48
|
+
self._previous = 0
|
|
49
|
+
self._current = 0
|
|
50
|
+
self._window_start = now
|
|
51
|
+
|
|
52
|
+
def increment(self) -> None:
|
|
53
|
+
"""Record one request."""
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._maybe_rotate_locked()
|
|
56
|
+
self._current += 1
|
|
57
|
+
|
|
58
|
+
def rate_per_second(self) -> float:
|
|
59
|
+
"""Return the estimated requests-per-second, as a sliding-window average.
|
|
60
|
+
|
|
61
|
+
Weights the previous window's count by the fraction of it that
|
|
62
|
+
still falls within the trailing ``window_duration``-sized lookback
|
|
63
|
+
from now, rather than always dividing by a fixed ``2 *
|
|
64
|
+
window_duration`` -- that fixed divisor systematically
|
|
65
|
+
underestimates by up to 2x right after a rotation, when the
|
|
66
|
+
current window has accumulated almost nothing yet but the full
|
|
67
|
+
previous window's count is still discounted as if it were only
|
|
68
|
+
half-relevant.
|
|
69
|
+
"""
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._maybe_rotate_locked()
|
|
72
|
+
elapsed = time.monotonic() - self._window_start
|
|
73
|
+
weight = max(0.0, 1.0 - elapsed / self._window_duration)
|
|
74
|
+
weighted = self._previous * weight + self._current
|
|
75
|
+
return weighted / self._window_duration
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared lazy, pull-based window-rotation decision logic.
|
|
2
|
+
|
|
3
|
+
Every windowed structure in this package (``WindowedSketch``,
|
|
4
|
+
``RollingRateCounter``, the circuit breaker's ``_ErrorWindow``) holds a
|
|
5
|
+
current/previous pair of accumulators and decides whether to rotate or reset
|
|
6
|
+
them lazily, on the next call that touches the structure, rather than via a
|
|
7
|
+
background thread or asyncio task. This avoids spinning one rotation task per
|
|
8
|
+
tracked key -- important once state is tracked per-endpoint rather than just
|
|
9
|
+
per-host, since a service can have dozens of endpoints.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import Enum, auto
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RotateAction(Enum):
|
|
18
|
+
"""What a windowed structure should do given elapsed time since rotation."""
|
|
19
|
+
|
|
20
|
+
NONE = auto()
|
|
21
|
+
ROTATE = auto()
|
|
22
|
+
RESET = auto()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def next_action(
|
|
26
|
+
window_start: float, window_duration: float, now: float
|
|
27
|
+
) -> RotateAction:
|
|
28
|
+
"""Decide whether a window should rotate or reset given elapsed time.
|
|
29
|
+
|
|
30
|
+
- Less than one ``window_duration`` has elapsed: do nothing.
|
|
31
|
+
- Between one and two window durations: rotate (current becomes
|
|
32
|
+
previous, a fresh current begins).
|
|
33
|
+
- Two or more window durations have elapsed: the structure has been idle
|
|
34
|
+
long enough that "previous" data is stale too, so reset entirely
|
|
35
|
+
rather than rotate.
|
|
36
|
+
"""
|
|
37
|
+
elapsed = now - window_start
|
|
38
|
+
if elapsed < window_duration:
|
|
39
|
+
return RotateAction.NONE
|
|
40
|
+
if elapsed < 2 * window_duration:
|
|
41
|
+
return RotateAction.ROTATE
|
|
42
|
+
return RotateAction.RESET
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Core async hedge scheduling: per-key state and the race-then-cancel logic.
|
|
2
|
+
|
|
3
|
+
Adapted from hedge-python's ``HedgeScheduler`` (``transport/_base.py``), with
|
|
4
|
+
two changes: state is keyed per *endpoint* rather than only per *host* (see
|
|
5
|
+
``_matcher.py``), and the win/lose bookkeeping always records the outcome
|
|
6
|
+
(latency + health) even when the winning task raised an exception. In
|
|
7
|
+
hedge-python, ``winner_task.result()`` is called before recording, so an
|
|
8
|
+
exception there silently skips recording entirely -- this rewrite records
|
|
9
|
+
first, then re-raises.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import math
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
19
|
+
from typing import Any, TypeVar, cast
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
from httpx_hedged._bounded import BoundedRegistry
|
|
23
|
+
from httpx_hedged._config import CircuitBreakerConfig, EffectiveConfig
|
|
24
|
+
from httpx_hedged._health import HealthRegistry
|
|
25
|
+
from httpx_hedged._rate import RollingRateCounter
|
|
26
|
+
from httpx_hedged._stats import Stats, StatsRegistry
|
|
27
|
+
from httpx_hedged.budget import TokenBucket
|
|
28
|
+
from httpx_hedged.sketch import WindowedSketch
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
_DEFAULT_RPS_SEED = 100.0
|
|
33
|
+
_SKETCH_RELATIVE_ACCURACY = 0.01
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_host(url: str) -> str:
|
|
37
|
+
"""Extract a sanitized host key from a URL.
|
|
38
|
+
|
|
39
|
+
Returns ``hostname:port`` (when port is present) or just ``hostname``,
|
|
40
|
+
stripping any userinfo (``user:pass@``) to avoid retaining credentials
|
|
41
|
+
in per-host keys. IPv6 addresses are bracket-wrapped when a port is
|
|
42
|
+
present to keep the key unambiguous (e.g. ``[::1]:8080``).
|
|
43
|
+
"""
|
|
44
|
+
parsed = urlparse(url)
|
|
45
|
+
hostname = parsed.hostname or ""
|
|
46
|
+
try:
|
|
47
|
+
port = parsed.port
|
|
48
|
+
except ValueError:
|
|
49
|
+
port = None
|
|
50
|
+
if port is not None:
|
|
51
|
+
if ":" in hostname:
|
|
52
|
+
return f"[{hostname}]:{port}"
|
|
53
|
+
return f"{hostname}:{port}"
|
|
54
|
+
return hostname or url
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _EndpointState:
|
|
58
|
+
"""Per-key (per-endpoint or per-host-fallback) hedge state."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, config: EffectiveConfig, stats: Stats) -> None:
|
|
61
|
+
self.config = config
|
|
62
|
+
self.sketch = WindowedSketch(
|
|
63
|
+
relative_accuracy=_SKETCH_RELATIVE_ACCURACY,
|
|
64
|
+
window_duration=config.window_duration,
|
|
65
|
+
)
|
|
66
|
+
self.counter = 0
|
|
67
|
+
if config.estimated_rps is not None:
|
|
68
|
+
self.token_bucket = TokenBucket(config.budget_percent, config.estimated_rps)
|
|
69
|
+
self.rate_counter: RollingRateCounter | None = None
|
|
70
|
+
else:
|
|
71
|
+
self.token_bucket = TokenBucket(config.budget_percent, _DEFAULT_RPS_SEED)
|
|
72
|
+
self.rate_counter = RollingRateCounter(config.rps_window_duration)
|
|
73
|
+
self.stats = stats
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HedgeScheduler:
|
|
77
|
+
"""Shared async hedge scheduling logic, used by ``HedgedTransport``.
|
|
78
|
+
|
|
79
|
+
Manages per-key sketches, warmup counters, rate estimation, token
|
|
80
|
+
bucket budget, and the race-then-cancel logic. Consults the shared
|
|
81
|
+
``HealthRegistry`` to suppress hedging (never the primary request)
|
|
82
|
+
while a host or endpoint circuit breaker is open.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
health: Shared circuit-breaker registry.
|
|
86
|
+
stats_registry: Shared per-key statistics registry.
|
|
87
|
+
host_circuit_breaker: Circuit-breaker configuration used for the
|
|
88
|
+
*host* tier, independent of whichever endpoint's config happens
|
|
89
|
+
to be resolved for a given request -- a host isn't owned by any
|
|
90
|
+
one endpoint, so its breaker thresholds must not depend on
|
|
91
|
+
request arrival order (see ``_should_hedge``/``_finish``, which
|
|
92
|
+
always pass this rather than the per-request resolved config
|
|
93
|
+
for the host side of ``HealthRegistry`` calls).
|
|
94
|
+
on_hedge_fired: Called with the key each time a hedge request is
|
|
95
|
+
actually launched (after all gates -- idempotency, circuit
|
|
96
|
+
breaker, budget -- have passed). Intended for metrics -- see
|
|
97
|
+
the README's observability section for an example.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
health: HealthRegistry,
|
|
103
|
+
stats_registry: StatsRegistry,
|
|
104
|
+
host_circuit_breaker: CircuitBreakerConfig | None = None,
|
|
105
|
+
on_hedge_fired: Callable[[str], None] | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._health = health
|
|
108
|
+
self._stats_registry = stats_registry
|
|
109
|
+
self._host_circuit_breaker = host_circuit_breaker or CircuitBreakerConfig()
|
|
110
|
+
self._on_hedge_fired = on_hedge_fired
|
|
111
|
+
self._states: BoundedRegistry[_EndpointState] = BoundedRegistry()
|
|
112
|
+
|
|
113
|
+
def state_for(self, key: str, config: EffectiveConfig) -> _EndpointState:
|
|
114
|
+
"""Get or create the state for a key. ``config`` is only used on creation."""
|
|
115
|
+
return self._states.get_or_create(
|
|
116
|
+
key, lambda: _EndpointState(config, self._stats_registry.for_key(key))
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def compute_hedge_delay(self, state: _EndpointState) -> float:
|
|
120
|
+
"""Compute the hedge delay in seconds for the current request on this key."""
|
|
121
|
+
config = state.config
|
|
122
|
+
if config.is_hardcoded:
|
|
123
|
+
assert config.hedge_delay is not None
|
|
124
|
+
return max(config.hedge_delay, config.min_delay)
|
|
125
|
+
|
|
126
|
+
if state.counter <= config.warmup_requests:
|
|
127
|
+
state.stats.increment_warmup()
|
|
128
|
+
delay = config.warmup_delay
|
|
129
|
+
else:
|
|
130
|
+
estimate = state.sketch.quantile(config.percentile)
|
|
131
|
+
delay = (
|
|
132
|
+
estimate
|
|
133
|
+
if estimate > 0 and not math.isnan(estimate)
|
|
134
|
+
else config.warmup_delay
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return max(delay, config.min_delay)
|
|
138
|
+
|
|
139
|
+
async def execute_with_hedge(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
key: str,
|
|
143
|
+
host: str,
|
|
144
|
+
config: EffectiveConfig,
|
|
145
|
+
primary_func: Callable[[], Awaitable[T]],
|
|
146
|
+
hedge_func: Callable[[], Awaitable[T]],
|
|
147
|
+
classify: Callable[[T], bool],
|
|
148
|
+
can_hedge: bool,
|
|
149
|
+
discard: Callable[[T], Awaitable[None]] | None = None,
|
|
150
|
+
) -> T:
|
|
151
|
+
"""Execute the primary request with hedge racing logic.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
key: Per-endpoint (or per-host-fallback) key for sketch/budget/health
|
|
155
|
+
tracking.
|
|
156
|
+
host: Host key, used for the host-level circuit breaker tier.
|
|
157
|
+
config: Resolved config for this key.
|
|
158
|
+
primary_func: Async callable that performs the primary request.
|
|
159
|
+
hedge_func: Async callable that performs the hedge request.
|
|
160
|
+
classify: Callable that returns True if a completed result should
|
|
161
|
+
count as a success for circuit-breaker purposes.
|
|
162
|
+
can_hedge: Whether the request is safe to hedge (idempotent).
|
|
163
|
+
discard: Optional async callable used to release a losing
|
|
164
|
+
task's already-completed result (e.g. closing an
|
|
165
|
+
``httpx.Response`` to release its pooled connection) when
|
|
166
|
+
the primary and hedge happen to finish in the same
|
|
167
|
+
event-loop pass.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The result from whichever request finishes first.
|
|
171
|
+
"""
|
|
172
|
+
state = self.state_for(key, config)
|
|
173
|
+
state.stats.increment_total()
|
|
174
|
+
|
|
175
|
+
state.counter += 1
|
|
176
|
+
if state.rate_counter is not None:
|
|
177
|
+
state.rate_counter.increment()
|
|
178
|
+
state.token_bucket.set_rps(state.rate_counter.rate_per_second())
|
|
179
|
+
|
|
180
|
+
hedge_delay = self.compute_hedge_delay(state)
|
|
181
|
+
start = time.monotonic()
|
|
182
|
+
|
|
183
|
+
primary_task: asyncio.Task[T] = asyncio.create_task(
|
|
184
|
+
cast("Coroutine[Any, Any, T]", primary_func())
|
|
185
|
+
)
|
|
186
|
+
hedge_task: asyncio.Task[T] | None = None
|
|
187
|
+
tasks = {primary_task}
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
done, _ = await asyncio.wait(tasks, timeout=hedge_delay)
|
|
191
|
+
if not done:
|
|
192
|
+
if self._should_hedge(state, host, key, can_hedge):
|
|
193
|
+
state.stats.increment_hedged()
|
|
194
|
+
if self._on_hedge_fired is not None:
|
|
195
|
+
self._on_hedge_fired(key)
|
|
196
|
+
hedge_task = asyncio.create_task(
|
|
197
|
+
cast("Coroutine[Any, Any, T]", hedge_func())
|
|
198
|
+
)
|
|
199
|
+
tasks.add(hedge_task)
|
|
200
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
201
|
+
|
|
202
|
+
winner_task = (
|
|
203
|
+
primary_task
|
|
204
|
+
if primary_task in done
|
|
205
|
+
else cast("asyncio.Task[T]", hedge_task)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if hedge_task is not None:
|
|
209
|
+
if winner_task is primary_task:
|
|
210
|
+
state.stats.increment_primary_wins()
|
|
211
|
+
else:
|
|
212
|
+
state.stats.increment_hedge_wins()
|
|
213
|
+
loser_task = hedge_task if winner_task is primary_task else primary_task
|
|
214
|
+
if not loser_task.done():
|
|
215
|
+
loser_task.cancel()
|
|
216
|
+
await asyncio.wait({loser_task})
|
|
217
|
+
await self._discard(loser_task, discard)
|
|
218
|
+
|
|
219
|
+
return await self._finish(state, host, key, winner_task, start, classify)
|
|
220
|
+
finally:
|
|
221
|
+
# Reached on the happy path too, where every task is already
|
|
222
|
+
# done and this is a no-op -- but if this coroutine itself is
|
|
223
|
+
# cancelled (e.g. the caller wrapped the request in a timeout)
|
|
224
|
+
# while blocked on one of the awaits above, asyncio.wait does
|
|
225
|
+
# not cancel the tasks it was waiting on, so they'd otherwise
|
|
226
|
+
# keep running detached, holding a pooled connection open.
|
|
227
|
+
pending = {task for task in tasks if not task.done()}
|
|
228
|
+
for task in pending:
|
|
229
|
+
task.cancel()
|
|
230
|
+
if pending:
|
|
231
|
+
await asyncio.wait(pending)
|
|
232
|
+
|
|
233
|
+
def _should_hedge(
|
|
234
|
+
self, state: _EndpointState, host: str, key: str, can_hedge: bool
|
|
235
|
+
) -> bool:
|
|
236
|
+
"""Check the hedge gates: idempotency, circuit breaker, then budget."""
|
|
237
|
+
if not can_hedge:
|
|
238
|
+
return False
|
|
239
|
+
if not self._health.hedging_allowed(
|
|
240
|
+
host, key, self._host_circuit_breaker, state.config.circuit_breaker
|
|
241
|
+
):
|
|
242
|
+
state.stats.increment_circuit_blocked()
|
|
243
|
+
return False
|
|
244
|
+
if not state.token_bucket.try_acquire():
|
|
245
|
+
state.stats.increment_budget_exhausted()
|
|
246
|
+
return False
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
async def _discard(
|
|
250
|
+
self, task: asyncio.Task[T], discard: Callable[[T], Awaitable[None]] | None
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Retrieve a losing task's outcome so it neither leaks a resource nor
|
|
253
|
+
logs "Task exception was never retrieved", whether it was cancelled
|
|
254
|
+
mid-flight or had already completed (primary and hedge finishing in
|
|
255
|
+
the same event-loop pass)."""
|
|
256
|
+
if task.cancelled():
|
|
257
|
+
return
|
|
258
|
+
exc = task.exception()
|
|
259
|
+
if exc is not None:
|
|
260
|
+
return
|
|
261
|
+
if discard is not None:
|
|
262
|
+
with contextlib.suppress(Exception):
|
|
263
|
+
await discard(task.result())
|
|
264
|
+
|
|
265
|
+
async def _finish(
|
|
266
|
+
self,
|
|
267
|
+
state: _EndpointState,
|
|
268
|
+
host: str,
|
|
269
|
+
key: str,
|
|
270
|
+
winner_task: asyncio.Task[T],
|
|
271
|
+
start: float,
|
|
272
|
+
classify: Callable[[T], bool],
|
|
273
|
+
) -> T:
|
|
274
|
+
"""Record latency and health outcome, then return the result or re-raise.
|
|
275
|
+
|
|
276
|
+
Recording always happens before the result is returned or the
|
|
277
|
+
exception is re-raised -- unlike hedge-python, where an exception
|
|
278
|
+
from ``winner_task.result()`` silently skips recording.
|
|
279
|
+
"""
|
|
280
|
+
elapsed = time.monotonic() - start
|
|
281
|
+
breaker_cfg = state.config.circuit_breaker
|
|
282
|
+
try:
|
|
283
|
+
result = winner_task.result()
|
|
284
|
+
except Exception:
|
|
285
|
+
state.sketch.add(elapsed)
|
|
286
|
+
self._health.record_result(
|
|
287
|
+
host, key, self._host_circuit_breaker, breaker_cfg, False
|
|
288
|
+
)
|
|
289
|
+
state.stats.increment_errors()
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
state.sketch.add(elapsed)
|
|
293
|
+
ok = classify(result)
|
|
294
|
+
self._health.record_result(
|
|
295
|
+
host, key, self._host_circuit_breaker, breaker_cfg, ok
|
|
296
|
+
)
|
|
297
|
+
if not ok:
|
|
298
|
+
state.stats.increment_errors()
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
def close(self) -> None:
|
|
302
|
+
"""Noop."""
|