The Problem: AI Agents Get Banned Mid-Session
You built an AI agent that logs into a website, browses a few pages, and extracts data. It works perfectly in testing. Then you deploy it at scale and the bans start.
The root cause is almost always IP rotation during an active session. Here is what happens:
- Your agent starts a session with IP
103.87.91.42(a mobile phone in India) - The website sets a session cookie tied to that IP
- Your next request rotates to IP
103.87.91.42(a different phone in Bangalore) - The website sees a new IP on an existing session and flags it as suspicious
- Account locked, CAPTCHA triggered, or silent ban applied
This is the #1 reason AI agents fail on login-protected sites. The fix is sticky proxies.
What Is a Sticky Proxy?
A sticky proxy routes all your traffic through the same mobile phone every time. Instead of rotating IPs across the pool, your connection always exits through one device.
| Feature | Rotating Proxy | Sticky Proxy |
|---|---|---|
| IP behavior | Different IP each request | Same IP every request |
| Use case | Anonymous scraping, one-shot requests | Login, multi-page flows, session state |
| API key | Your account API key | Per-node API key (one per phone) |
| Concurrency | Spread across pool | 5 connections per node |
| Bandwidth | Per-GB billing | Unlimited (flat rate) |
| Best for | Data collection at scale | AI agents, browsers, stateful workflows |
How Sticky Proxies Work on Snowpad
Each sticky node is a real phone running frpc (a reverse proxy client) that tunnels its mobile data connection to our gateway. When you connect with a sticky API key:
Your Agent → gw.snowpad.io:9999 → Gateway looks up node_api_key → Routes directly to one specific phone → Phone's mobile IP exits to the target websiteThe gateway does no rotation. It routes directly to the assigned phone. This means:
- Same IP every time — The phone's mobile IP is your exit IP
- Same geographic location — The phone stays in the same city/region/carrier
- No per-GB billing — Sticky plans are flat-rate with unlimited bandwidth
- 5 concurrent connections per node — The phone can handle 5 simultaneous TCP connections
Getting Your Sticky Credentials
On the Snowpad dashboard, go to Proxies. You will see a Sticky Credentials section with one card per assigned node:
Sticky API Key: YOUR_KEY
SOCKS5 URL: socks5://YOUR_KEY:x@gw.snowpad.io:9999
Status: online
Location: Rishikesh, UttarakhandEach sticky node has its own unique API key. Use this key as the SOCKS5 username to always route through that specific phone.
Quick Start: Python
Install the SOCKS5 library:
pip install requests[socks]Sticky IP — Same IP Every Request
import requests
STICKY_KEY = "YOUR_KEY"
PROXY = f"socks5://{STICKY_KEY}:x@gw.snowpad.io:9999"
# Every request exits through the same mobile phone
r1 = requests.get("https://httpbin.org/ip", proxies={"https": PROXY}, timeout=20)
print(r1.json()) # {"origin": "103.87.91.42"}
r2 = requests.get("https://httpbin.org/ip", proxies={"https": PROXY}, timeout=20)
print(r2.json()) # {"origin": "103.87.91.42"} — same IP!
r3 = requests.get("https://httpbin.org/ip", proxies={"https": PROXY}, timeout=20)
print(r3.json()) # {"origin": "103.87.91.42"} — still the sameLogin + Browse Workflow (Session Persistence)
import requests
STICKY_KEY = "YOUR_STICKY_API_KEY_HERE"
PROXY = f"socks5://{STICKY_KEY}:x@gw.snowpad.io:9999"
session = requests.Session()
session.proxies = {"http": PROXY, "https": PROXY}
# Step 1: Login — all requests share the same IP
login = session.post("https://example.com/login", data={
"email": "user@example.com",
"password": "secret"
}, timeout=20)
# Step 2: Browse — session cookie + same IP
dashboard = session.get("https://example.com/dashboard", timeout=20)
# Step 3: Extract data — still same IP
data = session.get("https://example.com/api/data", timeout=20)
print(f"Login: {login.status_code}, Dashboard: {dashboard.status_code}")Concurrent Requests (Stay Under 5 Per Node)
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
STICKY_KEY = "YOUR_STICKY_API_KEY_HERE"
PROXY = f"socks5://{STICKY_KEY}:x@gw.snowpad.io:9999"
# MAX 5 concurrent connections per sticky node
# Going over 5 gets you error 0x02 (capacity reached)
MAX_CONCURRENT = 5
urls = [f"https://httpbin.org/get?page={i}" for i in range(20)]
def fetch(url):
return requests.get(url, proxies={"https": PROXY}, timeout=20)
# Process in batches of 5
results = []
for i in range(0, len(urls), MAX_CONCURRENT):
batch = urls[i:i+MAX_CONCURRENT]
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT) as executor:
futures = {executor.submit(fetch, url): url for url in batch}
for future in as_completed(futures):
try:
resp = future.result()
results.append({"url": futures[future], "status": resp.status_code})
except Exception as e:
results.append({"url": futures[future], "error": str(e)})
print(f"Batch {i//MAX_CONCURRENT + 1}: {len([r for r in results if 'status' in r])} success")Quick Start: Playwright (Node.js)
import { chromium } from 'playwright';
const STICKY_KEY = 'YOUR_STICKY_API_KEY_HERE';
const browser = await chromium.launch({
proxy: {
server: 'socks5://gw.snowpad.io:9999',
username: STICKY_KEY,
password: 'x',
},
});
const page = await browser.newPage();
await page.goto('https://httpbin.org/ip');
const ip = await page.evaluate(() => document.body.innerText);
console.log(ip); // {"origin": "103.87.91.42"}
// Navigate to login — same IP maintained throughout the browser session
await page.goto('https://example.com/login');
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'secret');
await page.click('#login-button');
await page.waitForNavigation();
// Continue browsing — still the same IP
await page.goto('https://example.com/dashboard');
// ...extract data...
await browser.close();Quick Start: cURL
# Test sticky connection — same IP every time
STICKY_KEY="YOUR_STICKY_API_KEY_HERE"
curl -x socks5://${STICKY_KEY}:x@gw.snowpad.io:9999 https://httpbin.org/ip
# {"origin": "103.87.91.42"}
curl -x socks5://${STICKY_KEY}:x@gw.snowpad.io:9999 https://httpbin.org/ip
# {"origin": "103.87.91.42"} — same IP
# HTTP CONNECT proxy (alternative port)
curl -x http://${STICKY_KEY}:x@gw.snowpad.io:8443 https://httpbin.org/ip
# {"origin": "103.87.91.42"}Error Handling: Every Error Code and What to Do
When the gateway rejects your connection, it returns a SOCKS5 reply code. Here is what each one means and how to handle it:
0x02 — Connection Not Allowed by Ruleset
This is the most common error. It means the gateway rejected your request based on policy.
| Cause | Log Reason | Fix |
|---|---|---|
| Invalid or inactive API key | unauthorized |
Check your API key. Ensure your account is active and paid. |
| Too many concurrent connections | proxy_limit or sticky_capacity |
Max 5 concurrent connections per sticky node. Reduce parallel requests. |
| No proxies available | no_proxies |
Your sticky node is offline. Wait and retry, or contact support. |
import requests
import time
STICKY_KEY = "YOUR_STICKY_API_KEY_HERE"
PROXY = f"socks5://{STICKY_KEY}:x@gw.snowpad.io:9999"
MAX_RETRIES = 3
RETRY_DELAYS = [2, 5, 10] # Exponential backoff
def fetch_with_retry(url):
for attempt in range(MAX_RETRIES):
try:
resp = requests.get(url, proxies={"https": PROXY}, timeout=20)
return resp
except requests.exceptions.ProxyError as e:
if "0x02" in str(e):
if attempt < MAX_RETRIES - 1:
delay = RETRY_DELAYS[attempt]
print(f"Capacity reached (0x02), retrying in {delay}s...")
time.sleep(delay)
continue
raise Exception(f"Sticky node at capacity after {MAX_RETRIES} retries")
raise
raise Exception("Max retries exceeded")0x05 — Connection Refused (Upstream Failed)
The sticky phone's mobile data cannot reach the target website. This is a network issue on the phone side.
| Cause | Log Reason | Fix |
|---|---|---|
| Phone lost mobile data | sticky_upstream_failed |
Wait 5-10 seconds and retry. The phone reconnects automatically. |
| Target website is down | sticky_upstream_failed |
Try a different target URL to confirm it is the phone, not the site. |
| Phone is offline | sticky_upstream_failed |
Check the node status on the dashboard. If offline for more than a minute, contact support. |
def fetch_with_upstream_retry(url, max_retries=3):
"""Retry on upstream failures with exponential backoff."""
for attempt in range(max_retries):
try:
resp = requests.get(url, proxies={"https": PROXY}, timeout=20)
return resp
except requests.exceptions.ProxyError as e:
if "0x05" in str(e):
delay = 2 ** attempt # 1s, 2s, 4s
print(f"Upstream failed (0x05), retrying in {delay}s... (attempt {attempt+1}/{max_retries})")
time.sleep(delay)
continue
raise
except requests.exceptions.ConnectionError:
# Network issue, retry
time.sleep(2 ** attempt)
continue
raise Exception(f"Upstream failed after {max_retries} retries")0x01 — General SOCKS Server Failure
This means the SOCKS5 handshake itself failed. Common causes:
- Connection timeout during handshake (10-second limit)
- Invalid SOCKS5 client implementation
- Network connectivity issues between your server and the gateway
# Increase timeout and use a proper SOCKS5 library
import socks
import socket
import ssl
def sticky_request(host, port, sticky_key, target_host, target_port=443, timeout=20):
"""Raw SOCKS5 connection with proper error handling."""
s = socks.socksocket()
s.set_proxy(socks.SOCKS5, host, port, username=sticky_key, password="x")
s.settimeout(timeout)
try:
s.connect((target_host, target_port))
# Wrap in TLS for HTTPS
ctx = ssl.create_default_context()
ctx.check_hostname = True
ctx.verify_mode = ssl.CERT_REQUIRED
s = ctx.wrap_socket(s, server_hostname=target_host)
return s
except socks.GeneralProxyError as e:
error_str = str(e)
if "0x02" in error_str:
raise Exception("Sticky node at capacity (5 concurrent max). Reduce parallel requests.")
elif "0x05" in error_str:
raise Exception("Upstream failed. Phone may have lost connectivity. Retry in 5-10 seconds.")
elif "0x01" in error_str:
raise Exception("SOCKS5 handshake failed. Check network connectivity.")
raise
except socket.timeout:
raise Exception("Connection timed out. The phone may be offline or the target site is slow.")Best Practices for AI Agents
1. Use Sticky for Login-Protected Sites, Rotating for Bulk Collection
Login flow: Sticky proxy (same IP across login, browse, extract)
Data scraping: Rotating proxy (different IP per request to avoid rate limits)If your agent logs in and then scrapes, use a sticky proxy for the login and session, then switch to rotating for bulk data collection.
2. Never Exceed 5 Concurrent Connections Per Sticky Node
Each phone can handle 5 simultaneous TCP connections. If you exceed this, new connections get error 0x02 (capacity reached).
# BAD: 10 concurrent requests to same sticky node → 5 will fail
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(fetch, url) for url in urls]
# GOOD: Batch in groups of 5
for batch in chunked(urls, 5):
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(fetch, url) for url in batch]3. Implement Exponential Backoff on 0x05 Errors
Mobile data can briefly drop. The phone reconnects automatically within seconds. Do not give up after one failure.
def fetch_with_backoff(url, max_retries=3):
for i in range(max_retries):
try:
return requests.get(url, proxies={"https": PROXY}, timeout=20)
except requests.exceptions.ProxyError:
time.sleep(min(2 ** i, 10)) # 1s, 2s, 4s, capped at 10s
raise Exception(f"Failed after {max_retries} retries")4. Set Timeouts Generously
Mobile proxies add ~500-800ms latency per hop. Set timeouts to at least 20 seconds for web requests.
# BAD: 5-second timeout will fail frequently on mobile proxies
requests.get(url, proxies={"https": PROXY}, timeout=5)
# GOOD: 20-second timeout accounts for mobile network variability
requests.get(url, proxies={"https": PROXY}, timeout=20)5. Verify Your Sticky IP Before Starting a Workflow
def verify_sticky_ip():
"""Confirm sticky proxy is working and get the exit IP."""
try:
r = requests.get("https://api.ipify.org/", proxies={"https": PROXY}, timeout=20)
ip = r.json()["ip"]
print(f"Sticky IP confirmed: {ip}")
return ip
except Exception as e:
print(f"Sticky proxy not available: {e}")
return None6. Handle Node Offline Gracefully
Sticky phones can go offline briefly (network switches, signal drops). Your agent should detect this and retry rather than crashing.
import time
def fetch_with_node_offline_handling(url, max_retries=5, base_delay=3):
"""Handle temporary node offline with increasing delays."""
for attempt in range(max_retries):
try:
resp = requests.get(url, proxies={"https": PROXY}, timeout=20)
return resp
except requests.exceptions.ProxyError as e:
if "0x05" in str(e):
delay = base_delay * (2 ** attempt) # 3s, 6s, 12s, 24s, 48s
print(f"Node may be offline (0x05). Waiting {delay}s before retry {attempt+1}/{max_retries}")
time.sleep(delay)
continue
raise
except requests.exceptions.ConnectionError:
delay = base_delay * (2 ** attempt)
print(f"Connection failed. Waiting {delay}s before retry {attempt+1}/{max_retries}")
time.sleep(delay)
continue
raise Exception(f"Node offline after {max_retries} retries. Check dashboard status.")7. Don't Reuse HTTP Sessions Across Different Sticky Nodes
Each sticky node has a different IP. If you switch nodes mid-session, the target site will see a different IP on an existing session — exactly the problem sticky proxies solve.
# BAD: Reusing session with different sticky keys
session = requests.Session()
session.proxies = {"https": f"socks5://{KEY_1}:x@gw.snowpad.io:9999"}
session.get("https://example.com/login") # IP: 103.87.91.42
session.proxies = {"https": f"socks5://{KEY_2}:x@gw.snowpad.io:9999"}
session.get("https://example.com/dashboard") # IP: 103.87.91.42 — different IP!
# GOOD: New session for each sticky node
def create_sticky_session(sticky_key):
session = requests.Session()
session.proxies = {
"http": f"socks5://{sticky_key}:x@gw.snowpad.io:9999",
"https": f"socks5://{sticky_key}:x@gw.snowpad.io:9999",
}
return sessionTroubleshooting Cheat Sheet
| Symptom | Error | Cause | Fix |
|---|---|---|---|
0x02 immediately |
Connection not allowed | Invalid/inactive API key | Check dashboard → Proxies → Sticky Credentials |
0x02 after several requests |
Connection not allowed | > 5 concurrent connections | Reduce max_workers to ≤ 5 per sticky key |
0x05 on every request |
Connection refused | Sticky node is offline | Check node status on dashboard. Wait 1-2 minutes. |
0x05 intermittently |
Connection refused | Phone lost mobile data briefly | Retry with exponential backoff (1s, 2s, 4s) |
0x01 timeout |
SOCKS server failure | Network issue or handshake timeout | Increase timeout to 20s. Check your VPS can reach gw.snowpad.io:9999. |
| IP changes between requests | — | Using account API key instead of sticky key | Use the per-node node_api_key, not the account API key |
| Very slow (>5s) | — | Mobile network latency spike | Vi (Vodafone Idea) network has occasional spikes. Use retry logic. |
| Dashboard shows node offline | — | Phone app stopped or lost connectivity | Restart the phone app. Check mobile data is on. |
HTTP CONNECT Alternative (Port 8443)
If SOCKS5 port 9999 is blocked on your network, use HTTP CONNECT on port 8443:
# HTTP CONNECT proxy (alternative to SOCKS5)
PROXY_HTTP = f"http://{STICKY_KEY}:x@gw.snowpad.io:8443"
r = requests.get("https://httpbin.org/ip", proxies={"https": PROXY_HTTP}, timeout=20)Both ports route through the same sticky node. Use whichever your network allows.