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.

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.

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.

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.

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.

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:

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

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:

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.

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.


Failure attribution — ProxyStatusParser (RFC 9209)

When a proxy returns an error response, RFC 9209 Proxy-Status headers classify the failure into one of three buckets so the operator knows whether to blame the proxy, the upstream, or the target:

ProxyErrorClassMeaningWhere to look
NetworkThe proxy itself could not reach the target (DNS, TCP, TLS handshake, read timeout)proxy machine, network path between proxy and target
ProviderThe proxy is at fault (misconfiguration, auth failure, internal proxy error)proxy logs, proxy auth credentials
TargetThe proxy reached the target but the target itself returned an error (4xx, 5xx, anti-bot block)the target itself, the request being made
UnknownThe header was absent or didn't match any known RFC 9209 patterntreat as Target and investigate from there

stygian_proxy::ProxyStatusParser is the consumer-owned port trait. Rfc9209Parser is the default adapter that parses a single Proxy-Status header value into a ProxyErrorClass. The parser distinguishes network-error, proxy-error, http-response-error and similar RFC 9209 named errors; anything else falls back to Unknown.

The trait exists so a future adapter can pull failure attribution from the proxy's structured log or a sidecar metric instead of scraping the header — the default adapter is just the first implementation. Wire the parser into your error-handling path by calling parser.classify(&header_value) on every error response and using the returned ProxyErrorClass to drive your retry strategy (Network → re-route, Provider → re-auth, Target → back off).

Geofeed verifier — confirm the proxy is in the country it claims

A "US residential" pool that silently egresses from a different country is worse than no proxy at all: it breaks geofenced test harnesses and burns compliance posture. stygian_proxy::GeofeedVerifier is the consumer-owned port that checks an observed egress IP against a geofeed's claimed country/region/city/ASN and reports any divergence.

InMemoryGeofeedAdapter is the default adapter. It holds a parsed geofeed (RFC 8805 format) in memory and matches against it. The adapter returns a GeofeedDivergence with three fields — what the geofeed claims (country/region/city), what the verification observed, and the ASN — whenever they disagree.

The verifier does not make routing decisions itself; it produces the divergence signal so callers can quarantine the proxy, drop it from the pool, or surface the discrepancy to the operator. Wire it in by calling verifier.verify(observed_ip) periodically (e.g. after each proxy health check) and demoting any proxy whose last observation diverged from its registered claim.