Sticky Sessions

Without session stickiness, every proxy-rotation call may select a different exit IP. Most sites treat the IP as part of the session identity. A login flow that acquires a cookie from IP A and then submits the form from IP B will fail — or worse, trigger a suspicious-behaviour alarm.

Sticky sessions bind a target domain to one proxy for a configurable duration, so all requests to the same site use the same exit IP for the lifetime of the binding. When the binding expires the next request automatically picks a fresh proxy and pins the new one.


Configuration

Sticky-session behaviour is controlled by ProxyConfig::sticky_policy:

#![allow(unused)]
fn main() {
use std::time::Duration;
use stygian_proxy::{ProxyConfig, session::StickyPolicy};

let config = ProxyConfig {
    sticky_policy: StickyPolicy::domain(Duration::from_secs(600)), // 10-minute sessions
    ..ProxyConfig::default()
};
}
Policy variantDescription
StickyPolicy::Disabled (default)No binding — every call may use a different proxy
StickyPolicy::Domain { ttl }Bind per domain name; TTL controls how long the binding lives

The default TTL for StickyPolicy::domain_default() is 5 minutes.


Using sticky sessions

Once the policy is set, use acquire_for_domain instead of acquire_proxy:

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

let storage = Arc::new(MemoryProxyStore::default());
let config  = ProxyConfig {
    sticky_policy: StickyPolicy::domain(Duration::from_secs(600)),
    ..ProxyConfig::default()
};
let mgr = ProxyManager::with_round_robin(storage, config)?;

// Add proxies to the pool …
mgr.add_proxy(Proxy {
    url:        "http://residential-proxy:8080".into(),
    proxy_type: ProxyType::Http,
    username:   Some("user".into()),
    password:   Some("pass".into()),
    weight:     1,
    tags:       vec!["residential".into()],
}).await?;

// ── Login flow ────────────────────────────────────────────────────────────────

// All three calls reuse the same proxy for "store.example.com"
// as long as the 10-minute TTL has not elapsed.

let handle = mgr.acquire_for_domain("store.example.com").await?;
let _ = client_with_proxy(&handle).get("https://store.example.com/login").send().await?;
handle.mark_success();

let handle = mgr.acquire_for_domain("store.example.com").await?;
let _ = client_with_proxy(&handle)
    .post("https://store.example.com/session")
    .form(&[("user", "alice"), ("pass", "hunter2")])
    .send()
    .await?;
handle.mark_success();

let handle = mgr.acquire_for_domain("store.example.com").await?;
let _ = client_with_proxy(&handle).get("https://store.example.com/account").send().await?;
handle.mark_success();
Ok(())
}

Session lifecycle

stateDiagram-v2
    [*] --> Unbound : First request to domain

    Unbound --> Bound : acquire_for_domain()\nselects proxy via strategy\nbind(domain, proxy_id, ttl)

    Bound --> Bound : acquire_for_domain()\nlookup() hit — same proxy returned

    Bound --> Expired : TTL elapsed

    Expired --> Bound : acquire_for_domain()\npicks fresh proxy\nbinds new session

    Bound --> Unbound : ProxyHandle dropped\nwithout mark_success()\nunbind(domain) called

    Bound --> Unbound : proxy removed from pool\nor circuit breaker open\nsession invalidated

Failure handling

ProxyHandle is a RAII guard. When it is dropped without calling mark_success(), the sticky session for that domain is automatically invalidated and the circuit breaker records a failure:

#![allow(unused)]
fn main() {
let handle = mgr.acquire_for_domain("shop.example.com").await?;

// If the request fails or the guard is dropped without mark_success(),
// the domain session is cleared and the circuit breaker is incremented.
// The next call to acquire_for_domain picks a fresh proxy.
let resp = client.get("https://shop.example.com/checkout").send().await?;

if resp.status().is_success() {
    handle.mark_success();  // binding stays alive for the remainder of the TTL
}
// else: drop without mark_success → session reset automatically
}

Low-level SessionMap API

SessionMap can also be used standalone, outside of ProxyManager, when you need fine-grained control:

#![allow(unused)]
fn main() {
use std::time::Duration;
use uuid::Uuid;
use stygian_proxy::session::SessionMap;

let sessions = SessionMap::new();
let proxy_id = Uuid::new_v4();

// Bind "login.example.com" to a proxy for 5 minutes.
sessions.bind("login.example.com", proxy_id, Duration::from_secs(300));

// Lookup returns Some(id) while the session is active.
assert_eq!(sessions.lookup("login.example.com"), Some(proxy_id));

// Purge all expired entries — safe to call on any schedule.
let removed = sessions.purge_expired();
println!("purged {removed} expired sessions");

// Manually invalidate a binding.
sessions.unbind("login.example.com");
}
MethodDescription
bind(domain, proxy_id, ttl)Create or overwrite a domain binding
lookup(domain) -> Option<Uuid>Return the bound proxy ID, or None if expired
unbind(domain)Remove a binding immediately
purge_expired() -> usizeEvict all expired bindings; returns count removed
active_count() -> usizeNumber of non-expired bindings currently held

Pool stats

ProxyManager::pool_stats() includes sticky session state:

#![allow(unused)]
fn main() {
let stats = mgr.pool_stats().await?;
println!("total proxies:    {}", stats.total);
println!("healthy proxies:  {}", stats.healthy);
println!("circuit open:     {}", stats.open);
println!("active sessions:  {}", stats.active_sessions);
}

Multi-domain scraping

When you scrape many domains concurrently, each gets its own independent binding. The ProxyManager session map is an Arc<RwLock<HashMap<String, ...>>> so concurrent lookups never block each other:

#![allow(unused)]
fn main() {
// Different domains → different proxies, each bound separately.
let h1 = mgr.acquire_for_domain("shop-a.com").await?;
let h2 = mgr.acquire_for_domain("shop-b.com").await?;
let h3 = mgr.acquire_for_domain("shop-c.com").await?;

// All three run in parallel — each gets its own sticky proxy.
tokio::join!(
    fetch(&h1, "https://shop-a.com/products"),
    fetch(&h2, "https://shop-b.com/products"),
    fetch(&h3, "https://shop-c.com/products"),
);
}