httpx-hedged 0.2.0__tar.gz

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.
Files changed (42) hide show
  1. httpx_hedged-0.2.0/.github/workflows/ci.yml +36 -0
  2. httpx_hedged-0.2.0/.github/workflows/release.yml +35 -0
  3. httpx_hedged-0.2.0/.gitignore +210 -0
  4. httpx_hedged-0.2.0/.python-version +1 -0
  5. httpx_hedged-0.2.0/LICENSE +28 -0
  6. httpx_hedged-0.2.0/PKG-INFO +300 -0
  7. httpx_hedged-0.2.0/README.md +291 -0
  8. httpx_hedged-0.2.0/examples/app.py +48 -0
  9. httpx_hedged-0.2.0/examples/example_usage.py +143 -0
  10. httpx_hedged-0.2.0/examples/run_example.sh +43 -0
  11. httpx_hedged-0.2.0/pyproject.toml +49 -0
  12. httpx_hedged-0.2.0/src/httpx_hedged/__init__.py +38 -0
  13. httpx_hedged-0.2.0/src/httpx_hedged/_bounded.py +45 -0
  14. httpx_hedged-0.2.0/src/httpx_hedged/_config.py +221 -0
  15. httpx_hedged-0.2.0/src/httpx_hedged/_health.py +284 -0
  16. httpx_hedged-0.2.0/src/httpx_hedged/_matcher.py +129 -0
  17. httpx_hedged-0.2.0/src/httpx_hedged/_rate.py +75 -0
  18. httpx_hedged-0.2.0/src/httpx_hedged/_rotation.py +42 -0
  19. httpx_hedged-0.2.0/src/httpx_hedged/_scheduler.py +302 -0
  20. httpx_hedged-0.2.0/src/httpx_hedged/_stats.py +160 -0
  21. httpx_hedged-0.2.0/src/httpx_hedged/budget/__init__.py +7 -0
  22. httpx_hedged-0.2.0/src/httpx_hedged/budget/_token_bucket.py +54 -0
  23. httpx_hedged-0.2.0/src/httpx_hedged/sketch/__init__.py +8 -0
  24. httpx_hedged-0.2.0/src/httpx_hedged/sketch/_ddsketch.py +188 -0
  25. httpx_hedged-0.2.0/src/httpx_hedged/sketch/_windowed.py +92 -0
  26. httpx_hedged-0.2.0/src/httpx_hedged/transport.py +176 -0
  27. httpx_hedged-0.2.0/tests/__init__.py +0 -0
  28. httpx_hedged-0.2.0/tests/conftest.py +71 -0
  29. httpx_hedged-0.2.0/tests/integration/__init__.py +0 -0
  30. httpx_hedged-0.2.0/tests/integration/test_httpx_transport.py +249 -0
  31. httpx_hedged-0.2.0/tests/unit/__init__.py +0 -0
  32. httpx_hedged-0.2.0/tests/unit/test_config.py +116 -0
  33. httpx_hedged-0.2.0/tests/unit/test_ddsketch.py +91 -0
  34. httpx_hedged-0.2.0/tests/unit/test_health.py +166 -0
  35. httpx_hedged-0.2.0/tests/unit/test_matcher.py +112 -0
  36. httpx_hedged-0.2.0/tests/unit/test_rate_counter.py +67 -0
  37. httpx_hedged-0.2.0/tests/unit/test_rotation.py +38 -0
  38. httpx_hedged-0.2.0/tests/unit/test_scheduler.py +409 -0
  39. httpx_hedged-0.2.0/tests/unit/test_stats.py +66 -0
  40. httpx_hedged-0.2.0/tests/unit/test_token_bucket.py +47 -0
  41. httpx_hedged-0.2.0/tests/unit/test_windowed_sketch.py +76 -0
  42. httpx_hedged-0.2.0/uv.lock +770 -0
