Rotation Strategies

stygian-proxy ships five built-in rotation strategies. Four — RoundRobin, Random, Weighted, LeastUsed — are always compiled. A fifth, ThompsonSampling, is gated behind the bayesian-rotation cargo feature. All implement the RotationStrategy trait and operate on a slice of ProxyCandidate values built from the live pool. Strategies that find zero healthy candidates return ProxyError::AllProxiesUnhealthy rather than panicking.


Comparison

StrategyBest forNotes
RoundRobinStrategyEven distribution across identical proxiesAtomic counter, lock-free
RandomStrategySpreading load unpredictablyrand::rng() per call; no shared state
WeightedStrategyPrioritising faster or higher-quota proxiesWeighted random sampling; O(n)
LeastUsedStrategyNever overloading a single proxyPicks the candidate with the lowest total request count
ThompsonSampling (feature bayesian-rotation)Concentrating traffic on proxies that actually work against the targetPer-proxy Beta(α, β) posterior with AtomicU64 counters; 76% vs 36% round-robin in the internal ProxyOps benchmark (549k requests / 7 days)

RoundRobinStrategy

Distributes requests evenly across healthy proxies in insertion order. Uses an atomic counter so there is no Mutex on the hot path.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use stygian_proxy::{MemoryProxyStore, ProxyConfig, ProxyManager};

let storage = Arc::new(MemoryProxyStore::default());
let manager = ProxyManager::with_round_robin(storage, ProxyConfig::default())?;
}

ProxyManager::with_round_robin is the recommended default. The counter wraps safely at u64::MAX, which at 1 million requests per second takes ~585,000 years.


RandomStrategy

Picks a healthy proxy at random on every call. Useful when you want to avoid any predictable rotation pattern that fingerprinting could detect.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use stygian_proxy::{MemoryProxyStore, ProxyConfig, ProxyManager};
use stygian_proxy::strategy::RandomStrategy;

let storage = Arc::new(MemoryProxyStore::default());
let manager = ProxyManager::builder()
    .storage(storage)
    .strategy(Arc::new(RandomStrategy))
    .config(ProxyConfig::default())
    .build()?;
}

WeightedStrategy

Each Proxy has a weight: u32 field (default 1). WeightedStrategy performs weighted random sampling so proxies with higher weights are selected proportionally more often.

#![allow(unused)]
fn main() {
use stygian_proxy::types::{Proxy, ProxyType};

// This proxy is 3× more likely to be selected than a weight-1 proxy.
let fast_proxy = Proxy {
    url: "http://fast.example.com:8080".into(),
    proxy_type: ProxyType::Http,
    weight: 3,
    ..Default::default()
};
}

Use this strategy when proxies have different capacities, quotas, or observed speeds.


LeastUsedStrategy

Selects the healthy proxy with the lowest total request count at the time of the call. This maximises even distribution over time even when proxies are added dynamically.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use stygian_proxy::{MemoryProxyStore, ProxyConfig, ProxyManager};
use stygian_proxy::strategy::LeastUsedStrategy;

let storage = Arc::new(MemoryProxyStore::default());
let manager = ProxyManager::builder()
    .storage(storage)
    .strategy(Arc::new(LeastUsedStrategy))
    .config(ProxyConfig::default())
    .build()?;
}

ThompsonSampling (Bayesian rotation)

ThompsonSampling keeps a per-proxy Beta(α, β) posterior over the success rate, with AtomicU64 counters updated by ProxyHandle::mark_success and the implicit "failure on drop" path. On every acquire it samples each candidate's posterior and picks the proxy with the highest Thompson draw — proxies with stronger evidence of success get more traffic, and the strategy concentrates rather than distributes load.

The internal ProxyOps benchmark (549,114 requests / 7 days, identical proxies) cites 76% success rate vs 36% for round-robin on protected targets. Hot-path acquire stays sub-microsecond.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::time::Duration;
use stygian_proxy::{MemoryProxyStore, ProxyConfig, ProxyManager};

let storage = Arc::new(MemoryProxyStore::default());
let mgr = ProxyManager::with_thompson_sampling(
    storage,
    ProxyConfig::default(),
    Duration::from_secs(300), // decay_interval — defaults to 5 min
)?;

