hedge-python 0.1.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.
- hedge/__init__.py +17 -0
- hedge/_options.py +35 -0
- hedge/_stats.py +75 -0
- hedge/budget/__init__.py +5 -0
- hedge/budget/_token_bucket.py +52 -0
- hedge/interceptor/__init__.py +23 -0
- hedge/interceptor/_grpc.py +302 -0
- hedge/py.typed +0 -0
- hedge/sketch/__init__.py +6 -0
- hedge/sketch/_ddsketch.py +173 -0
- hedge/sketch/_windowed.py +115 -0
- hedge/transport/__init__.py +24 -0
- hedge/transport/_aiohttp.py +135 -0
- hedge/transport/_base.py +159 -0
- hedge/transport/_httpx.py +84 -0
- hedge_python-0.1.0.dist-info/METADATA +367 -0
- hedge_python-0.1.0.dist-info/RECORD +19 -0
- hedge_python-0.1.0.dist-info/WHEEL +4 -0
- hedge_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""WindowedSketch: sliding-window quantile estimation over DDSketch pairs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from hedge.sketch._ddsketch import DDSketch
|
|
10
|
+
|
|
11
|
+
_DEFAULT_WINDOW_DURATION = 30.0 # seconds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WindowedSketch:
|
|
15
|
+
"""Maintains a sliding window over two DDSketches that rotate periodically.
|
|
16
|
+
|
|
17
|
+
Quantile queries merge both sketches, giving a window that spans
|
|
18
|
+
1x to 2x the configured duration. Add always writes to the current sketch.
|
|
19
|
+
|
|
20
|
+
The rotation scheme::
|
|
21
|
+
|
|
22
|
+
t=0: current=A, previous=empty
|
|
23
|
+
t=30: current=B, previous=A (A covers [0,30))
|
|
24
|
+
t=60: current=C, previous=B (A is dropped)
|
|
25
|
+
|
|
26
|
+
This class is thread-safe and can be used from both sync and async code.
|
|
27
|
+
For async rotation, call ``start_async()`` after creating the sketch.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
relative_accuracy: DDSketch relative accuracy (default: 0.01).
|
|
31
|
+
window_duration: Rotation interval in seconds (default: 30.0).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
relative_accuracy: float = 0.01,
|
|
37
|
+
window_duration: float = _DEFAULT_WINDOW_DURATION,
|
|
38
|
+
) -> None:
|
|
39
|
+
if window_duration <= 0:
|
|
40
|
+
window_duration = _DEFAULT_WINDOW_DURATION
|
|
41
|
+
self._relative_accuracy = relative_accuracy
|
|
42
|
+
self._window_duration = window_duration
|
|
43
|
+
self._lock = threading.RLock()
|
|
44
|
+
self._current = DDSketch(relative_accuracy)
|
|
45
|
+
self._previous = DDSketch(relative_accuracy)
|
|
46
|
+
# Sync rotation
|
|
47
|
+
self._stop_event = threading.Event()
|
|
48
|
+
self._thread: threading.Thread | None = None
|
|
49
|
+
# Async rotation
|
|
50
|
+
self._async_task: asyncio.Task[None] | None = None
|
|
51
|
+
|
|
52
|
+
def start(self) -> None:
|
|
53
|
+
"""Start background rotation thread (for sync usage)."""
|
|
54
|
+
if self._thread is not None:
|
|
55
|
+
return
|
|
56
|
+
self._stop_event.clear()
|
|
57
|
+
self._thread = threading.Thread(target=self._rotate_loop, daemon=True)
|
|
58
|
+
self._thread.start()
|
|
59
|
+
|
|
60
|
+
def start_async(self) -> None:
|
|
61
|
+
"""Start background rotation as an asyncio task (for async usage).
|
|
62
|
+
|
|
63
|
+
Must be called from within a running event loop.
|
|
64
|
+
"""
|
|
65
|
+
if self._async_task is not None:
|
|
66
|
+
return
|
|
67
|
+
self._async_task = asyncio.ensure_future(self._rotate_loop_async())
|
|
68
|
+
|
|
69
|
+
def stop(self) -> None:
|
|
70
|
+
"""Stop the background rotation thread and wait for it to exit."""
|
|
71
|
+
if self._thread is not None:
|
|
72
|
+
self._stop_event.set()
|
|
73
|
+
self._thread.join()
|
|
74
|
+
self._thread = None
|
|
75
|
+
if self._async_task is not None:
|
|
76
|
+
self._async_task.cancel()
|
|
77
|
+
self._async_task = None
|
|
78
|
+
|
|
79
|
+
def add(self, value: float) -> None:
|
|
80
|
+
"""Record a latency sample (in seconds) to the current sketch."""
|
|
81
|
+
with self._lock:
|
|
82
|
+
self._current.add(value)
|
|
83
|
+
|
|
84
|
+
def quantile(self, q: float) -> float:
|
|
85
|
+
"""Return the estimated quantile q in [0, 1] over the sliding window.
|
|
86
|
+
|
|
87
|
+
Returns math.nan if no data has been recorded.
|
|
88
|
+
"""
|
|
89
|
+
with self._lock:
|
|
90
|
+
if self._current.count == 0 and self._previous.count == 0:
|
|
91
|
+
return math.nan
|
|
92
|
+
merged = DDSketch(self._relative_accuracy)
|
|
93
|
+
merged.merge(self._previous)
|
|
94
|
+
merged.merge(self._current)
|
|
95
|
+
return merged.quantile(q)
|
|
96
|
+
|
|
97
|
+
def rotate(self) -> None:
|
|
98
|
+
"""Manually rotate the window. Mostly useful for testing."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._previous = self._current
|
|
101
|
+
self._current = DDSketch(self._relative_accuracy)
|
|
102
|
+
|
|
103
|
+
def _rotate_loop(self) -> None:
|
|
104
|
+
"""Background thread rotation loop."""
|
|
105
|
+
while not self._stop_event.wait(self._window_duration):
|
|
106
|
+
self.rotate()
|
|
107
|
+
|
|
108
|
+
async def _rotate_loop_async(self) -> None:
|
|
109
|
+
"""Async rotation loop."""
|
|
110
|
+
try:
|
|
111
|
+
while True:
|
|
112
|
+
await asyncio.sleep(self._window_duration)
|
|
113
|
+
self.rotate()
|
|
114
|
+
except asyncio.CancelledError:
|
|
115
|
+
return
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Framework-specific hedge transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from hedge.transport._aiohttp import HedgedAiohttpSession
|
|
9
|
+
from hedge.transport._httpx import HedgedHttpxTransport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str): # type: ignore[no-untyped-def]
|
|
13
|
+
if name == "HedgedHttpxTransport":
|
|
14
|
+
from hedge.transport._httpx import HedgedHttpxTransport
|
|
15
|
+
|
|
16
|
+
return HedgedHttpxTransport
|
|
17
|
+
if name == "HedgedAiohttpSession":
|
|
18
|
+
from hedge.transport._aiohttp import HedgedAiohttpSession
|
|
19
|
+
|
|
20
|
+
return HedgedAiohttpSession
|
|
21
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["HedgedHttpxTransport", "HedgedAiohttpSession"]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Hedged session wrapper for aiohttp.ClientSession.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from hedge import HedgeConfig
|
|
6
|
+
from hedge.transport import HedgedAiohttpSession
|
|
7
|
+
|
|
8
|
+
async with HedgedAiohttpSession(config=HedgeConfig()) as session:
|
|
9
|
+
resp = await session.get("https://api.example.com/data")
|
|
10
|
+
data = await resp.json()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import aiohttp
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"aiohttp is required for HedgedAiohttpSession. "
|
|
26
|
+
"Install it with: pip install hedge-python[aiohttp]"
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
from hedge._options import HedgeConfig
|
|
30
|
+
from hedge.transport._base import HedgeScheduler
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from hedge._stats import Stats
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HedgedAiohttpSession:
|
|
37
|
+
"""An aiohttp session wrapper that adds adaptive hedged requests.
|
|
38
|
+
|
|
39
|
+
Wraps an ``aiohttp.ClientSession`` and races a backup request when the
|
|
40
|
+
primary exceeds its estimated latency percentile.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
config: Hedge configuration. Defaults to ``HedgeConfig()``.
|
|
44
|
+
**session_kwargs: Additional keyword arguments passed to
|
|
45
|
+
``aiohttp.ClientSession()``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
config: HedgeConfig | None = None,
|
|
51
|
+
**session_kwargs: Any,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._config = config or HedgeConfig()
|
|
54
|
+
self._session_kwargs = session_kwargs
|
|
55
|
+
self._session: aiohttp.ClientSession | None = None
|
|
56
|
+
self._scheduler = HedgeScheduler(self._config)
|
|
57
|
+
|
|
58
|
+
def _get_session(self) -> aiohttp.ClientSession:
|
|
59
|
+
if self._session is None or self._session.closed:
|
|
60
|
+
self._session = aiohttp.ClientSession(**self._session_kwargs)
|
|
61
|
+
return self._session
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def stats(self) -> Stats:
|
|
65
|
+
"""Access the live Stats object."""
|
|
66
|
+
return self._scheduler.stats
|
|
67
|
+
|
|
68
|
+
async def _request(
|
|
69
|
+
self,
|
|
70
|
+
method: str,
|
|
71
|
+
url: str,
|
|
72
|
+
**kwargs: Any,
|
|
73
|
+
) -> aiohttp.ClientResponse:
|
|
74
|
+
"""Perform a hedged request."""
|
|
75
|
+
parsed = urlparse(url)
|
|
76
|
+
host = parsed.netloc or parsed.hostname or url
|
|
77
|
+
sketch = self._scheduler.sketch_for(host)
|
|
78
|
+
|
|
79
|
+
can_hedge = method.upper() in ("GET", "HEAD", "OPTIONS")
|
|
80
|
+
session = self._get_session()
|
|
81
|
+
|
|
82
|
+
async def do_request() -> aiohttp.ClientResponse:
|
|
83
|
+
return await session.request(method, url, **kwargs)
|
|
84
|
+
|
|
85
|
+
def record_latency(response: aiohttp.ClientResponse, elapsed: float) -> None:
|
|
86
|
+
sketch.add(elapsed)
|
|
87
|
+
|
|
88
|
+
return await self._scheduler.execute_with_hedge(
|
|
89
|
+
host=host,
|
|
90
|
+
primary_func=do_request,
|
|
91
|
+
hedge_func=do_request,
|
|
92
|
+
record_latency=record_latency,
|
|
93
|
+
can_hedge=can_hedge,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def get(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
97
|
+
"""Perform a hedged GET request."""
|
|
98
|
+
return await self._request("GET", url, **kwargs)
|
|
99
|
+
|
|
100
|
+
async def post(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
101
|
+
"""Perform a POST request (no hedging; body cannot be safely replayed)."""
|
|
102
|
+
return await self._request("POST", url, **kwargs)
|
|
103
|
+
|
|
104
|
+
async def put(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
105
|
+
"""Perform a PUT request (no hedging)."""
|
|
106
|
+
return await self._request("PUT", url, **kwargs)
|
|
107
|
+
|
|
108
|
+
async def delete(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
109
|
+
"""Perform a DELETE request (no hedging)."""
|
|
110
|
+
return await self._request("DELETE", url, **kwargs)
|
|
111
|
+
|
|
112
|
+
async def head(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
113
|
+
"""Perform a hedged HEAD request."""
|
|
114
|
+
return await self._request("HEAD", url, **kwargs)
|
|
115
|
+
|
|
116
|
+
async def options(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
117
|
+
"""Perform a hedged OPTIONS request."""
|
|
118
|
+
return await self._request("OPTIONS", url, **kwargs)
|
|
119
|
+
|
|
120
|
+
async def close(self) -> None:
|
|
121
|
+
"""Close the session and stop background tasks."""
|
|
122
|
+
await self._scheduler.close()
|
|
123
|
+
if self._session and not self._session.closed:
|
|
124
|
+
await self._session.close()
|
|
125
|
+
|
|
126
|
+
async def __aenter__(self) -> HedgedAiohttpSession:
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
async def __aexit__(
|
|
130
|
+
self,
|
|
131
|
+
exc_type: type[BaseException] | None,
|
|
132
|
+
exc_val: BaseException | None,
|
|
133
|
+
exc_tb: TracebackType | None,
|
|
134
|
+
) -> None:
|
|
135
|
+
await self.close()
|
hedge/transport/_base.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Shared async hedge scheduling logic used by all transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
from hedge._stats import Stats
|
|
13
|
+
from hedge.budget import TokenBucket
|
|
14
|
+
from hedge.sketch import WindowedSketch
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Awaitable, Coroutine
|
|
18
|
+
|
|
19
|
+
from hedge._options import HedgeConfig
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HedgeScheduler:
|
|
25
|
+
"""Core async hedge scheduling shared across frameworks.
|
|
26
|
+
|
|
27
|
+
Manages per-host sketches, request counters, token bucket budget,
|
|
28
|
+
and the race-then-cancel logic.
|
|
29
|
+
|
|
30
|
+
This is an internal building block; users should use the framework-specific
|
|
31
|
+
transports (httpx, aiohttp) or interceptors (gRPC).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: HedgeConfig) -> None:
|
|
35
|
+
self.config = config
|
|
36
|
+
self.stats = config.stats or Stats()
|
|
37
|
+
self.budget = TokenBucket(config.budget_percent, config.estimated_rps)
|
|
38
|
+
self._sketches: dict[str, WindowedSketch] = {}
|
|
39
|
+
self._counters: dict[str, int] = defaultdict(int)
|
|
40
|
+
self._lock = asyncio.Lock()
|
|
41
|
+
|
|
42
|
+
def sketch_for(self, host: str) -> WindowedSketch:
|
|
43
|
+
"""Get or create a WindowedSketch for the given host."""
|
|
44
|
+
if host not in self._sketches:
|
|
45
|
+
sketch = WindowedSketch(
|
|
46
|
+
relative_accuracy=0.01,
|
|
47
|
+
window_duration=self.config.window_duration,
|
|
48
|
+
)
|
|
49
|
+
sketch.start_async()
|
|
50
|
+
self._sketches[host] = sketch
|
|
51
|
+
return self._sketches[host]
|
|
52
|
+
|
|
53
|
+
async def increment_counter(self, host: str) -> int:
|
|
54
|
+
"""Atomically increment and return the request counter for a host."""
|
|
55
|
+
async with self._lock:
|
|
56
|
+
self._counters[host] += 1
|
|
57
|
+
return self._counters[host]
|
|
58
|
+
|
|
59
|
+
def compute_hedge_delay(self, host: str, request_number: int) -> float:
|
|
60
|
+
"""Compute the hedge delay in seconds for a given host and request number."""
|
|
61
|
+
if request_number <= self.config.warmup_requests:
|
|
62
|
+
self.stats.increment_warmup()
|
|
63
|
+
delay = self.config.warmup_delay
|
|
64
|
+
else:
|
|
65
|
+
sketch = self.sketch_for(host)
|
|
66
|
+
estimate = sketch.quantile(self.config.percentile)
|
|
67
|
+
delay = estimate if estimate > 0 and not math.isnan(estimate) else self.config.warmup_delay
|
|
68
|
+
|
|
69
|
+
return max(delay, self.config.min_delay)
|
|
70
|
+
|
|
71
|
+
async def execute_with_hedge(
|
|
72
|
+
self,
|
|
73
|
+
host: str,
|
|
74
|
+
primary_func: Callable[[], Awaitable[T]],
|
|
75
|
+
hedge_func: Callable[[], Awaitable[T]],
|
|
76
|
+
record_latency: Callable[[T, float], None],
|
|
77
|
+
can_hedge: bool = True,
|
|
78
|
+
) -> T:
|
|
79
|
+
"""Execute primary request with hedge racing logic.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
host: Target host key for per-host sketch tracking.
|
|
83
|
+
primary_func: Async callable that performs the primary request.
|
|
84
|
+
hedge_func: Async callable that performs the hedge request.
|
|
85
|
+
record_latency: Callback to record latency to the sketch.
|
|
86
|
+
can_hedge: Whether the request is safe to hedge (idempotent).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The result from whichever request finishes first.
|
|
90
|
+
"""
|
|
91
|
+
self.stats.increment_total()
|
|
92
|
+
|
|
93
|
+
request_number = await self.increment_counter(host)
|
|
94
|
+
hedge_delay = self.compute_hedge_delay(host, request_number)
|
|
95
|
+
start = time.monotonic()
|
|
96
|
+
|
|
97
|
+
# Launch primary. ``primary_func()`` returns ``Awaitable[T]`` in the
|
|
98
|
+
# signature, but in practice transports always supply ``async def``
|
|
99
|
+
# callables (i.e. coroutine functions). ``asyncio.create_task`` only
|
|
100
|
+
# accepts coroutines, so cast for the type checker.
|
|
101
|
+
primary_coro = cast("Coroutine[Any, Any, T]", primary_func())
|
|
102
|
+
primary_task: asyncio.Task[T] = asyncio.create_task(primary_coro)
|
|
103
|
+
|
|
104
|
+
# Wait for hedge delay
|
|
105
|
+
done, _ = await asyncio.wait({primary_task}, timeout=hedge_delay)
|
|
106
|
+
if done:
|
|
107
|
+
result: T = primary_task.result()
|
|
108
|
+
elapsed = time.monotonic() - start
|
|
109
|
+
record_latency(result, elapsed)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
# Check if hedging is allowed
|
|
113
|
+
if not can_hedge:
|
|
114
|
+
result = await primary_task
|
|
115
|
+
elapsed = time.monotonic() - start
|
|
116
|
+
record_latency(result, elapsed)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
# Check budget
|
|
120
|
+
if not self.budget.try_acquire():
|
|
121
|
+
self.stats.increment_budget_exhausted()
|
|
122
|
+
result = await primary_task
|
|
123
|
+
elapsed = time.monotonic() - start
|
|
124
|
+
record_latency(result, elapsed)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
# Launch hedge
|
|
128
|
+
self.stats.increment_hedged()
|
|
129
|
+
hedge_coro = cast("Coroutine[Any, Any, T]", hedge_func())
|
|
130
|
+
hedge_task: asyncio.Task[T] = asyncio.create_task(hedge_coro)
|
|
131
|
+
|
|
132
|
+
done, pending = await asyncio.wait(
|
|
133
|
+
{primary_task, hedge_task},
|
|
134
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
winner_task = done.pop()
|
|
138
|
+
elapsed = time.monotonic() - start
|
|
139
|
+
|
|
140
|
+
# Cancel losers
|
|
141
|
+
for task in pending:
|
|
142
|
+
task.cancel()
|
|
143
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
144
|
+
await task
|
|
145
|
+
|
|
146
|
+
if winner_task is primary_task:
|
|
147
|
+
self.stats.increment_primary_wins()
|
|
148
|
+
else:
|
|
149
|
+
self.stats.increment_hedge_wins()
|
|
150
|
+
|
|
151
|
+
result = winner_task.result()
|
|
152
|
+
record_latency(result, elapsed)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
async def close(self) -> None:
|
|
156
|
+
"""Stop all background sketch rotation tasks."""
|
|
157
|
+
for sketch in self._sketches.values():
|
|
158
|
+
sketch.stop()
|
|
159
|
+
self._sketches.clear()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Hedged transport for httpx.AsyncClient.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from hedge import HedgeConfig
|
|
7
|
+
from hedge.transport import HedgedHttpxTransport
|
|
8
|
+
|
|
9
|
+
transport = HedgedHttpxTransport(config=HedgeConfig())
|
|
10
|
+
async with httpx.AsyncClient(transport=transport) as client:
|
|
11
|
+
resp = await client.get("https://api.example.com/data")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import httpx
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"httpx is required for HedgedHttpxTransport. "
|
|
23
|
+
"Install it with: pip install hedge-python[httpx]"
|
|
24
|
+
) from exc
|
|
25
|
+
|
|
26
|
+
from hedge._options import HedgeConfig
|
|
27
|
+
from hedge.transport._base import HedgeScheduler
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from hedge._stats import Stats
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HedgedHttpxTransport(httpx.AsyncBaseTransport):
|
|
34
|
+
"""An httpx async transport that adds adaptive hedged requests.
|
|
35
|
+
|
|
36
|
+
Wraps an inner transport (default: ``httpx.AsyncHTTPTransport``) and
|
|
37
|
+
races a backup request when the primary exceeds its estimated latency
|
|
38
|
+
percentile.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
inner: The underlying transport to wrap. Defaults to a new
|
|
42
|
+
``httpx.AsyncHTTPTransport()``.
|
|
43
|
+
config: Hedge configuration. Defaults to ``HedgeConfig()``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
inner: httpx.AsyncBaseTransport | None = None,
|
|
49
|
+
config: HedgeConfig | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._inner = inner or httpx.AsyncHTTPTransport()
|
|
52
|
+
self._config = config or HedgeConfig()
|
|
53
|
+
self._scheduler = HedgeScheduler(self._config)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def stats(self) -> Stats:
|
|
57
|
+
"""Access the live Stats object."""
|
|
58
|
+
return self._scheduler.stats
|
|
59
|
+
|
|
60
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
61
|
+
"""Handle an outgoing request with adaptive hedging."""
|
|
62
|
+
host = str(request.url.host)
|
|
63
|
+
sketch = self._scheduler.sketch_for(host)
|
|
64
|
+
|
|
65
|
+
can_hedge = request.method.upper() in ("GET", "HEAD", "OPTIONS")
|
|
66
|
+
|
|
67
|
+
async def do_request() -> httpx.Response:
|
|
68
|
+
return await self._inner.handle_async_request(request)
|
|
69
|
+
|
|
70
|
+
def record_latency(response: httpx.Response, elapsed: float) -> None:
|
|
71
|
+
sketch.add(elapsed)
|
|
72
|
+
|
|
73
|
+
return await self._scheduler.execute_with_hedge(
|
|
74
|
+
host=host,
|
|
75
|
+
primary_func=do_request,
|
|
76
|
+
hedge_func=do_request,
|
|
77
|
+
record_latency=record_latency,
|
|
78
|
+
can_hedge=can_hedge,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def aclose(self) -> None:
|
|
82
|
+
"""Close the transport and stop background tasks."""
|
|
83
|
+
await self._scheduler.close()
|
|
84
|
+
await self._inner.aclose()
|