6/17/2026

Building a Thread-Safe API Key Pool: Python Concurrency in Practice

Pythonconcurrencythread-safeAPI Keydesign-pattern

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?

ScenarioProblem
Two threads get the same keyAPI calls interfere, quota tracking breaks
One thread’s key released by anotherState gets out of sync, key reused mid-flight
Key quota exhausted but still usedWasted requests, all return 429
More threads than keysSome 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:

#RequirementWhy
1Thread-safeN Workers fetch keys concurrently without conflict
2Fair round-robinAll keys used evenly — no single key exhausts early
3Auto-disableSkip exhausted or invalid keys automatically
4No double-bookingOne key held by exactly one Worker at a time
5Lock-efficientKey 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:

  • Lock instead of RLock — key acquisition is mutually exclusive, no reentrancy needed
  • in_use flag prevents the same key from being held by two Workers simultaneously
  • disabled flag 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

Setup500 Images TimeAPI Success RateQuota Utilization
1 key, 1 thread~15 min100%100%
3 keys, 1 thread~45 min (round-robin)100%100%
3 keys, 3 threads (no lock)~6 min62% (conflicts)~73%
3 keys, 3 threads (KeyManager)~5 min100%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:

ScenarioResourceConsumer
API key poolThird-party API credentialsConcurrent request Workers
Database connection poolDB connectionsQuery threads
Proxy IP poolProxy serversCrawler Workers
Rate-limiting token bucketRequest quotaAPI 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.

AdviceWhy
Abstract earlyUse KeyManager from the first key, don’t wait for the 5th
Narrow the lock scopeOnly lock state checks and updates, not API calls
try/finally alwaysEnsure key release on every path, including exceptions
Don’t blockReturn 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.