003: Strategy Pattern for Proxy Rotation
Architecture decision record.
Status
Accepted
Context
ProxyWhirl needs to support multiple proxy selection algorithms to accommodate different use cases:
- Round-robin: Simple, fair distribution for general scraping
- Random: Unpredictable pattern to avoid detection
- Weighted: Favor high-performance or premium proxies
- Least-used: Balance load across proxy pool
- Performance-based: Select fastest proxies adaptively
- Session persistence: Sticky sessions for stateful workflows
- Geo-targeted: Filter by geographic location
- Composite: Combine multiple strategies (e.g., geo + performance)
Requirements:
- Extensibility: Easy to add custom strategies
- Configurability: Runtime strategy selection
- Type Safety: Compile-time validation of strategy interface
- Performance: <5ms selection time (SC-007)
- Thread Safety: Concurrent selection from multiple threads
- Testability: Isolated testing of each strategy
- Plugin Architecture: User-defined strategies without modifying core
Traditional approaches have limitations:
- If/else chains become unwieldy with many strategies
- Inheritance-based design couples strategies to base class
- Duck typing lacks compile-time validation
Decision
We implemented the Strategy Pattern with a registry-based plugin architecture:
Core Design
RotationStrategy Protocol (structural subtyping):
@runtime_checkable
class RotationStrategy(Protocol):
def select(self, pool: ProxyPool, context: Optional[SelectionContext]) -> Proxy:
"""Select a proxy from the pool."""
...
def record_result(self, proxy: Proxy, success: bool, response_time_ms: float) -> None:
"""Record request outcome for adaptive strategies."""
...Strategy Registry (singleton):
class StrategyRegistry:
_strategies: dict[str, type] = {}
def register_strategy(self, name: str, strategy_class: type) -> None:
"""Register custom strategy."""
...
def get_strategy(self, name: str) -> type:
"""Retrieve strategy by name."""
...Selection Context (request metadata):
class SelectionContext(BaseModel):
session_id: Optional[str] # For session persistence
target_country: Optional[str] # For geo-targeting
target_region: Optional[str] # For geo-targeting
failed_proxy_ids: list[str] # For retry exclusionBuilt-In Strategies
1. Round-Robin (RoundRobinStrategy):
- Sequential selection with wraparound
- Thread-safe index tracking via
threading.Lock - O(1) selection time
- Fair distribution across proxies
2. Random (RandomStrategy):
- Random selection from healthy proxies
- Uses
random.choice()(GIL-protected) - O(1) selection time
- Unpredictable pattern
3. Weighted (WeightedStrategy):
- Weighted random selection by success rate or custom weights
- Cached weight calculation for performance
- O(n) first call, O(1) subsequent calls (cache hit)
- Normalizes weights to sum=1.0 invariant
4. Least-Used (LeastUsedStrategy):
- Select proxy with fewest active requests
- Tracks
requests_startedcounter - O(n) selection time (linear scan)
- Load balancing across proxies
5. Performance-Based (PerformanceBasedStrategy):
- Weighted selection by inverse EMA response time
- Exploration period for new proxies (default: 5 trials)
- O(n) selection time
- Adaptive to proxy performance
6. Session Persistence (SessionPersistenceStrategy):
- Sticky proxy-to-session mapping
SessionManagerwith LRU eviction and TTL- O(1) session lookup
- 99.9% same-proxy guarantee (SC-005)
7. Geo-Targeted (GeoTargetedStrategy):
- Filter by
country_codeorregion - Configurable fallback to any proxy
- O(n) filtering + O(1) or O(n) selection
- 100% correct region selection (SC-006)
8. Composite (CompositeStrategy):
- Apply filters then selector
- Example: Geo filter → Performance selector
- O(n * filters) + O(selector) selection time
- <5ms total target (SC-007)
Registry Plugin Architecture
Registration:
# User defines custom strategy
class MyCustomStrategy:
def select(self, pool, context=None):
return pool.get_all_proxies()[0]
def record_result(self, proxy, success, response_time_ms):
pass
# Register it
registry = StrategyRegistry()
registry.register_strategy("my-custom", MyCustomStrategy)Retrieval:
strategy_class = registry.get_strategy("my-custom")
strategy = strategy_class()
proxy = strategy.select(pool)Validation (optional):
registry.register_strategy("my-custom", MyCustomStrategy, validate=True)
# Raises TypeError if missing required methodsConsequences
Positive
-
Extensibility:
- Add new strategies without modifying existing code
- Plugin architecture via registry
- Protocol-based validation ensures correctness
-
Type Safety:
@runtime_checkableProtocol enablesisinstance()checks- Static type checkers validate implementations
- Compile-time errors for missing methods
-
Performance:
- Strategy overhead <1ms (validated by tests)
- Caching in
WeightedStrategyreduces recalculation - Lock-free strategies (Random, Weighted) scale better
-
Configurability:
- Runtime strategy selection via string names
StrategyConfigfor per-strategy customization- No code changes required for different deployments
-
Thread Safety:
- Each strategy handles own concurrency
- Round-robin uses
threading.Lockfor index - Random/Weighted use GIL-protected random module
- Session uses
threading.RLockin SessionManager
-
Testability:
- Mock
ProxyPoolandSelectionContextfor unit tests - Isolated strategy testing
- Property-based testing with hypothesis
- Mock
-
Observability:
record_result()enables adaptive strategies- Logging of strategy selection decisions
- Metrics integration for strategy performance
Negative
-
Complexity:
- 8 built-in strategies to maintain
- Registry singleton adds global state
- Protocol validation requires runtime checks
-
Performance Variability:
- O(n) strategies (Least-Used, Geo) scale poorly with large pools
- Composite strategies multiply selection time
- Mitigated by filtering before expensive operations
-
Memory Overhead:
WeightedStrategycaches weights (O(n) memory)SessionManagerstores session mappings (O(sessions))- Mitigated by LRU eviction and TTL
-
Thread Contention:
- Round-robin lock serializes selections
- Session manager lock serializes session operations
- Mitigated by lock-free strategies (Random, Weighted)
-
Strategy Explosion:
- Many combinations possible (geo + weighted + session)
- Composite strategy helps but adds complexity
- Users may be overwhelmed by choices
Alternatives Considered
Class Inheritance:
class BaseStrategy(ABC):
@abstractmethod
def select(self, pool): ...- Tighter coupling to base class
- Harder to test in isolation
- Rejected: Protocol more flexible
Function-Based Strategies:
def round_robin_strategy(pool): ...- Simpler for stateless strategies
- No state management (e.g., round-robin index)
- No
record_result()for adaptive strategies - Rejected: Insufficient for complex strategies
Enum-Based Selection:
class StrategyType(Enum):
ROUND_ROBIN = "round_robin"
RANDOM = "random"- Simpler than registry
- Requires modifying enum for custom strategies
- No plugin architecture
- Rejected: Not extensible
External DSL:
strategy:
type: composite
filters:
- geo: {country: US}
selector:
type: performance- Configuration-driven
- Adds parsing complexity
- Runtime errors instead of compile-time
- Rejected: YAGNI (not needed yet)
Implementation Details
File Structure
proxywhirl/
├── strategies.py # All strategies + registry
├── models.py # StrategyConfig, SelectionContext
└── rotator.py # ProxyWhirl integrationKey Classes
StrategyRegistry:
- Singleton pattern with double-checked locking
- Thread-safe registration/retrieval
- Validation via Protocol checking
- <1s load time (SC-010)
SelectionContext:
class SelectionContext(BaseModel):
session_id: Optional[str] = None
target_country: Optional[str] = None
target_region: Optional[str] = None
failed_proxy_ids: list[str] = Field(default_factory=list)StrategyConfig:
class StrategyConfig(BaseModel):
weights: dict[str, float] = {} # For WeightedStrategy
exploration_count: Optional[int] = None # For PerformanceBasedStrategy
session_stickiness_duration_seconds: Optional[int] = None # For SessionPersistenceStrategy
geo_fallback_enabled: Optional[bool] = None # For GeoTargetedStrategy
geo_secondary_strategy: Optional[str] = None # For GeoTargetedStrategyIntegration with ProxyWhirl
class ProxyWhirl:
def __init__(self, strategy: Union[str, RotationStrategy] = "round-robin"):
if isinstance(strategy, str):
registry = StrategyRegistry()
strategy_class = registry.get_strategy(strategy)
self.strategy = strategy_class()
else:
self.strategy = strategy
async def get_proxy(self, context: Optional[SelectionContext] = None) -> Proxy:
proxy = self.strategy.select(self.pool, context)
# Use proxy...
success = ...
response_time_ms = ...
self.strategy.record_result(proxy, success, response_time_ms)
return proxyThread Safety Patterns
Round-Robin:
class RoundRobinStrategy:
def __init__(self):
self._current_index = 0
self._lock = threading.Lock()
def select(self, pool, context=None):
with self._lock:
index = self._current_index % len(pool)
self._current_index = (self._current_index + 1) % len(pool)
return pool[index]Session Manager:
class SessionManager:
def __init__(self):
self._sessions: dict[str, Session] = {}
self._lock = threading.RLock()
def get_session(self, session_id: str) -> Optional[Session]:
with self._lock:
return self._sessions.get(session_id)Weighted Strategy (lock-free):
class WeightedStrategy:
def select(self, pool, context=None):
# No lock needed - random.choices is GIL-protected
weights = self._get_weights(pool) # Cached
return random.choices(pool, weights=weights, k=1)[0]References
- Implementation:
/Users/ww/dev/projects/proxywhirl/proxywhirl/strategies.py - Models:
/Users/ww/dev/projects/proxywhirl/proxywhirl/models.py - Tests:
/Users/ww/dev/projects/proxywhirl/tests/unit/test_strategies*.py
Notes
Performance Benchmarks
From test suite:
- Round-Robin: <0.1ms per selection
- Random: <0.1ms per selection
- Weighted: <1ms per selection (cached), <5ms (cache miss)
- Least-Used: <1ms per selection (100 proxies)
- Performance-Based: <2ms per selection
- Session: <0.5ms per lookup
- Geo: <3ms per selection (1000 proxies)
- Composite: <5ms total (SC-007 compliant)
Design Rationale
Why Protocol over ABC?
- Structural subtyping (duck typing with validation)
- Third-party classes can be strategies without inheritance
- Easier mocking in tests
Why Registry Pattern?
- Decouples strategy names from implementations
- Enables plugin architecture
- Runtime strategy selection from config
Why SelectionContext?
- Centralized request metadata
- Avoids strategy-specific parameters
- Extensible without breaking interface
Why record_result()?
- Enables adaptive strategies (performance, weighted)
- Separates selection from outcome tracking
- Allows strategies to learn from feedback
Future Enhancements
-
Strategy Composition DSL:
strategy = (GeoTargeted(country="US") >> PerformanceBased() >> SessionPersistence()) -
Strategy Metrics:
- Track selection time per strategy
- Expose success rate by strategy
- Alert on strategy degradation
-
ML-Based Selection:
- Train model on historical proxy performance
- Predict optimal proxy for request
- Integrate with existing strategies
-
Strategy A/B Testing:
- Split traffic between strategies
- Compare performance metrics
- Auto-promote winning strategy