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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.