rotapool 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(gh repo *)",
5
+ "Bash(git add *)",
6
+ "Bash(git commit -m ' *)",
7
+ "Bash(uv run *)",
8
+ "Bash(git status *)",
9
+ "Bash(git commit *)",
10
+ "Bash(python -m pytest --co)",
11
+ "Bash(python -m pytest --cov --cov-report=term-missing -q)",
12
+ "Bash(python -m pytest --cov=src --cov-report=term-missing -q)",
13
+ "Bash(python -m pytest --cov=src --cov-branch --cov-report=json -q)",
14
+ "Bash(python -c ' *)",
15
+ "Bash(python *)",
16
+ "Bash(uv lock *)",
17
+ "Bash(git tag *)",
18
+ "Bash(git push *)",
19
+ "Bash(uv build *)"
20
+ ]
21
+ }
22
+ }
@@ -0,0 +1,19 @@
1
+ name: test
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ - name: Set up Python ${{ matrix.python-version }}
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: uv sync --all-extras
19
+ - run: uv run pytest -q
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ .pytest_cache/
7
+ .coverage
8
+ coverage.xml
9
+ htmlcov/
10
+ .vscode/
rotapool-0.1.0/LICENSE ADDED
@@ -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.
@@ -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