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