@@ -0,0 +1,36 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint-and-test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.13"]
14
+ fail-fast: false
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up uv
20
+ uses: astral-sh/setup-uv@v5
21
+ with:
22
+ version: "latest"
23
+ enable-cache: true
24
+
25
+ - name: Set up Python ${{ matrix.python-version }}
26
+ run: uv python install ${{ matrix.python-version }}
27
+
28
+ # If you have a requirements.txt file, uv installs it directly into an ephemeral environment
29
+ - name: Run Ruff Linter
30
+ run: uv run --with ruff ruff check .
31
+
32
+ - name: Run Ruff Formatter Check
33
+ run: uv run --with ruff ruff format --check .
34
+
35
+ - name: Run Tests with Pytest
36
+ run: uv run --with pytest pytest
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*' # Triggers on semantic version tags like v1.0.0, v0.2.1-beta, etc.
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ name: Build package and publish to PyPI
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+
14
+ # Required environment permissions for PyPI Trusted Publishing OIDC
15
+ permissions:
16
+ id-token: write # MANDATORY: Enables GitHub to request the OIDC token
17
+ contents: read # Required to checkout code
18
+
19
+ steps:
20
+ - name: Checkout repository
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Set up uv
24
+ uses: astral-sh/setup-uv@v5
25
+ with:
26
+ version: "latest"
27
+
28
+ - name: Set up Python
29
+ run: uv python install "3.13"
30
+
31
+ - name: Build Source and Wheel Distributions
32
+ run: uv build
33
+
34
+ - name: Publish Package to PyPI
35
+ run: uv publish
@@ -0,0 +1,210 @@
1
+ # Reference clone, not part of this project
2
+ /hedge-python/
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[codz]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py.cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+ cover/
56
+
57
+ # Translations
58
+ *.mo
59
+ *.pot
60
+
61
+ # Django stuff:
62
+ *.log
63
+ local_settings.py
64
+ db.sqlite3
65
+ db.sqlite3-journal
66
+
67
+ # Flask stuff:
68
+ instance/
69
+ .webassets-cache
70
+
71
+ # Scrapy stuff:
72
+ .scrapy
73
+
74
+ # Sphinx documentation
75
+ docs/_build/
76
+
77
+ # PyBuilder
78
+ .pybuilder/
79
+ target/
80
+
81
+ # Jupyter Notebook
82
+ .ipynb_checkpoints
83
+
84
+ # IPython
85
+ profile_default/
86
+ ipython_config.py
87
+
88
+ # pyenv
89
+ # For a library or package, you might want to ignore these files since the code is
90
+ # intended to run in multiple environments; otherwise, check them in:
91
+ # .python-version
92
+
93
+ # pipenv
94
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
96
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
97
+ # install all needed dependencies.
98
+ #Pipfile.lock
99
+
100
+ # UV
101
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
102
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
103
+ # commonly ignored for libraries.
104
+ #uv.lock
105
+
106
+ # poetry
107
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
108
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
109
+ # commonly ignored for libraries.
110
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
111
+ #poetry.lock
112
+ #poetry.toml
113
+
114
+ # pdm
115
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
116
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
117
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
118
+ #pdm.lock
119
+ #pdm.toml
120
+ .pdm-python
121
+ .pdm-build/
122
+
123
+ # pixi
124
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
125
+ #pixi.lock
126
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
127
+ # in the .venv directory. It is recommended not to include this directory in version control.
128
+ .pixi
129
+
130
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
131
+ __pypackages__/
132
+
133
+ # Celery stuff
134
+ celerybeat-schedule
135
+ celerybeat.pid
136
+
137
+ # SageMath parsed files
138
+ *.sage.py
139
+
140
+ # Environments
141
+ .env
142
+ .envrc
143
+ .venv
144
+ env/
145
+ venv/
146
+ ENV/
147
+ env.bak/
148
+ venv.bak/
149
+
150
+ # Spyder project settings
151
+ .spyderproject
152
+ .spyproject
153
+
154
+ # Rope project settings
155
+ .ropeproject
156
+
157
+ # mkdocs documentation
158
+ /site
159
+
160
+ # mypy
161
+ .mypy_cache/
162
+ .dmypy.json
163
+ dmypy.json
164
+
165
+ # Pyre type checker
166
+ .pyre/
167
+
168
+ # pytype static type analyzer
169
+ .pytype/
170
+
171
+ # Cython debug symbols
172
+ cython_debug/
173
+
174
+ # PyCharm
175
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
176
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
177
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
178
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
179
+ #.idea/
180
+
181
+ # Abstra
182
+ # Abstra is an AI-powered process automation framework.
183
+ # Ignore directories containing user credentials, local state, and settings.
184
+ # Learn more at https://abstra.io/docs
185
+ .abstra/
186
+
187
+ # Visual Studio Code
188
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
189
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
190
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
191
+ # you could uncomment the following to ignore the entire vscode folder
192
+ # .vscode/
193
+
194
+ # Ruff stuff:
195
+ .ruff_cache/
196
+
197
+ # PyPI configuration file
198
+ .pypirc
199
+
200
+ # Cursor
201
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
202
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
203
+ # refer to https://docs.cursor.com/context/ignore-files
204
+ .cursorignore
205
+ .cursorindexingignore
206
+
207
+ # Marimo
208
+ marimo/_static/
209
+ marimo/_lsp/
210
+ __marimo__/
@@ -0,0 +1 @@
1
+ 3.13
@@ -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.
@@ -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.