Skip to content

Commit 4fa73ba

Browse files
committed
Expand docs, loosen agent-readable, refresh lock
- Bump version to 0.1.2 and reword description to match the new health-aware-selection framing. - Loosen agent-readable pin from ==0.1.0 to >=0.1.0 so consumers pick up patch upgrades automatically. - README: add an "Designed for AI coding agents" callout pointing at `python -m agent_readable rotapool.Pool`, and add a Pitfalls section covering the work-outside-run anti-pattern, common don'ts, and the younger-sibling-only cancellation gotcha. - pool.py embedded agent-readable docs: add a primary_backup strategy example so the docs surfaced to coding agents match the new feature. - LICENSE: attribute to "zydo and rotapool contributors". - uv.lock: refreshed against the new pyproject.toml.
1 parent 7d91348 commit 4fa73ba

5 files changed

Lines changed: 175 additions & 118 deletions

File tree

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2026 zydo
3+
Copyright (c) 2026 zydo and rotapool contributors
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
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.
44

5+
> **Designed for AI coding agents,** `rotapool` exposes machine-readable usage notes via [agent-readable](https://github.com/zydo/agent-readable). When generating code that uses `Pool`, tell your coding agent to run `pip install agent-readable` and then `python -m agent_readable rotapool.Pool` to learn about the operation contract, do/don't rules, anti-patterns, and failure modes.
6+
57
## Core idea
68

79
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.
@@ -385,6 +387,45 @@ def call_in_thread(resource, payload):
385387

386388
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.
387389

390+
## Pitfalls
391+
392+
### Anti-pattern: doing the real work outside `run()`
393+
394+
The pool only sees what happens **inside** the operation. Returning a client or handle from `run()` and using it afterwards means every later failure is invisible — the attempt is already recorded as success and the cooldown counter was reset.
395+
396+
```python
397+
# WRONG — the actual API call is outside the pool's view.
398+
client = await pool.run(lambda r: build_client(r.value))
399+
response = await client.get("/things") # invisible to pool
400+
```
401+
402+
```python
403+
# RIGHT — the call lives inside the operation, so 429s reach the pool.
404+
async def fetch(resource):
405+
client = build_client(resource.value)
406+
try:
407+
return await client.get("/things")
408+
except RateLimited as e:
409+
raise CooldownResource(cooldown_seconds=e.retry_after)
410+
411+
response = await pool.run(fetch)
412+
```
413+
414+
Return only plain values (bytes, dict, dataclass) from operations. For N backend calls, make N `run()` invocations.
415+
416+
### Don't
417+
418+
- **Don't raise `CooldownResource` for business errors** (404, validation failures). The next resource will return the same error and burn the retry budget for nothing — these belong in normal exceptions or return values.
419+
- **Don't catch and swallow exceptions inside the operation.** The pool needs to see `CooldownResource` / `DisableResource` to update health; swallowing them turns rate limits into invisible successes.
420+
- **Don't mutate `Resource` fields from outside the pool.** `status`, `cooldown_until`, `last_acquired_at`, and `consecutive_cooldown` are framework-owned lifecycle state.
421+
- **Don't share one `Pool` across asyncio event loops.** The internal lock binds to the loop where it was first awaited; reusing the pool from a different loop is undefined behaviour.
422+
423+
### Gotcha: cancellation only hits younger siblings
424+
425+
When a resource raises `CooldownResource` or `DisableResource`, the framework cancels **younger** in-flight usages on that resource and retries them elsewhere. **Older** usages are left to run to completion — they may already have side effects upstream that you can't unwind.
426+
427+
`asyncio.CancelledError` from this sibling cancellation is swallowed by the framework and the affected usages retry on a fresh resource; only **outer caller cancellation** propagates back to the caller.
428+
388429
## Testing
389430

390431
```bash

pyproject.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "rotapool"
3-
version = "0.1.1"
4-
description = "Generic async resource pool with rotation, cooldown, and retry"
3+
version = "0.1.2"
4+
description = "Generic async resource pool with health-aware selection, cooldown, and retry"
55
readme = "README.md"
66
license = "MIT"
77
requires-python = ">=3.10"
@@ -27,9 +27,7 @@ classifiers = [
2727
"Framework :: AsyncIO",
2828
"Typing :: Typed",
2929
]
30-
dependencies = [
31-
"agent-readable==0.1.0",
32-
]
30+
dependencies = ["agent-readable>=0.1.0"]
3331

3432
[project.urls]
3533
Source = "https://github.com/zydo/rotapool"

src/rotapool/pool.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,24 @@ async def call(resource):
506506
async def fetch(resource, url): ...
507507
```
508508
509+
### Strategy: primary_backup
510+
511+
Default strategy is ``"round_robin"`` (fairness across resources). Pass
512+
``strategy="primary_backup"`` to instead exhaust earlier resources before
513+
touching later ones -- list/dict order becomes the priority ranking.
514+
515+
```python
516+
# Use the paid key first; only fall back to free when the paid key is
517+
# rate-limited (cooling_down), revoked (disabled), or at max_in_flight.
518+
pool = Pool(
519+
resources=[
520+
Resource(resource_id="paid", value="sk-paid-...", max_in_flight=8),
521+
Resource(resource_id="free", value="sk-free-..."),
522+
],
523+
strategy="primary_backup",
524+
)
525+
```
526+
509527
### Anti-pattern: doing the real work OUTSIDE ``run()``
510528
511529
The pool only sees what happens INSIDE the operation. Returning a client /

0 commit comments

Comments
 (0)