rotapool 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.
- rotapool/__init__.py +15 -0
- rotapool/exceptions.py +36 -0
- rotapool/models.py +49 -0
- rotapool/pool.py +424 -0
- rotapool-0.1.0.dist-info/METADATA +396 -0
- rotapool-0.1.0.dist-info/RECORD +8 -0
- rotapool-0.1.0.dist-info/WHEEL +4 -0
- rotapool-0.1.0.dist-info/licenses/LICENSE +21 -0
rotapool/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from importlib.metadata import version as _version
|
|
2
|
+
|
|
3
|
+
from .exceptions import CooldownResource, DisableResource, PoolExhausted
|
|
4
|
+
from .models import Resource
|
|
5
|
+
from .pool import Pool
|
|
6
|
+
|
|
7
|
+
__version__ = _version("rotapool")
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CooldownResource",
|
|
10
|
+
"DisableResource",
|
|
11
|
+
"Pool",
|
|
12
|
+
"PoolExhausted",
|
|
13
|
+
"Resource",
|
|
14
|
+
"__version__",
|
|
15
|
+
]
|
rotapool/exceptions.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CooldownResource(Exception):
|
|
5
|
+
"""Raise from a user operation to mark the resource as cooling_down.
|
|
6
|
+
|
|
7
|
+
cooldown_seconds: explicit cooldown duration (e.g. derived from a Retry-After
|
|
8
|
+
header). If None, the framework's default cooldown table applies based on
|
|
9
|
+
consecutive_cooldown count.
|
|
10
|
+
reason: free-form string surfaced in logs and metrics.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, cooldown_seconds: float | None = None, reason: str | None = None
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(reason or "resource cooldown")
|
|
17
|
+
self.cooldown_seconds = cooldown_seconds
|
|
18
|
+
self.reason = reason
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DisableResource(Exception):
|
|
22
|
+
"""Raise from a user operation to mark the resource as disabled."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, reason: str | None = None) -> None:
|
|
25
|
+
super().__init__(reason or "resource disabled")
|
|
26
|
+
self.reason = reason
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PoolExhausted(Exception):
|
|
30
|
+
"""Raised by the framework when the pool cannot satisfy a request.
|
|
31
|
+
|
|
32
|
+
Covers three scenarios:
|
|
33
|
+
- No eligible resource exists (all disabled, cooling down, or at capacity).
|
|
34
|
+
- Max retry attempts exhausted.
|
|
35
|
+
- Deadline exceeded.
|
|
36
|
+
"""
|
rotapool/models.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Resource(Generic[T]):
|
|
12
|
+
"""A single rotatable resource.
|
|
13
|
+
|
|
14
|
+
`cooldown_until` and `last_acquired_at` are `time.monotonic()` readings, not epoch
|
|
15
|
+
timestamps. They are only meaningful when compared to another `time.monotonic()`
|
|
16
|
+
call in the same process — do not log, persist, or pass to `datetime.fromtimestamp`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
resource_id: str
|
|
20
|
+
value: T
|
|
21
|
+
|
|
22
|
+
max_in_flight: int | None = None # None = unbounded concurrency
|
|
23
|
+
status: str = "healthy"
|
|
24
|
+
cooldown_until: float = 0.0
|
|
25
|
+
last_acquired_at: float = 0.0
|
|
26
|
+
consecutive_cooldown: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Usage:
|
|
31
|
+
"""One in-flight use of a resource.
|
|
32
|
+
|
|
33
|
+
`acquired_at` is a `time.monotonic()` reading, not an epoch timestamp — only
|
|
34
|
+
meaningful relative to other `time.monotonic()` calls in this process.
|
|
35
|
+
|
|
36
|
+
`task` holds a cancellable handle for the in-flight operation:
|
|
37
|
+
- `asyncio.Task` when the operation returned a coroutine (framework wrapped it).
|
|
38
|
+
- `asyncio.Future` when the operation directly returned a Future.
|
|
39
|
+
- `None` when the operation returned a plain Awaitable with no `.cancel()`
|
|
40
|
+
method. In that case `cancel_younger_usages` silently no-ops on this usage
|
|
41
|
+
and it runs to natural completion -- cancellation is best-effort by design.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
usage_id: str
|
|
45
|
+
request_id: str
|
|
46
|
+
resource_id: str
|
|
47
|
+
acquired_at: float
|
|
48
|
+
task: asyncio.Future | None = None
|
|
49
|
+
status: str = "in_flight" # in_flight | done | cancelled
|
rotapool/pool.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Awaitable, Callable, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
from .exceptions import CooldownResource, DisableResource, PoolExhausted
|
|
11
|
+
from .models import Resource, Usage
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
R = TypeVar("R")
|
|
15
|
+
|
|
16
|
+
_DEFAULT_COOLDOWN_TABLE: tuple[float, ...] = (30.0, 120.0, 300.0, 600.0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Pool(Generic[T]):
|
|
20
|
+
"""A pool of rotatable resources sharing the same usage policy.
|
|
21
|
+
|
|
22
|
+
Selection prefers resources with fewer in-flight usages, then older last acquisition
|
|
23
|
+
time, excludes cooling down and disabled resources.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
resources: list[Resource[T]] | dict[str, Resource[T]],
|
|
29
|
+
max_attempts: int = 3,
|
|
30
|
+
cooldown_table: tuple[float, ...] = _DEFAULT_COOLDOWN_TABLE,
|
|
31
|
+
) -> None:
|
|
32
|
+
# resource_id -> resource
|
|
33
|
+
self._resources: dict[str, Resource[T]] = self._build_resources(resources)
|
|
34
|
+
if not self._resources:
|
|
35
|
+
raise ValueError("Pool requires at least one resource")
|
|
36
|
+
|
|
37
|
+
# Total attempts per `run()` call, not per-resource. Each attempt selects a
|
|
38
|
+
# fresh resource via the pool's normal selection rules; a resource that
|
|
39
|
+
# triggered cooldown or disable on one attempt is skipped on the next.
|
|
40
|
+
# Effectively capped at `len(resources)`: once every resource has been tried and
|
|
41
|
+
# none is eligible, `run()` raises `PoolExhausted` rather than retrying any one
|
|
42
|
+
# twice.
|
|
43
|
+
self._max_attempts: int = max_attempts
|
|
44
|
+
|
|
45
|
+
# Cooldown times before a resource can be reactivated, indexed by
|
|
46
|
+
# consecutive_cooldown count.
|
|
47
|
+
self._cooldown_table: tuple[float, ...] = cooldown_table
|
|
48
|
+
|
|
49
|
+
# Guards all possibly racing states.
|
|
50
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
51
|
+
|
|
52
|
+
# usage_id -> Usage
|
|
53
|
+
self._usages: dict[str, Usage] = {}
|
|
54
|
+
|
|
55
|
+
# resource_id -> { usage_id_set }
|
|
56
|
+
self._inflight_by_resource: dict[str, set[str]] = {}
|
|
57
|
+
|
|
58
|
+
async def run(
|
|
59
|
+
self,
|
|
60
|
+
operation: Callable[[Resource[T]], Awaitable[R]],
|
|
61
|
+
*,
|
|
62
|
+
max_attempts: int | None = None,
|
|
63
|
+
deadline: float | None = None,
|
|
64
|
+
retry_delay: float = 0.5,
|
|
65
|
+
request_id: str | None = None,
|
|
66
|
+
) -> R:
|
|
67
|
+
"""Drive the retry loop for one logical request.
|
|
68
|
+
|
|
69
|
+
operation: callable receiving the selected resource and returning an Awaitable.
|
|
70
|
+
May raise CooldownResource or DisableResource to signal resource health. Any
|
|
71
|
+
other exception is treated as resource OK and propagates to the caller (so
|
|
72
|
+
user-side bugs do not poison the pool).
|
|
73
|
+
|
|
74
|
+
The returned awaitable can be:
|
|
75
|
+
- a coroutine (the typical case for `async def` operations) -- the framework
|
|
76
|
+
wraps it in an `asyncio.Task` so younger sibling cancellation works.
|
|
77
|
+
- an `asyncio.Future` -- cancellable directly via its `.cancel()` method.
|
|
78
|
+
- any other Awaitable (custom `__await__` object, etc.) -- awaited directly,
|
|
79
|
+
with cancellation a silent best-effort no-op for this usage.
|
|
80
|
+
|
|
81
|
+
Returning a non-awaitable raises `TypeError` (treated as a user bug; the
|
|
82
|
+
resource is marked healthy and the error propagates to the caller).
|
|
83
|
+
|
|
84
|
+
max_attempts: per-call override of Pool.__init__ max_attempts.
|
|
85
|
+
This is a total budget across resource switches, not per resource.
|
|
86
|
+
Effective value is ``min(max_attempts, len(resources))``.
|
|
87
|
+
|
|
88
|
+
deadline: absolute time.monotonic() value bounding total time across retries.
|
|
89
|
+
None disables the deadline.
|
|
90
|
+
|
|
91
|
+
retry_delay: pause between failed attempts to let cooling resources recover and
|
|
92
|
+
to avoid hammering the pool.
|
|
93
|
+
|
|
94
|
+
request_id: opaque string attached to every `Usage` created by this call.
|
|
95
|
+
Useful for correlating logs, metrics, or tracing back to the original
|
|
96
|
+
caller (e.g. an HTTP request-id header). Auto-generated UUID when None.
|
|
97
|
+
"""
|
|
98
|
+
rid = request_id or str(uuid.uuid4())
|
|
99
|
+
cap = max_attempts if max_attempts is not None else self._max_attempts
|
|
100
|
+
effective_attempts = min(cap, len(self._resources))
|
|
101
|
+
last_error: BaseException | None = None
|
|
102
|
+
|
|
103
|
+
for attempt_num in range(effective_attempts):
|
|
104
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
105
|
+
raise PoolExhausted(f"deadline exceeded after {attempt_num} attempt(s)")
|
|
106
|
+
|
|
107
|
+
acquired = await self._acquire(rid)
|
|
108
|
+
if acquired is None:
|
|
109
|
+
raise PoolExhausted("no eligible resource in pool")
|
|
110
|
+
resource, usage = acquired
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
awaited = operation(resource)
|
|
114
|
+
|
|
115
|
+
if inspect.iscoroutine(awaited):
|
|
116
|
+
# Wrap in Task so younger-usage cancellation can fire. No await
|
|
117
|
+
# between create_task and the assignment -- atomically safe in
|
|
118
|
+
# single-loop asyncio; no other coroutine interleaves.
|
|
119
|
+
task = asyncio.create_task(awaited)
|
|
120
|
+
usage.task = task
|
|
121
|
+
result = await task
|
|
122
|
+
elif isinstance(awaited, asyncio.Future):
|
|
123
|
+
# Future is cancellable via .cancel() without wrapping.
|
|
124
|
+
usage.task = awaited
|
|
125
|
+
result = await awaited
|
|
126
|
+
elif inspect.isawaitable(awaited):
|
|
127
|
+
# Plain awaitable with no cancel handle. Cancellation of younger
|
|
128
|
+
# usages on this resource is best-effort -- this usage runs to
|
|
129
|
+
# natural completion if a sibling fails.
|
|
130
|
+
result = await awaited
|
|
131
|
+
else:
|
|
132
|
+
raise TypeError(
|
|
133
|
+
f"operation must return an Awaitable, got {type(awaited).__name__}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
await self._on_ok(usage)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
except CooldownResource as e:
|
|
140
|
+
await self._on_cooldown(usage, cooldown_seconds=e.cooldown_seconds)
|
|
141
|
+
last_error = e
|
|
142
|
+
if attempt_num < effective_attempts - 1:
|
|
143
|
+
await asyncio.sleep(retry_delay)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
except DisableResource as e:
|
|
147
|
+
await self._on_disable(usage)
|
|
148
|
+
last_error = e
|
|
149
|
+
if attempt_num < effective_attempts - 1:
|
|
150
|
+
await asyncio.sleep(retry_delay)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
except asyncio.CancelledError:
|
|
154
|
+
# Distinguish "outer caller cancelled us" (re-raise so shutdown is
|
|
155
|
+
# honored) from "we cancelled our own handle via _on_cooldown /
|
|
156
|
+
# _on_disable" (swallow and retry). _collect_younger_usages_locked sets
|
|
157
|
+
# usage.status = "cancelled" under the lock *before* invoking .cancel()
|
|
158
|
+
# on the handle, so seeing "cancelled" here means a sibling on the same
|
|
159
|
+
# resource cancelled us. Works on any Python 3.10+ (no
|
|
160
|
+
# asyncio.Task.cancelling() dependency). Cleanup runs in finally.
|
|
161
|
+
cancelled_internally = usage.status == "cancelled"
|
|
162
|
+
usage.status = "cancelled"
|
|
163
|
+
if not cancelled_internally:
|
|
164
|
+
raise
|
|
165
|
+
last_error = asyncio.CancelledError()
|
|
166
|
+
if attempt_num < effective_attempts - 1:
|
|
167
|
+
await asyncio.sleep(retry_delay)
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
except Exception:
|
|
171
|
+
# Ordinary user/business exception: the resource is fine.
|
|
172
|
+
# Mark OK and propagate the exception unchanged to the caller.
|
|
173
|
+
await self._on_ok(usage)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
finally:
|
|
177
|
+
await self._cleanup_usage(usage)
|
|
178
|
+
|
|
179
|
+
# Loop only exits without returning when an attempt failed and set last_error;
|
|
180
|
+
# a clean exit (no failure) returns from inside the loop.
|
|
181
|
+
raise PoolExhausted(
|
|
182
|
+
f"max_attempts={effective_attempts} exhausted: {last_error!r}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def rotated(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
max_attempts: int | None = None,
|
|
189
|
+
deadline: float | None = None,
|
|
190
|
+
retry_delay: float = 0.5,
|
|
191
|
+
request_id: str | None = None,
|
|
192
|
+
) -> Callable[[Callable[..., Awaitable[R]]], Callable[..., Awaitable[R]]]:
|
|
193
|
+
"""Decorator factory: wrap a callable so every call goes through ``self.run()``
|
|
194
|
+
with resource rotation and retry.
|
|
195
|
+
|
|
196
|
+
The decorated function receives a ``Resource[T]`` as its first positional
|
|
197
|
+
argument (injected by the wrapper), followed by whatever the caller passes.
|
|
198
|
+
|
|
199
|
+
Any callable returning an Awaitable is accepted -- typically `async def`
|
|
200
|
+
functions, but plain functions returning a coroutine, an `asyncio.Future`, or
|
|
201
|
+
any awaitable also work. Cancellation of younger sibling usages is best-effort:
|
|
202
|
+
it works for coroutines and Futures, and silently no-ops for plain awaitables.
|
|
203
|
+
A callable that returns a non-awaitable raises `TypeError` at call time.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def decorator(func: Callable[..., Awaitable[R]]) -> Callable[..., Awaitable[R]]:
|
|
207
|
+
@functools.wraps(func)
|
|
208
|
+
async def wrapper(*args: Any, **kwargs: Any) -> R:
|
|
209
|
+
return await self.run(
|
|
210
|
+
lambda resource: func(resource, *args, **kwargs),
|
|
211
|
+
max_attempts=max_attempts,
|
|
212
|
+
deadline=deadline,
|
|
213
|
+
retry_delay=retry_delay,
|
|
214
|
+
request_id=request_id,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return wrapper
|
|
218
|
+
|
|
219
|
+
return decorator
|
|
220
|
+
|
|
221
|
+
def snapshot(self) -> dict[str, dict[str, Any]]:
|
|
222
|
+
"""Return a point-in-time summary of every resource in the pool.
|
|
223
|
+
|
|
224
|
+
Thread-safe without the lock -- reads simple types (str, int, float) and
|
|
225
|
+
Python-int counters that change atomically under the GIL. Good enough for
|
|
226
|
+
metrics / /status.
|
|
227
|
+
"""
|
|
228
|
+
now = time.monotonic()
|
|
229
|
+
result: dict[str, dict[str, Any]] = {}
|
|
230
|
+
for rid, r in self._resources.items():
|
|
231
|
+
inflight = len(self._inflight_by_resource.get(rid, set()))
|
|
232
|
+
cooldown_remaining = (
|
|
233
|
+
max(r.cooldown_until - now, 0.0) if r.status == "cooling_down" else 0.0
|
|
234
|
+
)
|
|
235
|
+
result[rid] = {
|
|
236
|
+
"status": r.status,
|
|
237
|
+
"in_flight": inflight,
|
|
238
|
+
"consecutive_cooldown": r.consecutive_cooldown,
|
|
239
|
+
"cooldown_seconds_remaining": cooldown_remaining,
|
|
240
|
+
"last_acquired_at": r.last_acquired_at,
|
|
241
|
+
}
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _build_resources(
|
|
246
|
+
resources: list[Resource[T]] | dict[str, Resource[T]],
|
|
247
|
+
) -> dict[str, Resource[T]]:
|
|
248
|
+
if isinstance(resources, list):
|
|
249
|
+
result: dict[str, Resource[T]] = {}
|
|
250
|
+
for r in resources:
|
|
251
|
+
if r.resource_id in result:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"Duplicate resource_id in pool: {r.resource_id!r}"
|
|
254
|
+
)
|
|
255
|
+
result[r.resource_id] = r
|
|
256
|
+
return result
|
|
257
|
+
return dict(resources)
|
|
258
|
+
|
|
259
|
+
async def _acquire(self, request_id: str) -> tuple[Resource[T], Usage] | None:
|
|
260
|
+
"""Atomically select an eligible resource and register a usage on it.
|
|
261
|
+
|
|
262
|
+
Returns (resource, usage) on success or None if no resource is eligible (all
|
|
263
|
+
disabled, all cooling down, or all at `max_in_flight` capacity). Selection and
|
|
264
|
+
registration share one lock acquisition to keep the derived in-flight count
|
|
265
|
+
consistent.
|
|
266
|
+
"""
|
|
267
|
+
now = time.monotonic()
|
|
268
|
+
|
|
269
|
+
async with self._lock:
|
|
270
|
+
candidates: list[Resource[T]] = []
|
|
271
|
+
|
|
272
|
+
for r in self._resources.values():
|
|
273
|
+
if r.status == "disabled":
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
if r.status == "cooling_down":
|
|
277
|
+
if r.cooldown_until <= now:
|
|
278
|
+
r.status = "healthy"
|
|
279
|
+
else:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
current = len(self._inflight_by_resource.get(r.resource_id, set()))
|
|
283
|
+
if r.max_in_flight is not None and current >= r.max_in_flight:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
candidates.append(r)
|
|
287
|
+
|
|
288
|
+
if not candidates:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
candidates.sort(
|
|
292
|
+
key=lambda r: (
|
|
293
|
+
len(self._inflight_by_resource.get(r.resource_id, set())),
|
|
294
|
+
r.last_acquired_at,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
selected = candidates[0]
|
|
298
|
+
selected.last_acquired_at = now
|
|
299
|
+
usage = Usage(
|
|
300
|
+
usage_id=str(uuid.uuid4()),
|
|
301
|
+
request_id=request_id,
|
|
302
|
+
resource_id=selected.resource_id,
|
|
303
|
+
acquired_at=now,
|
|
304
|
+
)
|
|
305
|
+
self._usages[usage.usage_id] = usage
|
|
306
|
+
self._inflight_by_resource.setdefault(selected.resource_id, set()).add(
|
|
307
|
+
usage.usage_id
|
|
308
|
+
)
|
|
309
|
+
return selected, usage
|
|
310
|
+
|
|
311
|
+
async def _on_ok(self, usage: Usage) -> None:
|
|
312
|
+
"""Resource is operational. Reset cooldown state.
|
|
313
|
+
|
|
314
|
+
Called whenever the user operation returns normally OR raises a non-resource
|
|
315
|
+
exception -- anything that proves the resource itself works, regardless of
|
|
316
|
+
business outcome.
|
|
317
|
+
|
|
318
|
+
Only resets cooldown state when the resource is currently healthy. If a
|
|
319
|
+
concurrent failure has since moved it to cooling_down or disabled, that more
|
|
320
|
+
recent signal wins -- e.g. an older usage that started before a 429 succeeds
|
|
321
|
+
after a younger sibling triggered the cooldown; its success does not prove
|
|
322
|
+
the rate limit lifted, so we leave the cooldown in place.
|
|
323
|
+
"""
|
|
324
|
+
async with self._lock:
|
|
325
|
+
usage.status = "done"
|
|
326
|
+
resource = self._resources.get(usage.resource_id)
|
|
327
|
+
if resource is not None and resource.status == "healthy":
|
|
328
|
+
resource.cooldown_until = 0.0
|
|
329
|
+
resource.consecutive_cooldown = 0
|
|
330
|
+
|
|
331
|
+
async def _on_cooldown(
|
|
332
|
+
self, usage: Usage, cooldown_seconds: float | None = None
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Resource is temporarily over capacity. Mark cooling_down and cancel younger
|
|
335
|
+
usages on the same resource so they can retry elsewhere.
|
|
336
|
+
|
|
337
|
+
cooldown_seconds: explicit duration (e.g. from a Retry-After header). If None,
|
|
338
|
+
falls back to this pool's cooldown_table.
|
|
339
|
+
"""
|
|
340
|
+
now = time.monotonic()
|
|
341
|
+
to_cancel: list[Usage] = []
|
|
342
|
+
|
|
343
|
+
async with self._lock:
|
|
344
|
+
usage.status = "done"
|
|
345
|
+
resource = self._resources.get(usage.resource_id)
|
|
346
|
+
if resource is None or resource.status == "disabled":
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
resource.consecutive_cooldown += 1
|
|
350
|
+
|
|
351
|
+
if cooldown_seconds is not None:
|
|
352
|
+
cd = cooldown_seconds
|
|
353
|
+
else:
|
|
354
|
+
idx = max(resource.consecutive_cooldown - 1, 0)
|
|
355
|
+
idx = min(idx, len(self._cooldown_table) - 1)
|
|
356
|
+
cd = self._cooldown_table[idx]
|
|
357
|
+
|
|
358
|
+
resource.status = "cooling_down"
|
|
359
|
+
resource.cooldown_until = max(resource.cooldown_until, now + cd)
|
|
360
|
+
|
|
361
|
+
to_cancel = self._collect_younger_usages_locked(usage)
|
|
362
|
+
|
|
363
|
+
self._cancel_tasks(to_cancel)
|
|
364
|
+
|
|
365
|
+
async def _on_disable(self, usage: Usage) -> None:
|
|
366
|
+
"""Resource is permanently bad. Mark disabled and cancel younger usages on the
|
|
367
|
+
same resource so they can retry elsewhere.
|
|
368
|
+
|
|
369
|
+
The triggering usage itself is excluded from cancellation -- its own cleanup is
|
|
370
|
+
handled by `run()`'s finally block.
|
|
371
|
+
"""
|
|
372
|
+
to_cancel: list[Usage] = []
|
|
373
|
+
|
|
374
|
+
async with self._lock:
|
|
375
|
+
usage.status = "done"
|
|
376
|
+
resource = self._resources.get(usage.resource_id)
|
|
377
|
+
if resource is not None:
|
|
378
|
+
resource.status = "disabled"
|
|
379
|
+
|
|
380
|
+
to_cancel = self._collect_younger_usages_locked(usage)
|
|
381
|
+
|
|
382
|
+
self._cancel_tasks(to_cancel)
|
|
383
|
+
|
|
384
|
+
async def _cleanup_usage(self, usage: Usage) -> None:
|
|
385
|
+
"""Remove a usage from tracking. Implicitly decrements the derived in-flight
|
|
386
|
+
count for the resource. Idempotent."""
|
|
387
|
+
async with self._lock:
|
|
388
|
+
ids = self._inflight_by_resource.get(usage.resource_id)
|
|
389
|
+
if ids is not None:
|
|
390
|
+
ids.discard(usage.usage_id)
|
|
391
|
+
if not ids:
|
|
392
|
+
self._inflight_by_resource.pop(usage.resource_id, None)
|
|
393
|
+
|
|
394
|
+
self._usages.pop(usage.usage_id, None)
|
|
395
|
+
|
|
396
|
+
def _collect_younger_usages_locked(self, failed_usage: Usage) -> list[Usage]:
|
|
397
|
+
"""Mark and return usages on the same resource with acquired_at > failed.
|
|
398
|
+
|
|
399
|
+
MUST be called with `self._lock` held. Older usages are NOT touched -- they may
|
|
400
|
+
still succeed (e.g. an upstream request that the remote side already accepted).
|
|
401
|
+
The failed usage itself is also excluded.
|
|
402
|
+
"""
|
|
403
|
+
to_cancel: list[Usage] = []
|
|
404
|
+
ids = self._inflight_by_resource.get(failed_usage.resource_id, set())
|
|
405
|
+
for usage_id in ids:
|
|
406
|
+
other = self._usages.get(usage_id)
|
|
407
|
+
if other is None:
|
|
408
|
+
continue
|
|
409
|
+
if (
|
|
410
|
+
other.status == "in_flight"
|
|
411
|
+
and other.acquired_at > failed_usage.acquired_at
|
|
412
|
+
and other.usage_id != failed_usage.usage_id
|
|
413
|
+
):
|
|
414
|
+
other.status = "cancelled"
|
|
415
|
+
to_cancel.append(other)
|
|
416
|
+
return to_cancel
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _cancel_tasks(usages: list[Usage]) -> None:
|
|
420
|
+
# Cancel outside the lock -- task.cancel() can trigger callbacks that try to
|
|
421
|
+
# reacquire it.
|
|
422
|
+
for u in usages:
|
|
423
|
+
if u.task is not None:
|
|
424
|
+
u.task.cancel()
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rotapool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generic async resource pool with rotation, cooldown, and retry
|
|
5
|
+
Project-URL: Source, https://github.com/zydo/rotapool
|
|
6
|
+
Project-URL: Issues, https://github.com/zydo/rotapool/issues
|
|
7
|
+
Author: zydo
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: async,pool,rate-limit,resource-management,retry,rotation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# rotapool
|
|
26
|
+
|
|
27
|
+
Async resource pool with inline health feedback, automatic cooldown, and retry — for API keys, proxies, GPU workers, or anything that can rate-limit you or go down.
|
|
28
|
+
|
|
29
|
+
## Core idea
|
|
30
|
+
|
|
31
|
+
Most resource pools are passive — they hand out resources round-robin or at random, and rely on external health checks to detect and remove bad ones. `rotapool` closes that gap: every call through the pool is also a health probe. The pool learns from caller signals in real time and immediately adjusts which resources to offer — no external probers or manual updates needed.
|
|
32
|
+
|
|
33
|
+
Not every failure means the resource is bad — an HTTP 400 is your bug, but a 429 is the key's problem. You tell `rotapool` which is which by raising exceptions from inside your operation, and the pool reacts accordingly:
|
|
34
|
+
|
|
35
|
+
| Signal | Meaning |
|
|
36
|
+
| ----------------------------------- | --------------------------------------- |
|
|
37
|
+
| normal return / any other exception | Resource is healthy |
|
|
38
|
+
| `CooldownResource` | Temporarily overloaded (e.g. 429) |
|
|
39
|
+
| `DisableResource` | Permanently unusable (e.g. revoked key) |
|
|
40
|
+
|
|
41
|
+
`rotapool` handles the rest — picks the best resource, cools down bad ones, cancels doomed in-flight work, and retries automatically.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install rotapool
|
|
47
|
+
# or
|
|
48
|
+
uv add rotapool
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Python 3.10+.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
### Initialize the pool
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from rotapool import CooldownResource, DisableResource, Pool, Resource
|
|
59
|
+
|
|
60
|
+
# Define your resources (e.g. API keys)
|
|
61
|
+
pool = Pool(
|
|
62
|
+
# A list or dict of Resource objects. Dict keys are used as resource IDs.
|
|
63
|
+
resources=[
|
|
64
|
+
Resource(
|
|
65
|
+
resource_id="key-1", # Unique identifier (used in logs, metrics, snapshot)
|
|
66
|
+
value="sk-aaa", # The actual resource value (generic type T)
|
|
67
|
+
# max_in_flight=None, # Max concurrent usages per resource (None = unlimited)
|
|
68
|
+
),
|
|
69
|
+
Resource(resource_id="key-2", value="sk-bbb"),
|
|
70
|
+
Resource(resource_id="key-3", value="sk-ccc"),
|
|
71
|
+
],
|
|
72
|
+
max_attempts=3, # Total retry budget per run() call (capped at len(resources))
|
|
73
|
+
cooldown_table=(30.0, 120.0, 300.0, 600.0), # Escalation: 1st=30s, 2nd=120s, 3rd=300s, 4th+=600s
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Option 1: Use the decorator
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# Resource rotation happens automatically.
|
|
81
|
+
# All parameters are optional and forward to pool.run() on every call.
|
|
82
|
+
@pool.rotated(
|
|
83
|
+
max_attempts=None, # Override the pool's max_attempts for this decorated function
|
|
84
|
+
deadline=None, # Absolute time.monotonic() deadline; None = no deadline
|
|
85
|
+
retry_delay=0.5, # Seconds to pause between failed attempts
|
|
86
|
+
request_id=None, # Opaque string attached to every Usage (e.g. HTTP request-id); auto-UUID if None
|
|
87
|
+
)
|
|
88
|
+
async def call_upstream(resource, url, payload):
|
|
89
|
+
async with httpx.AsyncClient() as client:
|
|
90
|
+
resp = await client.post(
|
|
91
|
+
url,
|
|
92
|
+
headers={"Authorization": f"Bearer {resource.value}"},
|
|
93
|
+
json=payload,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if resp.status_code == 429:
|
|
97
|
+
raise CooldownResource(
|
|
98
|
+
cooldown_seconds=parse_retry_after(resp.headers.get("retry-after")),
|
|
99
|
+
reason="rate limited",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if resp.status_code == 401:
|
|
103
|
+
raise DisableResource(reason="invalid key")
|
|
104
|
+
|
|
105
|
+
return resp.json()
|
|
106
|
+
|
|
107
|
+
# Call it — the framework picks the best key and retries on failure
|
|
108
|
+
result = await call_upstream("https://api.example.com/v1/chat", {"prompt": "hi"})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Option 2: Direct `run()`
|
|
112
|
+
|
|
113
|
+
`@pool.rotated()` is a thin shim over `pool.run()`. Use `run()` directly when you want per-call overrides or when the call site can't be decorated:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
async def call_upstream(resource, url, payload):
|
|
117
|
+
async with httpx.AsyncClient() as client:
|
|
118
|
+
resp = await client.post(
|
|
119
|
+
url,
|
|
120
|
+
headers={"Authorization": f"Bearer {resource.value}"},
|
|
121
|
+
json=payload,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if resp.status_code == 429:
|
|
125
|
+
raise CooldownResource(reason="rate limited")
|
|
126
|
+
if resp.status_code == 401:
|
|
127
|
+
raise DisableResource(reason="invalid key")
|
|
128
|
+
|
|
129
|
+
return resp.json()
|
|
130
|
+
|
|
131
|
+
# Operation receives the selected Resource as its first argument.
|
|
132
|
+
result = await pool.run(
|
|
133
|
+
lambda resource: call_upstream(resource, "https://api.example.com/v1/chat", {"prompt": "hi"}),
|
|
134
|
+
max_attempts=None, # Override the pool's max_attempts for this call only
|
|
135
|
+
deadline=time.monotonic() + 30, # Absolute time.monotonic() deadline bounding total retry time
|
|
136
|
+
retry_delay=0.5, # Seconds to pause between failed attempts
|
|
137
|
+
request_id="req-abc", # Opaque string attached to every Usage; auto-UUID when None
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## How it works
|
|
142
|
+
|
|
143
|
+
### Selection
|
|
144
|
+
|
|
145
|
+
When multiple resources are healthy, the pool picks the one with:
|
|
146
|
+
|
|
147
|
+
1. **Fewest in-flight usages** (load spreading)
|
|
148
|
+
2. **Oldest `last_acquired_at`** (round-robin fairness)
|
|
149
|
+
|
|
150
|
+
Selection and usage registration are atomic under one lock acquisition.
|
|
151
|
+
|
|
152
|
+
### Cooldown escalation
|
|
153
|
+
|
|
154
|
+
Each consecutive `CooldownResource` from the same resource escalates the cooldown:
|
|
155
|
+
|
|
156
|
+
| Consecutive count | Cooldown |
|
|
157
|
+
| ----------------- | -------- |
|
|
158
|
+
| 1st | 30s |
|
|
159
|
+
| 2nd | 120s |
|
|
160
|
+
| 3rd | 300s |
|
|
161
|
+
| 4th+ | 600s |
|
|
162
|
+
|
|
163
|
+
You can override per-event: `CooldownResource(cooldown_seconds=5)` (e.g. from a `Retry-After` header). The counter resets on the next success.
|
|
164
|
+
|
|
165
|
+
Custom tables are supported per pool:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
pool = Pool(
|
|
169
|
+
resources=[...],
|
|
170
|
+
cooldown_table=(10.0, 30.0, 60.0, 120.0),
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### In-flight cancellation (best-effort)
|
|
175
|
+
|
|
176
|
+
When a resource receives a `CooldownResource` or `DisableResource` signal, the framework cancels **younger** in-flight usages on the same resource. Older usages are left alone — they may still succeed. This maximises throughput while avoiding doomed requests.
|
|
177
|
+
|
|
178
|
+
Cancellation is **best-effort**: it works when the operation returns a coroutine (the framework wraps it in an `asyncio.Task`) or an `asyncio.Future` (cancelled directly). For plain awaitables with no `.cancel()` handle, cancellation silently no-ops for that usage and it runs to natural completion. Within a coroutine, the underlying I/O is only truly aborted if the operation uses cancellation-aware async libs (`httpx.AsyncClient`, `aiohttp`).
|
|
179
|
+
|
|
180
|
+
### Retry
|
|
181
|
+
|
|
182
|
+
`pool.run()` drives the retry loop. `@pool.rotated()` is a thin decorator shim over it. Attempts are capped at `min(max_attempts, len(resources))` — more retries than resources is pointless.
|
|
183
|
+
|
|
184
|
+
### Cancellation discrimination
|
|
185
|
+
|
|
186
|
+
The framework distinguishes external cancellation (client disconnect, shutdown — re-raised) from internal cancellation (resource failure — swallowed and retried) by checking `usage.status`. The cooldown/disable handler sets the status to `"cancelled"` under the pool lock *before* invoking `.cancel()` on the handle, so observing that status when `CancelledError` arrives reliably means "we cancelled ourselves." Works on any Python 3.10+.
|
|
187
|
+
|
|
188
|
+
## API reference
|
|
189
|
+
|
|
190
|
+
### `rotapool.Pool[T]`
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
pool = Pool(
|
|
194
|
+
resources: list[Resource[T]] | dict[str, Resource[T]],
|
|
195
|
+
# resources: A list of Resource objects, or a dict mapping resource_id -> Resource.
|
|
196
|
+
# Duplicate resource_ids in list form raise ValueError.
|
|
197
|
+
|
|
198
|
+
max_attempts: int = 3,
|
|
199
|
+
# max_attempts: Total retry budget per run() call. Each attempt picks a fresh
|
|
200
|
+
# resource. Effectively capped at len(resources) — once every
|
|
201
|
+
# resource has been tried and none is eligible, run() raises
|
|
202
|
+
# PoolExhausted rather than retrying any one twice.
|
|
203
|
+
|
|
204
|
+
cooldown_table: tuple[float, ...] = (30.0, 120.0, 300.0, 600.0),
|
|
205
|
+
# cooldown_table: Escalation table indexed by consecutive_cooldown count.
|
|
206
|
+
# 1st cooldown → cooldown_table[0], 2nd → cooldown_table[1], etc.
|
|
207
|
+
# Out-of-range values clamp to the last entry.
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
await pool.run(
|
|
213
|
+
operation: Callable[[Resource[T]], Awaitable[R]],
|
|
214
|
+
# operation: Callable receiving the selected Resource and returning an
|
|
215
|
+
# Awaitable. Raise CooldownResource or DisableResource to
|
|
216
|
+
# signal resource health. Any other exception is treated as
|
|
217
|
+
# "resource is fine" and propagates to the caller.
|
|
218
|
+
# Accepted return types:
|
|
219
|
+
# - coroutine (typical async def) -- cancellable
|
|
220
|
+
# - asyncio.Future (e.g. loop.create_future) -- cancellable
|
|
221
|
+
# - any Awaitable (custom __await__) -- best-effort
|
|
222
|
+
# Returning a non-Awaitable raises TypeError at call time.
|
|
223
|
+
|
|
224
|
+
*, # All following parameters are keyword-only.
|
|
225
|
+
|
|
226
|
+
max_attempts: int | None = None,
|
|
227
|
+
# max_attempts: Per-call override of the pool's max_attempts. None = use pool default.
|
|
228
|
+
|
|
229
|
+
deadline: float | None = None,
|
|
230
|
+
# deadline: Absolute time.monotonic() value bounding total time across
|
|
231
|
+
# retries. Raises PoolExhausted if exceeded. None = no deadline.
|
|
232
|
+
|
|
233
|
+
retry_delay: float = 0.5,
|
|
234
|
+
# retry_delay: Seconds to pause between failed attempts.
|
|
235
|
+
|
|
236
|
+
request_id: str | None = None,
|
|
237
|
+
# request_id: Opaque string attached to every Usage created by this call.
|
|
238
|
+
# Auto-generated UUID when None.
|
|
239
|
+
) -> R
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
@pool.rotated(
|
|
244
|
+
max_attempts: int | None = None, # Per-call override; None = use pool default
|
|
245
|
+
deadline: float | None = None, # Absolute time.monotonic() deadline
|
|
246
|
+
retry_delay: float = 0.5, # Pause between failed attempts
|
|
247
|
+
request_id: str | None = None, # Opaque string for Usage tracking
|
|
248
|
+
)
|
|
249
|
+
# Returns a decorator. The decorated function receives a Resource[T] as its
|
|
250
|
+
# first positional argument (injected by the wrapper), followed by caller args.
|
|
251
|
+
# Any callable returning an Awaitable is accepted (async def, sync function
|
|
252
|
+
# returning a coroutine / Future / awaitable). A callable that returns a
|
|
253
|
+
# non-Awaitable raises TypeError at call time.
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
pool.snapshot() -> dict[str, dict[str, Any]]
|
|
258
|
+
# Returns a point-in-time summary of every resource. Thread-safe without the lock.
|
|
259
|
+
# Example return value:
|
|
260
|
+
# {
|
|
261
|
+
# "key-1": {
|
|
262
|
+
# "status": "healthy", # "healthy" | "cooling_down" | "disabled"
|
|
263
|
+
# "in_flight": 2, # Current in-flight usage count
|
|
264
|
+
# "consecutive_cooldown": 0, # Escalation counter
|
|
265
|
+
# "cooldown_seconds_remaining": 0.0, # Seconds until cooldown expires (0 if healthy)
|
|
266
|
+
# "last_acquired_at": 12345.67, # time.monotonic() of last acquire
|
|
267
|
+
# },
|
|
268
|
+
# ...
|
|
269
|
+
# }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### `rotapool.Resource[T]`
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
resource = Resource(
|
|
276
|
+
resource_id: str,
|
|
277
|
+
# resource_id: Unique identifier for this resource.
|
|
278
|
+
|
|
279
|
+
value: T,
|
|
280
|
+
# value: The actual resource object (API key, proxy URL, etc.).
|
|
281
|
+
|
|
282
|
+
max_in_flight: int | None = None,
|
|
283
|
+
# max_in_flight: Maximum concurrent usages. None = unlimited, 1 = exclusive.
|
|
284
|
+
|
|
285
|
+
status: str = "healthy",
|
|
286
|
+
# status: Current health: "healthy", "cooling_down", or "disabled".
|
|
287
|
+
# Managed by the framework — do not set manually.
|
|
288
|
+
|
|
289
|
+
cooldown_until: float = 0.0,
|
|
290
|
+
# cooldown_until: time.monotonic() deadline when status is "cooling_down".
|
|
291
|
+
# Managed by the framework — do not set manually.
|
|
292
|
+
|
|
293
|
+
last_acquired_at: float = 0.0,
|
|
294
|
+
# last_acquired_at: time.monotonic() of most recent acquire. Affects selection
|
|
295
|
+
# order (oldest first). Managed by the framework.
|
|
296
|
+
|
|
297
|
+
consecutive_cooldown: int = 0,
|
|
298
|
+
# consecutive_cooldown: Number of consecutive CooldownResource signals. Indexes into
|
|
299
|
+
# the pool's cooldown_table. Resets to 0 on next success.
|
|
300
|
+
# Managed by the framework — do not set manually.
|
|
301
|
+
)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Exceptions
|
|
305
|
+
|
|
306
|
+
| Exception | Who raises it | Meaning |
|
|
307
|
+
| ------------------ | -------------- | -------------------------------------------------------------- |
|
|
308
|
+
| `CooldownResource` | Your operation | Resource temporarily over capacity |
|
|
309
|
+
| `DisableResource` | Your operation | Resource permanently bad |
|
|
310
|
+
| `PoolExhausted` | Framework | No eligible resource, max attempts reached, or deadline passed |
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
raise CooldownResource(
|
|
314
|
+
cooldown_seconds: float | None = None,
|
|
315
|
+
# Explicit cooldown duration (e.g. from Retry-After header).
|
|
316
|
+
# None = use the pool's cooldown_table based on consecutive_cooldown count.
|
|
317
|
+
|
|
318
|
+
reason: str | None = None,
|
|
319
|
+
# Free-form string surfaced in the exception message and logs.
|
|
320
|
+
)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
raise DisableResource(
|
|
325
|
+
reason: str | None = None,
|
|
326
|
+
# Free-form string surfaced in the exception message and logs.
|
|
327
|
+
)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Resource types
|
|
331
|
+
|
|
332
|
+
`rotapool` is generic — `T` can be anything:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
# API keys (string bearer tokens)
|
|
336
|
+
Resource(resource_id="key-1", value="sk-...")
|
|
337
|
+
|
|
338
|
+
# HTTP proxies
|
|
339
|
+
Resource(resource_id="proxy-1", value="http://proxy:8080", max_in_flight=10)
|
|
340
|
+
|
|
341
|
+
# Browser sessions (exclusive)
|
|
342
|
+
Resource(resource_id="session-1", value=<webdriver>, max_in_flight=1)
|
|
343
|
+
|
|
344
|
+
# GPU workers
|
|
345
|
+
Resource(resource_id="gpu-0", value="cuda:0", max_in_flight=1)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Operation shapes
|
|
349
|
+
|
|
350
|
+
`pool.run` and `@pool.rotated` accept any callable that returns an `Awaitable`. The framework picks the cancellation strategy at runtime based on what the callable returns:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
# 1. async def -- the typical case. Cancellation is full-strength: the
|
|
354
|
+
# framework wraps the coroutine in a Task and cancels younger siblings
|
|
355
|
+
# via task.cancel() on resource failure.
|
|
356
|
+
@pool.rotated()
|
|
357
|
+
async def call_async(resource, payload):
|
|
358
|
+
async with httpx.AsyncClient() as client:
|
|
359
|
+
return await client.post(url, json=payload,
|
|
360
|
+
headers={"Authorization": f"Bearer {resource.value}"})
|
|
361
|
+
|
|
362
|
+
# 2. Sync function returning a coroutine -- previously rejected, now accepted.
|
|
363
|
+
# Useful when you want to construct the coroutine yourself or thread args.
|
|
364
|
+
@pool.rotated()
|
|
365
|
+
def call_returning_coro(resource, payload):
|
|
366
|
+
return some_async_helper(resource.value, payload) # returns a coroutine
|
|
367
|
+
|
|
368
|
+
# 3. Sync function returning an asyncio.Future -- accepted and cancellable
|
|
369
|
+
# via Future.cancel(). Useful for executor wrappers.
|
|
370
|
+
@pool.rotated()
|
|
371
|
+
def call_in_thread(resource, payload):
|
|
372
|
+
loop = asyncio.get_running_loop()
|
|
373
|
+
return loop.run_in_executor(None, blocking_request, resource.value, payload)
|
|
374
|
+
|
|
375
|
+
# 4. Anything returning a plain Awaitable (custom __await__) is also accepted,
|
|
376
|
+
# but with no cancel handle: younger sibling cancellation silently no-ops
|
|
377
|
+
# for this usage and it runs to natural completion (best-effort).
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
A callable that returns a non-Awaitable (e.g. a plain `int`) raises `TypeError` at call time. The resource is marked healthy (your bug, not the resource's) and the error propagates to the caller.
|
|
381
|
+
|
|
382
|
+
## Testing
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
# pip
|
|
386
|
+
pip install -e ".[dev]"
|
|
387
|
+
pytest
|
|
388
|
+
|
|
389
|
+
# uv
|
|
390
|
+
uv sync --all-extras
|
|
391
|
+
uv run pytest
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## License
|
|
395
|
+
|
|
396
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
rotapool/__init__.py,sha256=M3pN_KErkZCMcIIU9n0UdRqpFPrTjSMXt85ewKNh4JY,342
|
|
2
|
+
rotapool/exceptions.py,sha256=M3pbGXVJ-vtSKLW-SVO9C8kOYcfPL0L6xDLcodhXENg,1196
|
|
3
|
+
rotapool/models.py,sha256=Lx0ZL9SOjylHSuGlv0SOSsVrpdOB66foNkuBQrzMNc8,1603
|
|
4
|
+
rotapool/pool.py,sha256=BrVkzetGF_0Ulz3w6zXRZE0J-pIgW_52CThbk5f4j8A,17723
|
|
5
|
+
rotapool-0.1.0.dist-info/METADATA,sha256=k3cWRUEh-Ew3otirmLMbuGICxjiP4xZcXByBTo2HFW0,16294
|
|
6
|
+
rotapool-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
rotapool-0.1.0.dist-info/licenses/LICENSE,sha256=TiqPFK8iuihgxQgdBdJ62hhfUl3LGcO61V0FBnZOTvw,1061
|
|
8
|
+
rotapool-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zydo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|