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,300 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpx-hedged
3
+ Version: 0.2.0
4
+ Summary: Adaptive, per-endpoint request hedging transport for httpx. Learns latency percentiles per endpoint with DDSketch, caps hedge rate with a token bucket, and stops hedging under a host/endpoint circuit breaker.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: httpx>=0.27.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # httpx-hedged
11
+
12
+ An [httpx](https://www.python-httpx.org/) transport that adds
13
+ adaptive, per-endpoint request hedging: fire a backup request when the
14
+ primary is running slow, take whichever finishes first, cancel the
15
+ loser. Based on Google's [The Tail at
16
+ Scale](https://research.google/pubs/pub40801/) and modeled heavily on
17
+ [hedge-python](https://github.com/sunhailin-Leo/hedge-python).
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ import asyncio
23
+ import httpx
24
+ from httpx_hedged import HedgedTransport
25
+
26
+ async def main():
27
+ transport = HedgedTransport()
28
+ async with httpx.AsyncClient(transport=transport) as client:
29
+ response = await client.get("https://api.example.com/data")
30
+ print(response.json())
31
+
32
+ asyncio.run(main())
33
+ ```
34
+
35
+ With no configuration, `HedgedTransport` learns a p90 latency estimate per
36
+ host (via a [DDSketch](https://arxiv.org/abs/2004.08604) quantile sketch)
37
+ and fires a hedge request whenever the primary exceeds it.
38
+
39
+ ## Why per-endpoint?
40
+
41
+ A single host can host wildly different endpoints. Learning one latency
42
+ distribution per *host* -- rather than per endpoint -- means a handful of
43
+ calls to a slow endpoint skew the hedge trigger for a fast one sharing the
44
+ same host:
45
+
46
+ ```
47
+ GET /api/v1/fast-lookup median 10ms, high RPS
48
+ GET /api/v1/bulk-export median 900ms, low RPS
49
+ ```
50
+
51
+ `HedgedTransport` lets you register per-endpoint config up front. Each
52
+ registered endpoint gets its own latency sketch, rate estimate, and hedge
53
+ budget -- all still funneled through a **single inner transport and
54
+ connection pool**, unlike using `httpx` `mounts={}`, which would mean one
55
+ connection pool per pattern:
56
+
57
+ ```python
58
+ from httpx_hedged import EndpointConfig, HedgedTransport
59
+
60
+ transport = HedgedTransport()
61
+ transport.register("GET", "/api/v1/fast-lookup", EndpointConfig(percentile=0.90))
62
+ transport.register("GET", "/api/v1/bulk-export", EndpointConfig(percentile=0.90))
63
+ ```
64
+
65
+ Requests that don't match a registered pattern fall back to a default
66
+ config, tracked per host (the same behavior as hedging with no registered
67
+ endpoints at all).
68
+
69
+ Route patterns may contain `{name}` placeholders or a bare `*` for a single
70
+ path segment, e.g. `/api/v1/users/{id}`. Patterns are matched in
71
+ registration order -- register more specific patterns first.
72
+
73
+ ## Hardcoded vs. adaptive delay
74
+
75
+ Most endpoints should hedge adaptively, against their own learned
76
+ percentile:
77
+
78
+ ```python
79
+ transport.register("GET", "/api/v1/search", EndpointConfig(percentile=0.95))
80
+ ```
81
+
82
+ For an endpoint where you already know the right delay -- or want
83
+ deterministic behavior without a warmup period -- hardcode it instead:
84
+
85
+ ```python
86
+ transport.register("GET", "/api/v1/health", EndpointConfig(hedge_delay=0.05))
87
+ ```
88
+
89
+ A hardcoded endpoint still records latency into its sketch for
90
+ observability; it just isn't consulted for the hedge-delay decision.
91
+
92
+ ## Explicit endpoint override
93
+
94
+ Auto-matching not precise enough for a particular call site (or you'd
95
+ rather not register a pattern)? Tag the request directly -- this bypasses
96
+ pattern matching entirely:
97
+
98
+ ```python
99
+ await client.get(
100
+ "https://api.example.com/some/path",
101
+ extensions={"hedge_endpoint": "pinned-name"},
102
+ )
103
+ ```
104
+
105
+ The name must already be registered (`register(..., name="pinned-name")`);
106
+ an unknown name raises `UnknownHedgeEndpointError` rather than silently
107
+ falling back, so typos fail loudly.
108
+
109
+ ## Configuration
110
+
111
+ ### `HedgeConfig` (transport-wide default)
112
+
113
+ | Parameter | Type | Default | Description |
114
+ |---|---|---|---|
115
+ | `percentile` | `float` | `0.90` | Sketch quantile used as the hedge trigger |
116
+ | `budget_percent` | `float` | `10.0` | Max hedge rate as percent of total traffic |
117
+ | `estimated_rps` | `float \| None` | `None` | Pin the expected requests/sec, or leave `None` to auto-estimate from observed traffic |
118
+ | `rps_window_duration` | `float` | `10.0` | Rolling window (seconds) for RPS auto-estimation |
119
+ | `min_delay` | `float` | `0.001` | Floor on the hedge delay in seconds |
120
+ | `warmup_requests` | `int` | `20` | Requests using a fixed delay before the sketch is trusted |
121
+ | `warmup_delay` | `float` | `0.01` | Fixed hedge delay during warmup, in seconds |
122
+ | `window_duration` | `float` | `30.0` | Latency sketch rotation interval in seconds |
123
+ | `circuit_breaker` | `CircuitBreakerConfig` | see below | Health circuit-breaker configuration |
124
+
125
+ ### `EndpointConfig` (per-registered-endpoint override)
126
+
127
+ Every field mirrors `HedgeConfig` and defaults to `None`, meaning "inherit
128
+ the transport default." One extra field:
129
+
130
+ | Parameter | Type | Default | Description |
131
+ |---|---|---|---|
132
+ | `hedge_delay` | `float \| None` | `None` | Hardcode the hedge delay for this endpoint, skipping the sketch for the decision |
133
+
134
+ ### `CircuitBreakerConfig`
135
+
136
+ | Parameter | Type | Default | Description |
137
+ |---|---|---|---|
138
+ | `error_rate_threshold` | `float` | `0.5` | Failure fraction that trips the breaker open |
139
+ | `min_samples` | `int` | `20` | Minimum samples in-window before the breaker can trip |
140
+ | `window_duration` | `float` | `30.0` | Rolling window (seconds) for the error-rate estimate |
141
+ | `cooldown` | `float` | `30.0` | Seconds the breaker stays open before a half-open trial |
142
+ | `half_open_max_trial` | `int` | `5` | Trial requests allowed through while half-open |
143
+ | `treat_5xx_as_failure` | `bool` | `True` | Whether an HTTP 5xx response counts as a failure |
144
+
145
+ All three config classes validate their fields at construction time (e.g.
146
+ `percentile` must be strictly between 0 and 1, delays and windows must be
147
+ non-negative/positive) and raise `ValueError` immediately on a bad value,
148
+ rather than silently misbehaving later.
149
+
150
+ ## How it works
151
+
152
+ ### Race and cancel
153
+
154
+ ```
155
+ ┌─ primary ─────────── ✓ (fast) ──→ return
156
+ request ──────┤
157
+ └─ hedge fires after estimated delay ─── ✗ (cancelled)
158
+ ```
159
+
160
+ Only idempotent methods (`GET`, `HEAD`, `OPTIONS`) are ever hedged, to avoid
161
+ duplicating side effects. A request with a body is also never hedged, even
162
+ if the method is idempotent -- the primary and hedge send the same
163
+ `httpx.Request` object, and a body backed by a one-shot stream can't be
164
+ safely read twice.
165
+
166
+ ### DDSketch quantile estimator
167
+
168
+ Each tracked key (an endpoint, or the per-host fallback) gets its own
169
+ sliding-window DDSketch pair that rotates every `window_duration` seconds.
170
+ DDSketch gives relative-error quantile guarantees regardless of the
171
+ underlying latency distribution's shape.
172
+
173
+ ### Token bucket budget
174
+
175
+ Hedges are rate-limited by a token bucket refilling at
176
+ `estimated_rps * budget_percent / 100` tokens/second, per key. During a
177
+ genuine outage the bucket drains and hedging stops automatically,
178
+ preventing the load-doubling spiral that would deepen the incident. By
179
+ default the RPS feeding this calculation is estimated automatically from
180
+ observed traffic per key, rather than requiring a manual guess per
181
+ endpoint.
182
+
183
+ ## Circuit breaker
184
+
185
+ A closed / open / half-open circuit breaker tracks request success/failure
186
+ at **two independent tiers**: one breaker per host, one breaker per
187
+ endpoint key. Either tripping open suppresses hedging for its scope -- a
188
+ host-level trip disables hedging for every endpoint on that host, while an
189
+ endpoint-level trip disables hedging only for that one endpoint.
190
+
191
+ Crucially, the breaker **only ever suppresses the hedge request**. The
192
+ primary request is always sent, and its result or exception is always
193
+ returned to the caller normally -- this is not a request-blocking circuit
194
+ breaker, so hedging can't pile extra load onto a backend that's already
195
+ struggling.
196
+
197
+ ```
198
+ CLOSED ──(error rate ≥ threshold, samples ≥ min_samples)──▶ OPEN
199
+ OPEN ──(cooldown elapsed)──▶ HALF_OPEN
200
+ HALF_OPEN ──(trial requests mostly succeed)──▶ CLOSED
201
+ HALF_OPEN ──(trial requests mostly fail)────▶ OPEN
202
+ ```
203
+
204
+ Note: health is recorded from the *winning* task's outcome only -- a
205
+ cancelled loser's real outcome is unknowable, and losers are cancelled
206
+ deliberately (not doing so would defeat the point of reducing load on a
207
+ struggling backend).
208
+
209
+ ## Observability
210
+
211
+ ### Polling stats and health snapshots
212
+
213
+ ```python
214
+ transport = HedgedTransport()
215
+
216
+ # ... after running some traffic ...
217
+ for key, snap in transport.stats.all_snapshots().items():
218
+ print(key, snap)
219
+
220
+ print(transport.stats.global_snapshot())
221
+ print(transport.health.host_state("api.example.com"))
222
+ ```
223
+
224
+ `StatsSnapshot` reports `total_requests`, `hedged_requests`, `hedge_wins`,
225
+ `primary_wins`, `budget_exhausted`, `warmup_requests`, `circuit_blocked`,
226
+ and `errors` per key, plus a global aggregate.
227
+
228
+ ### Push-based hooks: metrics and alerting
229
+
230
+ Polling snapshots works for dashboards, but alerting on a circuit-breaker
231
+ trip and emitting a metric on every hedge fire are both things you want to
232
+ happen *at the moment they occur*, not on the next poll. `HedgedTransport`
233
+ takes two optional callbacks for exactly this:
234
+
235
+ ```python
236
+ import logging
237
+
238
+ import httpx
239
+ from httpx_hedged import EndpointConfig, HedgeConfig, HedgedTransport
240
+
241
+ logger = logging.getLogger("myapp.hedging")
242
+
243
+
244
+ def emit_hedge_fired_metric(key: str) -> None:
245
+ statsd_client.incr("http.hedge.fired", tags=[f"endpoint:{key}"])
246
+
247
+
248
+ def alert_on_circuit_open(scope: str, key: str) -> None:
249
+ # scope is "host" or "endpoint"; key is the host name or endpoint key
250
+ # that tripped. Fires exactly once per OPEN transition.
251
+ logger.error(
252
+ "hedging circuit breaker OPEN: %s=%s is unhealthy, hedging suspended", scope, key
253
+ )
254
+
255
+
256
+ transport = HedgedTransport(
257
+ default_config=HedgeConfig(),
258
+ on_hedge_fired=emit_hedge_fired_metric,
259
+ on_circuit_open=alert_on_circuit_open,
260
+ )
261
+
262
+ transport.register("GET", "/api/v1/fast-lookup", EndpointConfig(percentile=0.90))
263
+ transport.register("GET", "/api/v1/bulk-export", EndpointConfig(percentile=0.90))
264
+
265
+ async with httpx.AsyncClient(transport=transport) as client:
266
+ ...
267
+ ```
268
+
269
+ `on_hedge_fired` is called with the key each time a hedge request is
270
+ actually launched -- after the idempotency, circuit-breaker, and budget
271
+ gates have all passed, so it only fires for hedges that were genuinely
272
+ sent. `on_circuit_open` is called once per OPEN transition (not on every
273
+ suppressed hedge while it stays open), so it's safe to wire straight into
274
+ an alerting/paging pipeline without flooding it.
275
+
276
+ Both callbacks run synchronously on the request path, so keep them fast
277
+ (increment a counter, log a line) -- don't do network I/O in them directly.
278
+
279
+ ## Relationship to hedge-python
280
+
281
+ This library is modeled heavily on
282
+ [hedge-python](https://github.com/sunhailin-Leo/hedge-python), which
283
+ pioneered the DDSketch-based adaptive-hedging approach this project
284
+ borrows. hedge-python keys its sketch per host, which works well for a
285
+ single-endpoint-per-host use case; this project exists to add per-endpoint
286
+ tracking on top of the same core idea, plus a health circuit breaker,
287
+ for services that expose many differently-shaped endpoints on one host. See
288
+ the filed [upstream issue](https://github.com/sunhailin-Leo/hedge-python/issues/2)
289
+ for the motivating scenario.
290
+
291
+ ## References
292
+
293
+ - [The Tail at Scale](https://research.google/pubs/pub40801/) -- Google's paper on tail latency
294
+ - [DDSketch: A fast and fully-mergeable quantile sketch with relative-error guarantees](https://arxiv.org/abs/2004.08604) -- Masson et al., VLDB 2019
295
+ - [hedge-python](https://github.com/sunhailin-Leo/hedge-python) -- the project this one is modeled after
296
+ - [httpx documentation](https://www.python-httpx.org/)
297
+
298
+ ## License
299
+
300
+ BSD 3-Clause License. See [LICENSE](LICENSE) file for details.
@@ -0,0 +1,19 @@
1
+ httpx_hedged/__init__.py,sha256=qgb6ZGlin6loceLjokpQZjYwAB81ifMNgUmVHatIi3A,1324
2
+ httpx_hedged/_bounded.py,sha256=rTt2L1pg92NUVD4BaR3N2hRR7Nc9VrS2pXxfVpdvr9M,1588
3
+ httpx_hedged/_config.py,sha256=tLgauUBM-bEhdg_4k-6r01KVYLTVh4iYcHvupypIBnA,8651
4
+ httpx_hedged/_health.py,sha256=n0TMzmsngyTQlkgnc2pnAk-nDOZGPGTKhQR5-6tGpuw,10353
5
+ httpx_hedged/_matcher.py,sha256=k9RK0f8OWB4VdzPjHszRjAHqy2KbesmhaFx_w7YvJCU,4385
6
+ httpx_hedged/_rate.py,sha256=tab9R_wD-7p-zo9WCRTFyPPIn3kSbp1iJoFru-HgxJo,2888
7
+ httpx_hedged/_rotation.py,sha256=mes59ls0dCFEort2L5DTt1s_-mmi2lSPQyXW0RyUwOY,1526
8
+ httpx_hedged/_scheduler.py,sha256=qR7kVjsX8gpy3HKw2rQFRguhPDyseY_mBCDweN8llHw,12049
9
+ httpx_hedged/_stats.py,sha256=mDhenR_zp3So4lC4eO8a8RLfXOgbtWJ5NF6EdXaKMz8,5083
10
+ httpx_hedged/transport.py,sha256=8pmREmmLA6FVWtH9yjIrnjOCPTvf9Z53dP4CFZInzv8,6999
11
+ httpx_hedged/budget/__init__.py,sha256=boLCL5U9IKycVE688dpXuss0dHGpjWhVacrWEe4794w,158
12
+ httpx_hedged/budget/_token_bucket.py,sha256=zB_S6lgjcNTmFQjPD6CbPWdlvcBs7eKKTPcLJHbNkkM,1887
13
+ httpx_hedged/sketch/__init__.py,sha256=5Wjre__zLRAN6ZKowlgup0Px2QqtEPk1cgJtKeQAplQ,266
14
+ httpx_hedged/sketch/_ddsketch.py,sha256=HsnLA8hyT2dDS6t6Nd1MvixMrwyt-0b4Jte2yK2bU2A,6336
15
+ httpx_hedged/sketch/_windowed.py,sha256=Hy0PjmJlTzsoNkLhMVULSZ6P5jENkENQuBXtevBowdM,3455
16
+ httpx_hedged-0.2.0.dist-info/METADATA,sha256=w9VALmhLLd1sUvEt0W_35zuVMruETNSOgOGWeA34vNg,11889
17
+ httpx_hedged-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
18
+ httpx_hedged-0.2.0.dist-info/licenses/LICENSE,sha256=VUmTrbhUTXvjVSZdZhR9TrQ24lKPI1RsO_5AIDeJO9k,1499
19
+ httpx_hedged-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Brendan Fahy
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.