keymesh 0.1.2a0__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.
- keymesh/__init__.py +45 -0
- keymesh/concurrency/__init__.py +4 -0
- keymesh/concurrency/semaphores.py +47 -0
- keymesh/cooldown/__init__.py +4 -0
- keymesh/cooldown/manager.py +52 -0
- keymesh/exceptions.py +30 -0
- keymesh/metrics/__init__.py +4 -0
- keymesh/metrics/pool_metrics.py +52 -0
- keymesh/pool/__init__.py +5 -0
- keymesh/pool/pool.py +251 -0
- keymesh/pool/sync_pool.py +253 -0
- keymesh/scheduler/__init__.py +26 -0
- keymesh/scheduler/base.py +40 -0
- keymesh/scheduler/least_busy.py +28 -0
- keymesh/scheduler/round_robin.py +34 -0
- keymesh/scheduler/weighted.py +44 -0
- keymesh/state/__init__.py +5 -0
- keymesh/state/key_state.py +151 -0
- keymesh/state/sync_key_state.py +151 -0
- keymesh/storage/__init__.py +16 -0
- keymesh/storage/base.py +37 -0
- keymesh/storage/json_storage.py +72 -0
- keymesh/storage/memory.py +43 -0
- keymesh/storage/sync_base.py +37 -0
- keymesh/storage/sync_json.py +72 -0
- keymesh/storage/sync_memory.py +43 -0
- keymesh/utils/__init__.py +4 -0
- keymesh/utils/helpers.py +58 -0
- keymesh-0.1.2a0.dist-info/METADATA +253 -0
- keymesh-0.1.2a0.dist-info/RECORD +33 -0
- keymesh-0.1.2a0.dist-info/WHEEL +4 -0
- keymesh-0.1.2a0.dist-info/entry_points.txt +2 -0
- keymesh-0.1.2a0.dist-info/licenses/LICENSE +21 -0
keymesh/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KeyMesh — Credential Orchestration Runtime for AI API Systems.
|
|
3
|
+
|
|
4
|
+
A lightweight, concurrency-safe API key pool manager and scheduler.
|
|
5
|
+
KeyMesh is NOT a proxy, SDK wrapper, or HTTP gateway.
|
|
6
|
+
It is purely a credential allocator and rate-limit-aware scheduler.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from keymesh import KeyPool, SchedulerStrategy
|
|
10
|
+
|
|
11
|
+
pool = KeyPool(
|
|
12
|
+
keys=["sk-key1", "sk-key2", "sk-key3"],
|
|
13
|
+
strategy=SchedulerStrategy.LEAST_BUSY,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
key = await pool.acquire()
|
|
17
|
+
# ... use key in your own SDK/HTTP client ...
|
|
18
|
+
await pool.release(key)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from keymesh.pool.pool import KeyPool
|
|
22
|
+
from keymesh.pool.sync_pool import SyncKeyPool
|
|
23
|
+
from keymesh.scheduler.base import SchedulerStrategy
|
|
24
|
+
from keymesh.state.key_state import KeyState
|
|
25
|
+
from keymesh.state.sync_key_state import SyncKeyState
|
|
26
|
+
from keymesh.exceptions import (
|
|
27
|
+
KeyMeshError,
|
|
28
|
+
NoAvailableKeyError,
|
|
29
|
+
KeyCooldownError,
|
|
30
|
+
KeyExhaustedError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"KeyPool",
|
|
35
|
+
"SyncKeyPool",
|
|
36
|
+
"SchedulerStrategy",
|
|
37
|
+
"KeyState",
|
|
38
|
+
"SyncKeyState",
|
|
39
|
+
"KeyMeshError",
|
|
40
|
+
"NoAvailableKeyError",
|
|
41
|
+
"KeyCooldownError",
|
|
42
|
+
"KeyExhaustedError",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concurrency utilities for KeyMesh.
|
|
3
|
+
|
|
4
|
+
Provides a SemaphoreGroup for per-key concurrency limits — optional,
|
|
5
|
+
for users who want to cap in-flight requests per key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SemaphoreGroup:
|
|
12
|
+
"""
|
|
13
|
+
Per-key asyncio semaphore pool.
|
|
14
|
+
|
|
15
|
+
Use this when you want to cap the number of concurrent in-flight
|
|
16
|
+
requests per API key (e.g., a provider that allows max 5 concurrent calls).
|
|
17
|
+
|
|
18
|
+
Semaphores are created lazily on first use inside a running event loop,
|
|
19
|
+
avoiding "Future attached to a different loop" errors when this object
|
|
20
|
+
is instantiated at module level or before ``asyncio.run()`` is called.
|
|
21
|
+
|
|
22
|
+
Example
|
|
23
|
+
-------
|
|
24
|
+
```python
|
|
25
|
+
sem_group = SemaphoreGroup(max_concurrent=5)
|
|
26
|
+
|
|
27
|
+
key = await pool.acquire()
|
|
28
|
+
async with sem_group.acquire(key):
|
|
29
|
+
response = await client.call(api_key=key)
|
|
30
|
+
await pool.release(key)
|
|
31
|
+
```
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_concurrent: int = 10) -> None:
|
|
35
|
+
self._max = max_concurrent
|
|
36
|
+
self._sems: dict[str, asyncio.Semaphore] = {}
|
|
37
|
+
|
|
38
|
+
def acquire(self, key: str) -> asyncio.Semaphore:
|
|
39
|
+
"""Return (or lazily create) the semaphore for `key`.
|
|
40
|
+
|
|
41
|
+
Must be called from within a running async context so that the
|
|
42
|
+
semaphore is bound to the correct event loop.
|
|
43
|
+
"""
|
|
44
|
+
if key not in self._sems:
|
|
45
|
+
# Created inside a coroutine: the running loop is guaranteed.
|
|
46
|
+
self._sems[key] = asyncio.Semaphore(self._max)
|
|
47
|
+
return self._sems[key]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cooldown manager — tracks and enforces per-key cooldown windows.
|
|
3
|
+
|
|
4
|
+
KeyMesh never sleeps waiting for cooldowns. Instead, unavailable keys
|
|
5
|
+
are filtered out at acquire-time and re-admitted when time has elapsed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from keymesh.state.key_state import KeyState
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CooldownManager:
|
|
16
|
+
"""
|
|
17
|
+
Stateless helper for cooldown evaluation.
|
|
18
|
+
|
|
19
|
+
All actual cooldown state lives inside KeyState.
|
|
20
|
+
This class provides higher-level helpers used by the pool.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def apply(key_state: "KeyState", duration: float = 60.0) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Immediately apply a cooldown window to a key (synchronous, for internal use).
|
|
27
|
+
|
|
28
|
+
.. deprecated::
|
|
29
|
+
This method mutates ``cooldown_until`` directly without acquiring
|
|
30
|
+
the key's internal ``asyncio.Lock``, making it unsafe under
|
|
31
|
+
concurrency. Use ``await key_state.apply_cooldown(duration)``
|
|
32
|
+
in async contexts instead.
|
|
33
|
+
"""
|
|
34
|
+
import warnings
|
|
35
|
+
|
|
36
|
+
warnings.warn(
|
|
37
|
+
"CooldownManager.apply() is not concurrency-safe. "
|
|
38
|
+
"Use `await key_state.apply_cooldown(duration)` in async code.",
|
|
39
|
+
DeprecationWarning,
|
|
40
|
+
stacklevel=2,
|
|
41
|
+
)
|
|
42
|
+
key_state.cooldown_until = time.monotonic() + duration
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def is_expired(key_state: "KeyState") -> bool:
|
|
46
|
+
"""Return True if cooldown has elapsed (key may be re-admitted)."""
|
|
47
|
+
return time.monotonic() >= key_state.cooldown_until
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def remaining(key_state: "KeyState") -> float:
|
|
51
|
+
"""Seconds until cooldown expires. Returns 0 if not cooling down."""
|
|
52
|
+
return max(0.0, key_state.cooldown_until - time.monotonic())
|
keymesh/exceptions.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KeyMesh custom exceptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KeyMeshError(Exception):
|
|
7
|
+
"""Base exception for all KeyMesh errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NoAvailableKeyError(KeyMeshError):
|
|
11
|
+
"""Raised when no API key is currently available (all cooling down or failed)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KeyCooldownError(KeyMeshError):
|
|
15
|
+
"""Raised when a specific key is in cooldown and cannot be used."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, key: str, cooldown_remaining: float) -> None:
|
|
18
|
+
self.key = key
|
|
19
|
+
self.cooldown_remaining = cooldown_remaining
|
|
20
|
+
super().__init__(
|
|
21
|
+
f"Key ...{key[-6:]} is in cooldown for {cooldown_remaining:.1f}s more."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KeyExhaustedError(KeyMeshError):
|
|
26
|
+
"""Raised when a key has exceeded its failure threshold and is removed from the pool."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, key: str) -> None:
|
|
29
|
+
self.key = key
|
|
30
|
+
super().__init__(f"Key ...{key[-6:]} has been exhausted and removed from pool.")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime metrics aggregation for the pool.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PoolMetrics:
|
|
11
|
+
"""Aggregated runtime metrics for a KeyPool instance."""
|
|
12
|
+
|
|
13
|
+
total_acquires: int = 0
|
|
14
|
+
total_releases: int = 0
|
|
15
|
+
total_failures: int = 0
|
|
16
|
+
total_cooldowns: int = 0
|
|
17
|
+
total_retries: int = 0
|
|
18
|
+
no_key_available_count: int = 0
|
|
19
|
+
_started_at: float = field(default_factory=time.monotonic, init=False)
|
|
20
|
+
|
|
21
|
+
def record_acquire(self) -> None:
|
|
22
|
+
self.total_acquires += 1
|
|
23
|
+
|
|
24
|
+
def record_release(self) -> None:
|
|
25
|
+
self.total_releases += 1
|
|
26
|
+
|
|
27
|
+
def record_failure(self) -> None:
|
|
28
|
+
self.total_failures += 1
|
|
29
|
+
|
|
30
|
+
def record_cooldown(self) -> None:
|
|
31
|
+
self.total_cooldowns += 1
|
|
32
|
+
|
|
33
|
+
def record_retry(self) -> None:
|
|
34
|
+
self.total_retries += 1
|
|
35
|
+
|
|
36
|
+
def record_no_key(self) -> None:
|
|
37
|
+
self.no_key_available_count += 1
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def uptime_seconds(self) -> float:
|
|
41
|
+
return time.monotonic() - self._started_at
|
|
42
|
+
|
|
43
|
+
def snapshot(self) -> dict[str, object]:
|
|
44
|
+
return {
|
|
45
|
+
"uptime_seconds": round(self.uptime_seconds, 2),
|
|
46
|
+
"total_acquires": self.total_acquires,
|
|
47
|
+
"total_releases": self.total_releases,
|
|
48
|
+
"total_failures": self.total_failures,
|
|
49
|
+
"total_cooldowns": self.total_cooldowns,
|
|
50
|
+
"total_retries": self.total_retries,
|
|
51
|
+
"no_key_available_count": self.no_key_available_count,
|
|
52
|
+
}
|
keymesh/pool/__init__.py
ADDED
keymesh/pool/pool.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KeyPool — the central public interface of KeyMesh.
|
|
3
|
+
|
|
4
|
+
Responsibilities:
|
|
5
|
+
- Hold and manage a pool of KeyState objects
|
|
6
|
+
- Delegate key selection to the configured scheduler
|
|
7
|
+
- Enforce cooldowns, failure thresholds, and retry logic
|
|
8
|
+
- Surface a minimal public API: acquire / release / mark_failed / mark_rate_limited
|
|
9
|
+
|
|
10
|
+
KeyPool is framework-agnostic. It never sends HTTP requests or wraps any SDK.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any, Sequence
|
|
16
|
+
|
|
17
|
+
from keymesh.exceptions import KeyExhaustedError, NoAvailableKeyError
|
|
18
|
+
from keymesh.metrics.pool_metrics import PoolMetrics
|
|
19
|
+
from keymesh.scheduler import BaseScheduler, SchedulerStrategy, build_scheduler
|
|
20
|
+
from keymesh.state.key_state import KeyState
|
|
21
|
+
from keymesh.storage.base import BaseStorage
|
|
22
|
+
from keymesh.storage.memory import MemoryStorage
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KeyPool:
|
|
28
|
+
"""
|
|
29
|
+
Manages a collection of API keys with scheduling, cooldown, and retry support.
|
|
30
|
+
|
|
31
|
+
Example
|
|
32
|
+
-------
|
|
33
|
+
```python
|
|
34
|
+
pool = KeyPool(keys=["sk-a", "sk-b", "sk-c"])
|
|
35
|
+
|
|
36
|
+
key = await pool.acquire()
|
|
37
|
+
try:
|
|
38
|
+
response = await my_client.call(api_key=key)
|
|
39
|
+
await pool.release(key, latency=response.elapsed)
|
|
40
|
+
except RateLimitError:
|
|
41
|
+
await pool.mark_rate_limited(key, cooldown=60.0)
|
|
42
|
+
except Exception:
|
|
43
|
+
await pool.mark_failed(key)
|
|
44
|
+
```
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
keys: Sequence[str],
|
|
50
|
+
*,
|
|
51
|
+
strategy: SchedulerStrategy = SchedulerStrategy.LEAST_BUSY,
|
|
52
|
+
scheduler: BaseScheduler | None = None,
|
|
53
|
+
storage: BaseStorage | None = None,
|
|
54
|
+
default_cooldown: float = 60.0,
|
|
55
|
+
max_failures: int = 10,
|
|
56
|
+
acquire_timeout: float = 30.0,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
keys:
|
|
62
|
+
List of raw API key strings. Must be non-empty.
|
|
63
|
+
strategy:
|
|
64
|
+
Scheduling strategy to use. Ignored if `scheduler` is provided.
|
|
65
|
+
scheduler:
|
|
66
|
+
Custom scheduler instance. Overrides `strategy`.
|
|
67
|
+
storage:
|
|
68
|
+
Persistence backend. Defaults to in-memory (no persistence).
|
|
69
|
+
default_cooldown:
|
|
70
|
+
Default cooldown duration in seconds when mark_rate_limited is called.
|
|
71
|
+
max_failures:
|
|
72
|
+
Consecutive failures before a key is considered exhausted.
|
|
73
|
+
acquire_timeout:
|
|
74
|
+
Max seconds to wait for an available key before raising NoAvailableKeyError.
|
|
75
|
+
"""
|
|
76
|
+
if not keys:
|
|
77
|
+
raise ValueError("KeyPool requires at least one API key.")
|
|
78
|
+
|
|
79
|
+
self._states: dict[str, KeyState] = {
|
|
80
|
+
k: KeyState(key=k, max_failures=max_failures) for k in keys
|
|
81
|
+
}
|
|
82
|
+
self._scheduler: BaseScheduler = scheduler or build_scheduler(strategy)
|
|
83
|
+
self._storage: BaseStorage = storage or MemoryStorage()
|
|
84
|
+
self._default_cooldown = default_cooldown
|
|
85
|
+
self._max_failures = max_failures
|
|
86
|
+
self._acquire_timeout = acquire_timeout
|
|
87
|
+
self._metrics = PoolMetrics()
|
|
88
|
+
self._pool_lock = asyncio.Lock()
|
|
89
|
+
|
|
90
|
+
logger.info(
|
|
91
|
+
"KeyPool initialised: %d keys, strategy=%s",
|
|
92
|
+
len(keys),
|
|
93
|
+
self._scheduler.strategy,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
async def acquire(self) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Acquire an available API key.
|
|
101
|
+
|
|
102
|
+
Blocks (up to `acquire_timeout`) until a key becomes available.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
str
|
|
107
|
+
The raw API key string to inject into your HTTP client / SDK.
|
|
108
|
+
|
|
109
|
+
Raises
|
|
110
|
+
------
|
|
111
|
+
NoAvailableKeyError
|
|
112
|
+
If no key becomes available within `acquire_timeout` seconds.
|
|
113
|
+
"""
|
|
114
|
+
loop = asyncio.get_running_loop()
|
|
115
|
+
deadline = loop.time() + self._acquire_timeout
|
|
116
|
+
|
|
117
|
+
while True:
|
|
118
|
+
key_state = await self._try_acquire()
|
|
119
|
+
if key_state is not None:
|
|
120
|
+
self._metrics.record_acquire()
|
|
121
|
+
logger.debug("Acquired key ...%s", key_state.key[-6:])
|
|
122
|
+
return key_state.key
|
|
123
|
+
|
|
124
|
+
self._metrics.record_no_key()
|
|
125
|
+
remaining = deadline - loop.time()
|
|
126
|
+
if remaining <= 0:
|
|
127
|
+
raise NoAvailableKeyError(
|
|
128
|
+
"No API keys are currently available. "
|
|
129
|
+
"All keys are either cooling down or exhausted."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Brief sleep before retrying to avoid busy-loop
|
|
133
|
+
await asyncio.sleep(min(0.5, remaining))
|
|
134
|
+
|
|
135
|
+
async def release(self, key: str, *, latency: float = 0.0) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Release a key after a successful API call.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
key:
|
|
142
|
+
The key string returned by acquire().
|
|
143
|
+
latency:
|
|
144
|
+
Optional: elapsed seconds for the call (used for weighted scheduling).
|
|
145
|
+
"""
|
|
146
|
+
state = self._resolve(key)
|
|
147
|
+
await state.record_success(latency)
|
|
148
|
+
self._metrics.record_release()
|
|
149
|
+
await self._storage.save(key, state.snapshot())
|
|
150
|
+
logger.debug("Released key ...%s (latency=%.3fs)", key[-6:], latency)
|
|
151
|
+
|
|
152
|
+
async def mark_failed(self, key: str) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Mark a key as failed (non-rate-limit error).
|
|
155
|
+
|
|
156
|
+
After `max_failures` calls, the key is marked exhausted and excluded
|
|
157
|
+
from future scheduling.
|
|
158
|
+
|
|
159
|
+
Raises
|
|
160
|
+
------
|
|
161
|
+
KeyExhaustedError
|
|
162
|
+
If the key has now reached the failure threshold.
|
|
163
|
+
"""
|
|
164
|
+
state = self._resolve(key)
|
|
165
|
+
await state.record_failure()
|
|
166
|
+
self._metrics.record_failure()
|
|
167
|
+
await self._storage.save(key, state.snapshot())
|
|
168
|
+
logger.warning("Key ...%s marked failed (total=%d)", key[-6:], state.failure_count)
|
|
169
|
+
|
|
170
|
+
if state.is_exhausted:
|
|
171
|
+
raise KeyExhaustedError(key)
|
|
172
|
+
|
|
173
|
+
async def mark_rate_limited(self, key: str, *, cooldown: float | None = None) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Mark a key as rate-limited (HTTP 429).
|
|
176
|
+
|
|
177
|
+
The key will be excluded from scheduling until the cooldown elapses.
|
|
178
|
+
Resets the key's consecutive failure counter — a rate-limited key is
|
|
179
|
+
still valid, just throttled.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
cooldown:
|
|
184
|
+
Cooldown duration in seconds. Defaults to pool's `default_cooldown`.
|
|
185
|
+
"""
|
|
186
|
+
duration = cooldown if cooldown is not None else self._default_cooldown
|
|
187
|
+
state = self._resolve(key)
|
|
188
|
+
await state.apply_cooldown(duration)
|
|
189
|
+
self._metrics.record_cooldown()
|
|
190
|
+
await self._storage.save(key, state.snapshot())
|
|
191
|
+
logger.warning(
|
|
192
|
+
"Key ...%s rate-limited, cooling down for %.1fs", key[-6:], duration
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def add_key(self, key: str) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Add or reset a key in the pool at runtime.
|
|
198
|
+
|
|
199
|
+
Use this to:
|
|
200
|
+
- Re-admit an exhausted key after rotating/refreshing it.
|
|
201
|
+
- Inject a brand-new key into a running pool without restarting.
|
|
202
|
+
|
|
203
|
+
Parameters
|
|
204
|
+
----------
|
|
205
|
+
key:
|
|
206
|
+
The raw API key string to add or reset.
|
|
207
|
+
"""
|
|
208
|
+
async with self._pool_lock:
|
|
209
|
+
self._states[key] = KeyState(key=key, max_failures=self._max_failures)
|
|
210
|
+
logger.info("Key ...%s added/reset in pool", key[-6:])
|
|
211
|
+
|
|
212
|
+
# ── Diagnostics ───────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def status(self) -> dict[str, Any]:
|
|
215
|
+
"""Return a full status snapshot of the pool and all key states."""
|
|
216
|
+
return {
|
|
217
|
+
"pool_metrics": self._metrics.snapshot(),
|
|
218
|
+
"scheduler": self._scheduler.strategy,
|
|
219
|
+
"keys": [ks.snapshot() for ks in self._states.values()],
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def available_count(self) -> int:
|
|
223
|
+
"""Number of keys currently available for scheduling."""
|
|
224
|
+
return sum(1 for ks in self._states.values() if ks.is_available)
|
|
225
|
+
|
|
226
|
+
# ── Internal ──────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
async def _try_acquire(self) -> KeyState | None:
|
|
229
|
+
"""
|
|
230
|
+
Ask the scheduler to pick one available key.
|
|
231
|
+
|
|
232
|
+
Returns None if no key is available right now.
|
|
233
|
+
"""
|
|
234
|
+
async with self._pool_lock:
|
|
235
|
+
candidates = [ks for ks in self._states.values() if ks.is_available]
|
|
236
|
+
key_state = self._scheduler.select(candidates)
|
|
237
|
+
if key_state is not None:
|
|
238
|
+
await key_state.increment_active()
|
|
239
|
+
return key_state
|
|
240
|
+
|
|
241
|
+
def _resolve(self, key: str) -> KeyState:
|
|
242
|
+
"""Look up a KeyState by raw key string. Raises KeyError if unknown."""
|
|
243
|
+
try:
|
|
244
|
+
return self._states[key]
|
|
245
|
+
except KeyError as exc:
|
|
246
|
+
raise KeyError(f"Unknown key '...{key[-6:]}' — was it added to this pool?") from exc
|
|
247
|
+
|
|
248
|
+
async def close(self) -> None:
|
|
249
|
+
"""Release storage resources. Call when pool is no longer needed."""
|
|
250
|
+
await self._storage.close()
|
|
251
|
+
logger.info("KeyPool closed.")
|