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.
@@ -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."""