Building a Thread-Safe API Key Pool: Python Concurrency in Practice
TinyPNG’s free API gives each account 500 compressions per month. To handle more images, the straightforward solution is registering multiple keys.
But multiple keys introduce a new problem: when multiple threads grab keys concurrently, how do you prevent conflicts?
| Scenario | Problem |
|---|---|
| Two threads get the same key | API calls interfere, quota tracking breaks |
| One thread’s key released by another | State gets out of sync, key reused mid-flight |
| Key quota exhausted but still used | Wasted requests, all return 429 |
| More threads than keys | Some threads starve waiting forever |
This article breaks down the complete KeyManager implementation from TinyOpt — a production-validated, thread-safe API key pool.
01. Requirements: What We Need
A key management module must satisfy 5 requirements:
| # | Requirement | Why |
|---|---|---|
| 1 | Thread-safe | N Workers fetch keys concurrently without conflict |
| 2 | Fair round-robin | All keys used evenly — no single key exhausts early |
| 3 | Auto-disable | Skip exhausted or invalid keys automatically |
| 4 | No double-booking | One key held by exactly one Worker at a time |
| 5 | Lock-efficient | Key acquisition doesn’t become a bottleneck under load |
02. Data Structure
Each key is a simple object:
@dataclass
class APIKey:
key: str
month_quota: int = 500
used_count: int = 0
disabled: bool = False
in_use: bool = False
KeyManager maintains a key list and a threading.Lock:
class KeyManager:
def __init__(self):
self.keys: list[APIKey] = []
self._lock = threading.Lock()
self._index = 0
Design rationale:
Lockinstead ofRLock— key acquisition is mutually exclusive, no reentrancy neededin_useflag prevents the same key from being held by two Workers simultaneouslydisabledflag lets exhausted keys be permanently skipped
03. Core Method: acquire_key
This is the heart of the module. It must select the best available key while remaining thread-safe:
def acquire_key(self) -> APIKey | None:
with self._lock:
n = len(self.keys)
if n == 0:
return None
for i in range(n):
idx = (self._index + i) % n
key = self.keys[idx]
if not key.in_use and not key.disabled:
key.in_use = True
self._index = (idx + 1) % n
return key
return None
Key details:
① Scan all keys, not just the next one
Starting from _index, it scans the full circle. This guarantees it finds an available key even if the current pointer lands on one that’s in use.
② Pointer advancement ensures fairness After each allocation, the pointer moves forward by one. The next allocation starts from the following position. Keys are used evenly — no single key gets drained before others.
③ Return None instead of blocking
When no keys are available, return None. The caller decides whether to wait, retry, or degrade. This prevents deadlocks in thread pools.
release_key is symmetric:
def release_key(self, key: APIKey):
with self._lock:
key.in_use = False
04. Auto-Disable on Quota Exhaustion
After each compression, check the remaining quota from the API response:
def process_one_file(self, file_path):
key = self.key_manager.acquire_key()
if key is None:
self.signal_progress.emit(file_path, "waiting")
return False
try:
response = self._call_tinypng(file_path, key.key)
remaining = response.headers.get("Compression-Count")
if remaining and int(remaining) <= 0:
key.disabled = True
self.signal_log.emit(f"Key {key.key[:8]}... exhausted, auto-disabled")
key.used_count += 1
return True
except TinyPNGQuotaExceeded:
key.disabled = True
return False
finally:
self.key_manager.release_key(key)
try/finally guarantees the key is released regardless of success or failure.
05. Concurrency Configuration
KeyManager doesn’t limit concurrency itself. That’s handled by CompressWorker:
max_workers = min(len(key_manager.keys), 3, len(files))
Three limits, take the minimum:
- Available keys (each key handles one image at a time)
- Hard limit of 3 (TinyPNG API’s implicit concurrency ceiling)
- Files to process
06. Performance Comparison
| Setup | 500 Images Time | API Success Rate | Quota Utilization |
|---|---|---|---|
| 1 key, 1 thread | ~15 min | 100% | 100% |
| 3 keys, 1 thread | ~45 min (round-robin) | 100% | 100% |
| 3 keys, 3 threads (no lock) | ~6 min | 62% (conflicts) | ~73% |
| 3 keys, 3 threads (KeyManager) | ~5 min | 100% | 100% |
The 62% success rate and 73% quota utilization in the lock-free version say it all — threads grabbing keys without control waste both requests and quota.
With KeyManager:
- 500 images: 15 minutes → 5 minutes
- All 3 keys fully utilized
- Zero quota waste, zero conflicts
07. Where Else Can This Pattern Apply?
KeyManager is essentially a resource pool pattern. Any scenario with limited resources and concurrent consumers can use it:
| Scenario | Resource | Consumer |
|---|---|---|
| API key pool | Third-party API credentials | Concurrent request Workers |
| Database connection pool | DB connections | Query threads |
| Proxy IP pool | Proxy servers | Crawler Workers |
| Rate-limiting token bucket | Request quota | API callers |
The core idea is always the same: protect state changes with a lock, ensure fairness with round-robin, track resource status with flags.
08. Summary
The biggest lesson from writing KeyManager: the hard part of concurrent programming isn’t the lock — it’s realizing you need one.
Adding a lock is trivial when KeyManager is 50 lines. By the time you realize you need one, the key is already being passed around 3 classes and modified in 5 places. Good luck covering all paths then.
| Advice | Why |
|---|---|
| Abstract early | Use KeyManager from the first key, don’t wait for the 5th |
| Narrow the lock scope | Only lock state checks and updates, not API calls |
| try/finally always | Ensure key release on every path, including exceptions |
| Don’t block | Return None when no resource, let the caller decide |
👉 TinyOpt comes with built-in multi-key management. If you’re designing a similar resource pool, the KeyManager in the source code is a good reference implementation.