// Optional: seed the bandit from a known-good feed so cold-start
// traffic is already informed.
mgr.strategy_warmup_observe(proxy_id_a, true).await;
mgr.strategy_warmup_observe(proxy_id_b, false).await;
}

Two knobs control how quickly stale observations age out of the posterior:

  • decay_interval (default 5 min) — how often to apply the decay.
  • decay_factor (default 0.95) — multiplicative weight applied to old observations.

A prior-bias seam lets the strategy weight proxies whose TargetVendorCompatibility is higher for the target vendor. See the ThompsonStrategy rustdoc for the full surface.

When the strategy is enabled, mark_success and the drop-failure path both feed the bandit — no separate observer call is required at the call site.


Capability filtering

All strategies operate on a pre-filtered ProxyCandidate slice. Before a strategy runs, ProxyManager filters the pool by CapabilityRequirement. Use acquire_with_capabilities(&req) to express requirements at call time:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use stygian_proxy::{MemoryProxyStore, ProxyConfig, ProxyManager};
use stygian_proxy::types::{CapabilityRequirement, IpClassRequirement, VendorId, well_known};

let storage = Arc::new(MemoryProxyStore::default());
let manager = ProxyManager::with_round_robin(storage, ProxyConfig::default())?;

let req = CapabilityRequirement {
    target_vendor: Some(VendorId::Akamai),
    require_ip_class: Some(IpClassRequirement::at_least(
        stygian_proxy::types::IpClass::Residential,
    )),
    require_tls_profile: Some("chrome-131".into()),
    require_asn: Some(well_known::KNOWN_ASN_AKAMAI),  // 20_940
    require_city: Some("Cambridge".into()),
    ..Default::default()
};
let handle = manager.acquire_with_capabilities(&req).await?;
}

When no candidate in the pool satisfies the requirement, the call returns ProxyError::NoCompatibleProxy — distinct from ProxyError::AllProxiesUnhealthy, which signals every proxy is currently circuit-open rather than structurally incompatible.

ProxyCapabilities fields

ProxyCapabilities is the per-proxy capability record. Every field is #[serde(default)], so a serialised payload from an older 0.13.x release deserialises cleanly into a ProxyCapabilities::default().

FieldTypeDescription
supports_https_connectboolProxy supports CONNECT for HTTPS tunnelling
supports_socks5_udpboolProxy supports SOCKS5 with UDP relay
supports_http3_tunnelboolProxy supports HTTP/3 (QUIC) tunnelling
geo_countryOption<String>ISO-3166-1 alpha-2 country code for the egress IP
geo_confidenceOption<f32>Confidence score [0.0, 1.0] for the geo data; None if unstated
is_cdn_edgebooltrue for CDN-fronted egress nodes (ProxyType::CdnEdge)
cdn_providerOption<String>Advisory CDN provider name (e.g. "cloudflare")
tls_profileOption<String>Named TLS fingerprint profile ("chrome-131", "firefox-120", "curl")
ip_classIpClassTrust class — Mobile / Isp / Residential / Datacenter / Unknown (default)
target_compatibilityTargetVendorCompatibilityPer-vendor trust tier overrides
asnOption<u32>Egress AS number; None means provider did not tag it
cityOption<String>Egress city (operator-declared)
postal_codeOption<String>Egress postal/ZIP code (operator-declared)

Proxy itself exposes two top-level fields that mirror and can override the nested values: ip_class: IpClass and target_compatibility: TargetVendorCompatibility.

CapabilityRequirement fields

CapabilityRequirement is the call-site filter. Every field is independently #[serde(default, skip_serializing_if = "Option::is_none")] — a serialised requirement with no filters round-trips to CapabilityRequirement::default().

FieldTypeSemantics
require_https_connectboolMust be true on the proxy's capabilities.supports_https_connect
require_socks5_udpboolMust be true on supports_socks5_udp
require_http3_tunnelboolMust be true on supports_http3_tunnel
require_geo_countryOption<String>Exact match against geo_country
require_cdn_edgeboolProxy must advertise is_cdn_edge = true
require_tls_profileOption<String>Exact match against tls_profile
require_ip_classOption<IpClassRequirement>Minimum trust class (rank-based — see below)
target_vendorOption<VendorId>Proxy's target_compatibility for this vendor must be non-Blocked
require_asnOption<u32>Exact match against capabilities.asn
require_cityOption<String>Exact match against capabilities.city
require_postal_codeOption<String>Exact match against capabilities.postal_code

