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/_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,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()
|