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.
@@ -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()
@@ -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()