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/_stats.py ADDED
@@ -0,0 +1,160 @@
1
+ """Thread-safe, per-key statistics for hedge operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass
7
+
8
+ from httpx_hedged._bounded import BoundedRegistry
9
+
10
+
11
+ class Stats:
12
+ """Thread-safe counters for hedge operations on a single key.
13
+
14
+ All fields use a lock for atomic updates and are safe to read concurrently.
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ self._lock = threading.Lock()
19
+ self.total_requests: int = 0
20
+ self.hedged_requests: int = 0
21
+ self.hedge_wins: int = 0
22
+ self.primary_wins: int = 0
23
+ self.budget_exhausted: int = 0
24
+ self.warmup_requests: int = 0
25
+ self.circuit_blocked: int = 0
26
+ self.errors: int = 0
27
+
28
+ def increment_total(self) -> None:
29
+ with self._lock:
30
+ self.total_requests += 1
31
+
32
+ def increment_hedged(self) -> None:
33
+ with self._lock:
34
+ self.hedged_requests += 1
35
+
36
+ def increment_hedge_wins(self) -> None:
37
+ with self._lock:
38
+ self.hedge_wins += 1
39
+
40
+ def increment_primary_wins(self) -> None:
41
+ with self._lock:
42
+ self.primary_wins += 1
43
+
44
+ def increment_budget_exhausted(self) -> None:
45
+ with self._lock:
46
+ self.budget_exhausted += 1
47
+
48
+ def increment_warmup(self) -> None:
49
+ with self._lock:
50
+ self.warmup_requests += 1
51
+
52
+ def increment_circuit_blocked(self) -> None:
53
+ with self._lock:
54
+ self.circuit_blocked += 1
55
+
56
+ def increment_errors(self) -> None:
57
+ with self._lock:
58
+ self.errors += 1
59
+
60
+ def snapshot(self, key: str = "") -> StatsSnapshot:
61
+ """Take a consistent point-in-time copy of all counters."""
62
+ with self._lock:
63
+ return StatsSnapshot(
64
+ key=key,
65
+ total_requests=self.total_requests,
66
+ hedged_requests=self.hedged_requests,
67
+ hedge_wins=self.hedge_wins,
68
+ primary_wins=self.primary_wins,
69
+ budget_exhausted=self.budget_exhausted,
70
+ warmup_requests=self.warmup_requests,
71
+ circuit_blocked=self.circuit_blocked,
72
+ errors=self.errors,
73
+ )
74
+
75
+ def hedge_rate(self) -> float:
76
+ """Return hedged_requests / total_requests, or 0.0 if no requests."""
77
+ with self._lock:
78
+ if self.total_requests == 0:
79
+ return 0.0
80
+ return self.hedged_requests / self.total_requests
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class StatsSnapshot:
85
+ """Immutable point-in-time snapshot of Stats for a single key."""
86
+
87
+ key: str
88
+ total_requests: int
89
+ hedged_requests: int
90
+ hedge_wins: int
91
+ primary_wins: int
92
+ budget_exhausted: int
93
+ warmup_requests: int
94
+ circuit_blocked: int
95
+ errors: int
96
+
97
+ def __add__(self, other: StatsSnapshot) -> StatsSnapshot:
98
+ return StatsSnapshot(
99
+ key="*",
100
+ total_requests=self.total_requests + other.total_requests,
101
+ hedged_requests=self.hedged_requests + other.hedged_requests,
102
+ hedge_wins=self.hedge_wins + other.hedge_wins,
103
+ primary_wins=self.primary_wins + other.primary_wins,
104
+ budget_exhausted=self.budget_exhausted + other.budget_exhausted,
105
+ warmup_requests=self.warmup_requests + other.warmup_requests,
106
+ circuit_blocked=self.circuit_blocked + other.circuit_blocked,
107
+ errors=self.errors + other.errors,
108
+ )
109
+
110
+
111
+ _EMPTY_SNAPSHOT = StatsSnapshot(
112
+ key="*",
113
+ total_requests=0,
114
+ hedged_requests=0,
115
+ hedge_wins=0,
116
+ primary_wins=0,
117
+ budget_exhausted=0,
118
+ warmup_requests=0,
119
+ circuit_blocked=0,
120
+ errors=0,
121
+ )
122
+
123
+
124
+ class StatsRegistry:
125
+ """Owns one ``Stats`` object per tracked key plus a global aggregate view.
126
+
127
+ hedge-python only ever exposes a single global ``Stats`` object; since
128
+ this library tracks hedge behavior per endpoint, per-key breakdown is
129
+ the point of observability here.
130
+ """
131
+
132
+ def __init__(self) -> None:
133
+ self._lock = threading.Lock()
134
+ self._stats: BoundedRegistry[Stats] = BoundedRegistry()
135
+
136
+ def for_key(self, key: str) -> Stats:
137
+ """Get or create the ``Stats`` object for a key."""
138
+ with self._lock:
139
+ return self._stats.get_or_create(key, Stats)
140
+
141
+ def snapshot(self, key: str) -> StatsSnapshot | None:
142
+ """Return a snapshot for a key, or None if the key has no recorded stats."""
143
+ with self._lock:
144
+ stats = self._stats.get(key)
145
+ if stats is None:
146
+ return None
147
+ return stats.snapshot(key)
148
+
149
+ def all_snapshots(self) -> dict[str, StatsSnapshot]:
150
+ """Return a snapshot for every tracked key."""
151
+ with self._lock:
152
+ items = list(self._stats.items())
153
+ return {key: stats.snapshot(key) for key, stats in items}
154
+
155
+ def global_snapshot(self) -> StatsSnapshot:
156
+ """Return the sum of every tracked key's stats."""
157
+ total = _EMPTY_SNAPSHOT
158
+ for snapshot in self.all_snapshots().values():
159
+ total = total + snapshot
160
+ return total
@@ -0,0 +1,7 @@
1
+ """Hedge-rate budget primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from httpx_hedged.budget._token_bucket import TokenBucket
6
+
7
+ __all__ = ["TokenBucket"]
@@ -0,0 +1,54 @@
1
+ """Token bucket algorithm for controlling hedge request rate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+
8
+
9
+ class TokenBucket:
10
+ """Controls hedge request rate using a token bucket algorithm.
11
+
12
+ The bucket refills at ``estimated_rps * budget_percent / 100`` tokens per
13
+ second. During genuine outages the bucket drains and hedging stops,
14
+ preventing the load-doubling spiral that would deepen the incident.
15
+
16
+ Args:
17
+ budget_percent: Max hedge rate as percent of total traffic.
18
+ estimated_rps: Expected requests per second.
19
+ """
20
+
21
+ def __init__(
22
+ self, budget_percent: float = 10.0, estimated_rps: float = 100.0
23
+ ) -> None:
24
+ self._lock = threading.Lock()
25
+ self._budget_percent = budget_percent
26
+ self._rate = estimated_rps * (budget_percent / 100.0)
27
+ self._max_burst = max(self._rate * 2, 1.0)
28
+ self._tokens = self._max_burst
29
+ self._last_refill = time.monotonic()
30
+
31
+ def try_acquire(self) -> bool:
32
+ """Return True if a hedge token is available, False if over budget."""
33
+ with self._lock:
34
+ now = time.monotonic()
35
+ elapsed = now - self._last_refill
36
+ self._last_refill = now
37
+
38
+ self._tokens += elapsed * self._rate
39
+ if self._tokens > self._max_burst:
40
+ self._tokens = self._max_burst
41
+
42
+ if self._tokens < 1.0:
43
+ return False
44
+ self._tokens -= 1.0
45
+ return True
46
+
47
+ def set_rps(self, rps: float) -> None:
48
+ """Update the hedge rate as traffic changes, preserving the budget %."""
49
+ with self._lock:
50
+ self._rate = rps * (self._budget_percent / 100.0)
51
+ max_burst = max(self._rate * 2, 1.0)
52
+ self._max_burst = max_burst
53
+ if self._tokens > self._max_burst:
54
+ self._tokens = self._max_burst
@@ -0,0 +1,8 @@
1
+ """Quantile-sketch primitives used to estimate per-key latency percentiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from httpx_hedged.sketch._ddsketch import DDSketch
6
+ from httpx_hedged.sketch._windowed import WindowedSketch
7
+
8
+ __all__ = ["DDSketch", "WindowedSketch"]
@@ -0,0 +1,188 @@
1
+ """DDSketch streaming quantile estimator.
2
+
3
+ Based on Masson et al., "DDSketch: A fast and fully-mergeable quantile sketch
4
+ with relative-error guarantees", VLDB 2019. Adapted from hedge-python
5
+ (https://github.com/sunhailin-Leo/hedge-python).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections import defaultdict
12
+
13
+
14
+ class _LogMapping:
15
+ """Maps values to bucket indices using logarithmic scaling.
16
+
17
+ For a given relative accuracy alpha, gamma = (1+alpha)/(1-alpha).
18
+ A positive value x maps to bucket index ceil(ln(x) / ln(gamma)).
19
+
20
+ The guarantee: any value in bucket i is within a factor of gamma^0.5 of
21
+ the bucket's representative value, giving relative error <= alpha.
22
+ """
23
+
24
+ __slots__ = ("gamma", "multiplier")
25
+
26
+ def __init__(self, relative_accuracy: float) -> None:
27
+ self.gamma = (1 + relative_accuracy) / (1 - relative_accuracy)
28
+ self.multiplier = 1.0 / math.log(self.gamma)
29
+
30
+ def index(self, value: float) -> int:
31
+ """Return the bucket index for a strictly positive value."""
32
+ return math.ceil(math.log(value) * self.multiplier)
33
+
34
+ def value(self, index: int) -> float:
35
+ """Return the representative value (geometric midpoint) for a bucket."""
36
+ return math.exp((index - 0.5) / self.multiplier)
37
+
38
+
39
+ class _Store:
40
+ """Sparse map of bucket indices to cumulative counts with lazy sorted key cache."""
41
+
42
+ __slots__ = ("_sorted_keys_cache", "bins", "count")
43
+
44
+ def __init__(self) -> None:
45
+ self.bins: dict[int, float] = defaultdict(float)
46
+ self.count: float = 0.0
47
+ self._sorted_keys_cache: list[int] | None = None
48
+
49
+ @property
50
+ def sorted_keys(self) -> list[int]:
51
+ """Return sorted bin keys, building from cache when possible."""
52
+ if self._sorted_keys_cache is None:
53
+ self._sorted_keys_cache = sorted(self.bins.keys())
54
+ return self._sorted_keys_cache
55
+
56
+ def add(self, index: int) -> None:
57
+ if index not in self.bins:
58
+ self._sorted_keys_cache = None
59
+ self.bins[index] += 1.0
60
+ self.count += 1.0
61
+
62
+ def merge(self, other: _Store) -> None:
63
+ for idx, cnt in other.bins.items():
64
+ self.bins[idx] += cnt
65
+ self.count += other.count
66
+ self._sorted_keys_cache = None
67
+
68
+ def reset(self) -> None:
69
+ self.bins = defaultdict(float)
70
+ self.count = 0.0
71
+ self._sorted_keys_cache = None
72
+
73
+
74
+ class DDSketch:
75
+ """Streaming quantile sketch with relative-error guarantees.
76
+
77
+ Positive and negative values are stored in separate sparse bucket maps.
78
+ Zero values are counted separately. Min and max are tracked exactly.
79
+
80
+ Property: for any quantile q, the returned estimate satisfies
81
+ |estimate - true_value| / |true_value| <= relative_accuracy
82
+
83
+ Args:
84
+ relative_accuracy: Target relative accuracy. 0.01 means estimates
85
+ are within +/-1% of the true value. Must be in (0, 1).
86
+ """
87
+
88
+ def __init__(self, relative_accuracy: float = 0.01) -> None:
89
+ if not (0 < relative_accuracy < 1):
90
+ raise ValueError("relative_accuracy must be in (0, 1)")
91
+ self._mapping = _LogMapping(relative_accuracy)
92
+ self._positive = _Store()
93
+ self._negative = _Store()
94
+ self._zero_count: float = 0.0
95
+ self._count: int = 0
96
+ self._min: float = math.inf
97
+ self._max: float = -math.inf
98
+
99
+ @property
100
+ def count(self) -> int:
101
+ """Total number of values added."""
102
+ return self._count
103
+
104
+ def add(self, value: float) -> None:
105
+ """Record a single value. O(1) per insert.
106
+
107
+ NaN and infinite values are silently ignored.
108
+ """
109
+ if math.isnan(value) or math.isinf(value):
110
+ return
111
+
112
+ if value > 0:
113
+ self._positive.add(self._mapping.index(value))
114
+ elif value < 0:
115
+ self._negative.add(self._mapping.index(-value))
116
+ else:
117
+ self._zero_count += 1.0
118
+
119
+ self._count += 1
120
+ if value < self._min:
121
+ self._min = value
122
+ if value > self._max:
123
+ self._max = value
124
+
125
+ def quantile(self, q: float) -> float:
126
+ """Return the estimated value at quantile q in [0, 1].
127
+
128
+ Returns math.nan if the sketch is empty.
129
+
130
+ The estimate satisfies the relative-error guarantee:
131
+ |estimate - true_value| / |true_value| <= relative_accuracy
132
+ """
133
+ if self._count == 0:
134
+ return math.nan
135
+ if q <= 0:
136
+ return self._min
137
+ if q >= 1:
138
+ return self._max
139
+
140
+ rank: float = float(math.ceil(q * self._count))
141
+
142
+ # Negative values: iterate descending (most negative -> least negative)
143
+ if self._negative.count > 0:
144
+ cumulative = 0.0
145
+ neg_bins = self._negative.bins
146
+ for idx in reversed(self._negative.sorted_keys):
147
+ cumulative += neg_bins[idx]
148
+ if cumulative >= rank:
149
+ return -self._mapping.value(idx)
150
+ rank -= self._negative.count
151
+
152
+ # Zero values
153
+ if self._zero_count > 0:
154
+ rank -= self._zero_count
155
+ if rank <= 0:
156
+ return 0.0
157
+
158
+ # Positive values: iterate ascending (least positive -> most positive)
159
+ if self._positive.count > 0:
160
+ cumulative = 0.0
161
+ pos_bins = self._positive.bins
162
+ for idx in self._positive.sorted_keys:
163
+ cumulative += pos_bins[idx]
164
+ if cumulative >= rank:
165
+ return self._mapping.value(idx)
166
+
167
+ return self._max
168
+
169
+ def merge(self, other: DDSketch) -> None:
170
+ """Combine other into self. The merge is exact: no error accumulates."""
171
+ self._positive.merge(other._positive)
172
+ self._negative.merge(other._negative)
173
+ self._zero_count += other._zero_count
174
+ self._count += other._count
175
+ if other._count > 0:
176
+ if other._min < self._min:
177
+ self._min = other._min
178
+ if other._max > self._max:
179
+ self._max = other._max
180
+
181
+ def reset(self) -> None:
182
+ """Clear all state. Used for windowed / tumbling-window decay."""
183
+ self._positive.reset()
184
+ self._negative.reset()
185
+ self._zero_count = 0.0
186
+ self._count = 0
187
+ self._min = math.inf
188
+ self._max = -math.inf
@@ -0,0 +1,92 @@
1
+ """WindowedSketch: sliding-window quantile estimation over DDSketch pairs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import threading
7
+ import time
8
+
9
+ from httpx_hedged._rotation import RotateAction, next_action
10
+ from httpx_hedged.sketch._ddsketch import DDSketch
11
+
12
+ _DEFAULT_WINDOW_DURATION = 30.0 # seconds
13
+
14
+
15
+ class WindowedSketch:
16
+ """Maintains a sliding window over two DDSketches that rotate lazily.
17
+
18
+ Quantile queries merge both sketches, giving a window that spans 1x to
19
+ 2x the configured duration. ``add`` always writes to the current
20
+ sketch. Rotation is decided lazily on each call rather than by a
21
+ background thread/task -- see ``httpx_hedged._rotation`` -- since a
22
+ service can have many independently-tracked endpoints, and spinning one
23
+ rotation task per endpoint does not scale.
24
+
25
+ The rotation scheme::
26
+
27
+ t=0: current=A, previous=empty
28
+ t=30: current=B, previous=A (A covers [0,30))
29
+ t=60: current=C, previous=B (A is dropped)
30
+ t=90+ (idle): hard reset, both sketches emptied
31
+
32
+ This class is thread-safe.
33
+
34
+ Args:
35
+ relative_accuracy: DDSketch relative accuracy (default: 0.01).
36
+ window_duration: Rotation interval in seconds (default: 30.0).
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ relative_accuracy: float = 0.01,
42
+ window_duration: float = _DEFAULT_WINDOW_DURATION,
43
+ ) -> None:
44
+ if window_duration <= 0:
45
+ window_duration = _DEFAULT_WINDOW_DURATION
46
+ self._relative_accuracy = relative_accuracy
47
+ self._window_duration = window_duration
48
+ self._lock = threading.Lock()
49
+ self._current = DDSketch(relative_accuracy)
50
+ self._previous = DDSketch(relative_accuracy)
51
+ self._window_start = time.monotonic()
52
+
53
+ def _maybe_rotate_locked(self) -> None:
54
+ """Rotate or reset if enough time has passed. Caller must hold the lock."""
55
+ now = time.monotonic()
56
+ action = next_action(self._window_start, self._window_duration, now)
57
+ if action is RotateAction.NONE:
58
+ return
59
+ if action is RotateAction.ROTATE:
60
+ self._previous = self._current
61
+ self._current = DDSketch(self._relative_accuracy)
62
+ else: # RESET
63
+ self._previous = DDSketch(self._relative_accuracy)
64
+ self._current = DDSketch(self._relative_accuracy)
65
+ self._window_start = now
66
+
67
+ def add(self, value: float) -> None:
68
+ """Record a latency sample (in seconds) to the current sketch."""
69
+ with self._lock:
70
+ self._maybe_rotate_locked()
71
+ self._current.add(value)
72
+
73
+ def quantile(self, q: float) -> float:
74
+ """Return the estimated quantile q in [0, 1] over the sliding window.
75
+
76
+ Returns math.nan if no data has been recorded.
77
+ """
78
+ with self._lock:
79
+ self._maybe_rotate_locked()
80
+ if self._current.count == 0 and self._previous.count == 0:
81
+ return math.nan
82
+ merged = DDSketch(self._relative_accuracy)
83
+ merged.merge(self._previous)
84
+ merged.merge(self._current)
85
+ return merged.quantile(q)
86
+
87
+ def rotate(self) -> None:
88
+ """Force an immediate rotation. Mostly useful for testing."""
89
+ with self._lock:
90
+ self._previous = self._current
91
+ self._current = DDSketch(self._relative_accuracy)
92
+ self._window_start = time.monotonic()
@@ -0,0 +1,176 @@
1
+ """Hedged transport for httpx.AsyncClient.
2
+
3
+ Usage::
4
+
5
+ import httpx
6
+ from httpx_hedged import EndpointConfig, HedgeConfig, HedgedTransport
7
+
8
+ transport = HedgedTransport()
9
+ transport.register("GET", "/api/v1/fast-lookup", EndpointConfig(percentile=0.90))
10
+ transport.register("GET", "/api/v1/bulk-export", EndpointConfig(percentile=0.90))
11
+
12
+ async with httpx.AsyncClient(transport=transport) as client:
13
+ resp = await client.get("https://api.example.com/api/v1/fast-lookup")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+
20
+ import httpx
21
+
22
+ from httpx_hedged._config import EndpointConfig, HedgeConfig, resolve
23
+ from httpx_hedged._health import HealthRegistry
24
+ from httpx_hedged._matcher import EndpointMatcher, Route
25
+ from httpx_hedged._scheduler import HedgeScheduler, extract_host
26
+ from httpx_hedged._stats import StatsRegistry
27
+
28
+ _IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS")
29
+
30
+
31
+ def _has_body(request: httpx.Request) -> bool:
32
+ """Whether a request carries a body that a hedge can't safely re-send.
33
+
34
+ The primary and hedge both send the same ``httpx.Request`` object; a
35
+ body backed by a one-shot async stream (e.g. ``content=some_generator``)
36
+ would be consumed by whichever of the two reads it first, corrupting or
37
+ failing the other. Idempotent methods essentially never carry a body in
38
+ normal use, so this only ever changes behavior for that edge case.
39
+ """
40
+ content_length = request.headers.get("content-length")
41
+ if content_length not in (None, "0"):
42
+ return True
43
+ return request.headers.get("transfer-encoding", "").lower() == "chunked"
44
+
45
+
46
+ class HedgedTransport(httpx.AsyncBaseTransport):
47
+ """An httpx async transport that adds adaptive, per-endpoint hedged requests.
48
+
49
+ Wraps a single inner transport (default: ``httpx.AsyncHTTPTransport``,
50
+ one connection pool) and races a backup request when the primary
51
+ exceeds its estimated latency percentile -- or a hardcoded delay, for
52
+ endpoints registered with ``EndpointConfig(hedge_delay=...)``.
53
+
54
+ Endpoints are identified by registering method + path patterns via
55
+ ``register()``; a request can also be tagged directly with
56
+ ``extensions={"hedge_endpoint": "name"}`` to bypass pattern matching.
57
+ Unmatched requests fall back to a default config scoped per host.
58
+
59
+ A circuit breaker independently tracks host-level and endpoint-level
60
+ health; when either trips, hedging is suppressed for that scope (the
61
+ primary request is always still sent and its result/exception is always
62
+ returned normally).
63
+
64
+ Args:
65
+ inner: The underlying transport to wrap. Defaults to a new
66
+ ``httpx.AsyncHTTPTransport()``.
67
+ default_config: Hedge configuration used for any request that
68
+ doesn't match a registered endpoint. Defaults to ``HedgeConfig()``.
69
+ routes: Endpoints to register up front (equivalent to calling
70
+ ``register()`` for each one after construction).
71
+ on_hedge_fired: Called with the key each time a hedge request is
72
+ actually launched. Intended for metrics -- see the README's
73
+ observability section for an example.
74
+ on_circuit_open: Called as ``on_circuit_open(scope, key)`` each
75
+ time a host- or endpoint-scoped circuit breaker trips open
76
+ (``scope`` is ``"host"`` or ``"endpoint"``). Intended for
77
+ alerting -- see the README's observability section for an
78
+ example.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ inner: httpx.AsyncBaseTransport | None = None,
84
+ default_config: HedgeConfig | None = None,
85
+ routes: list[Route] | None = None,
86
+ on_hedge_fired: Callable[[str], None] | None = None,
87
+ on_circuit_open: Callable[[str, str], None] | None = None,
88
+ ) -> None:
89
+ self._inner = inner or httpx.AsyncHTTPTransport()
90
+ self._default_config = default_config or HedgeConfig()
91
+ self._matcher = EndpointMatcher()
92
+ self._stats = StatsRegistry()
93
+ self._health = HealthRegistry(on_circuit_open=on_circuit_open)
94
+ self._scheduler = HedgeScheduler(
95
+ self._health,
96
+ self._stats,
97
+ self._default_config.circuit_breaker,
98
+ on_hedge_fired=on_hedge_fired,
99
+ )
100
+
101
+ for route in routes or []:
102
+ self.register(
103
+ route.method, route.path_pattern, route.config, name=route.name
104
+ )
105
+
106
+ def register(
107
+ self,
108
+ method: str,
109
+ path_pattern: str,
110
+ config: EndpointConfig,
111
+ *,
112
+ name: str | None = None,
113
+ ) -> str:
114
+ """Register a per-endpoint hedge config for a method + path pattern.
115
+
116
+ ``path_pattern`` segments may contain ``{name}`` placeholders or a
117
+ bare ``*`` to match any single path segment (e.g.
118
+ ``/api/v1/users/{id}``). Routes are matched in registration order,
119
+ first match wins -- register more specific patterns first.
120
+
121
+ Returns the resolved endpoint name (used as the key in ``stats``
122
+ and as the value for ``extensions={"hedge_endpoint": name}``).
123
+ """
124
+ return self._matcher.register(method, path_pattern, config, name=name)
125
+
126
+ @property
127
+ def stats(self) -> StatsRegistry:
128
+ """Per-endpoint and aggregate hedge statistics."""
129
+ return self._stats
130
+
131
+ @property
132
+ def health(self) -> HealthRegistry:
133
+ """Host- and endpoint-level circuit breaker state."""
134
+ return self._health
135
+
136
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
137
+ """Handle an outgoing request with adaptive, per-endpoint hedging."""
138
+ host = extract_host(str(request.url))
139
+ route = self._matcher.match(request)
140
+
141
+ if route is not None:
142
+ key = f"endpoint:{route.name}"
143
+ resolved = resolve(route.config, self._default_config)
144
+ else:
145
+ key = f"host:{host}"
146
+ resolved = resolve(None, self._default_config)
147
+
148
+ can_hedge = request.method.upper() in _IDEMPOTENT_METHODS and not _has_body(
149
+ request
150
+ )
151
+ treat_5xx_as_failure = resolved.circuit_breaker.treat_5xx_as_failure
152
+
153
+ async def do_request() -> httpx.Response:
154
+ return await self._inner.handle_async_request(request)
155
+
156
+ def classify(response: httpx.Response) -> bool:
157
+ return not (treat_5xx_as_failure and response.status_code >= 500)
158
+
159
+ async def discard(response: httpx.Response) -> None:
160
+ await response.aclose()
161
+
162
+ return await self._scheduler.execute_with_hedge(
163
+ key=key,
164
+ host=host,
165
+ config=resolved,
166
+ primary_func=do_request,
167
+ hedge_func=do_request,
168
+ classify=classify,
169
+ can_hedge=can_hedge,
170
+ discard=discard,
171
+ )
172
+
173
+ async def aclose(self) -> None:
174
+ """Close the transport and the wrapped inner transport."""
175
+ self._scheduler.close()
176
+ await self._inner.aclose()