IpClassRequirement wraps a minimum: IpClass field. The check is rank-based: Mobile (rank 4) outranks Isp (3) which outranks Residential (2) which outranks Datacenter (1); Unknown (0) satisfies only an at_least(Unknown) requirement. A proxy tagged IpClass::Unknown never satisfies a non-empty IP-class requirement, which is the safe default for legacy un-tagged proxies.

The target_vendor filter is the fail-secure gate on free-list pools: a proxy whose target_compatibility.get(VendorId) == Some(TrustTier::Blocked) does not satisfy target_vendor = Some(VendorId). Free-list fetchers populate every ingested proxy with TargetVendorCompatibility::default_blocked() so operators cannot accidentally route premium traffic through a public free-list pool.

Annotating proxies at registration time

#![allow(unused)]
fn main() {
use std::sync::Arc;
use stygian_proxy::{MemoryProxyStore, ProxyConfig, ProxyManager};
use stygian_proxy::types::{
    IpClass, Proxy, ProxyCapabilities, ProxyType, TargetVendorCompatibility,
    TrustTier, VendorId, well_known,
};

let storage = Arc::new(MemoryProxyStore::default());
let manager = ProxyManager::with_round_robin(storage, ProxyConfig::default())?;

// Operator-curated residential proxy, known to defeat Akamai.
manager.add_proxy(Proxy {
    url: "http://user:pass@edge1.example.com:8080".into(),
    proxy_type: ProxyType::Http,
    weight: 1,
    tags: vec!["eu-west".into()],
    capabilities: ProxyCapabilities {
        supports_https_connect: true,
        is_cdn_edge: true,
        cdn_provider: Some("akamai".into()),
        tls_profile: Some("chrome-131".into()),
        asn: Some(well_known::KNOWN_ASN_AKAMAI),
        city: Some("Cambridge".into()),
        ..Default::default()
    },
    ip_class: IpClass::Residential,
    target_compatibility: TargetVendorCompatibility::default()
        .set(VendorId::Akamai, TrustTier::Preferred),
    ..Default::default()
}).await?;
}

For ingest-time metadata without going through a Proxy struct literal, see add_proxy_with_metadata, which validates the URL against vendor_quirks::check and accepts (url, asn, city, postal_code) directly:

#![allow(unused)]
fn main() {
use stygian_proxy::types::well_known;
manager.add_proxy_with_metadata(
    "http://user:pass@edge1.example.com:8080".into(),
    well_known::KNOWN_ASN_AKAMAI,
    "Cambridge".into(),
    "02142".into(),
).await?;
}

Custom strategies

Implement RotationStrategy to plug in your own selection logic. Rust 2024 supports async fn in traits natively — no async_trait macro needed.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use stygian_proxy::error::ProxyResult;
use stygian_proxy::strategy::{ProxyCandidate, RotationStrategy};

/// Always pick the proxy with the best success rate.
pub struct BestSuccessRateStrategy;

impl RotationStrategy for BestSuccessRateStrategy {
    async fn select<'a>(
        &self,
        candidates: &'a [ProxyCandidate],
    ) -> ProxyResult<&'a ProxyCandidate> {
        candidates
            .iter()
            .filter(|c| c.healthy)
            .max_by(|a, b| {
                let ra = a.metrics.success_rate();
                let rb = b.metrics.success_rate();
                ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal)
            })
            .ok_or(stygian_proxy::error::ProxyError::AllProxiesUnhealthy)
    }
}

let storage = Arc::new(MemoryProxyStore::default());
let manager = ProxyManager::builder()
    .storage(storage)
    .strategy(Arc::new(BestSuccessRateStrategy))
    .config(ProxyConfig::default())
    .build()?;
}

ProxyCandidate fields

FieldTypeDescription
idUuidStable proxy identifier
weightu32Relative selection weight
metricsArc<ProxyMetrics>Shared atomics: requests, failures, latency
healthyboolResult of the last health check

ProxyMetrics exposes success_rate() -> f64 and avg_latency_ms() -> f64 computed from the atomic counters without any locking.