When a Snowpad connection fails, the gateway doesn't just drop it — it tells you why, in one short string: proxy_limit, unauthorized, upstream_dial, and a handful of others. These reasons show up in your dashboard's Recent Errors card and in every row of Usage Logs.
This guide covers each code: what it means, what causes it, and the exact fix — with copy-paste Python and Playwright code you can drop into your scraper today.
The 30-second version
| Error | Meaning | Fix |
|---|---|---|
proxy_limit |
Too many concurrent connections for your plan | Reduce worker count |
unauthorized |
Wrong, revoked, or rotated API key | Copy a fresh key from Credentials |
trial_expired |
Plan ended or trial lapsed | Renew on the Billing page |
no_proxies |
No phone online for your pool right now | Wait 30–60s and retry |
upstream_dial |
Exit phone lost signal mid-dial | Retry with backoff |
upstream_connect |
Target refused the phone's connection | Retry once, then check the target |
pool_capacity_exhausted |
Gateway at global capacity | Back off, retry in a minute |
handshake / missing_auth |
Malformed proxy handshake, no credentials sent | Check your client config |
Two rules that cover 90% of cases: retry network errors (upstream_dial, upstream_connect, no_proxies) with backoff, and fix config errors (proxy_limit, unauthorized, trial_expired) instead of retrying them.
proxy_limit — too many concurrent connections
This is the most common error for new Pro users. Your plan allows a fixed number of concurrent connections (Pro: 180, Dedicated: 80 per node). The moment connection #181 opens, the gateway rejects it with proxy_limit — instantly, no waiting.
Cause: a worker pool, thread pool, or Playwright farm bigger than your cap. The classic shape is asyncio.gather(*[fetch(u) for u in urls]) over thousands of URLs with no limiter.
Fix: cap concurrency below your plan limit and queue the rest. Leave headroom (50–70% of cap) so bursts don't trip the gate.
import asyncio
import aiohttp
# Pro allows 180 concurrent — run 100 workers, queue everything else.
SEMAPHORE = asyncio.Semaphore(100)
PROXY = "http://YOUR_API_KEY:x@gw.snowpad.io:9999"
async def fetch(session, url):
async with SEMAPHORE: # <-- this line fixes proxy_limit
async with session.get(url, proxy=PROXY, timeout=30) as r:
return await r.text()
async def main(urls):
async with aiohttp.ClientSession() as session:
return await asyncio.gather(*[fetch(session, u) for u in urls])Playwright equivalent — bound the browser contexts, not just the pages:
from playwright.async_api import async_playwright
PROXY = {"server": "http://gw.snowpad.io:9999",
"username": "YOUR_API_KEY", "password": "x"}
async def scrape(urls):
async with async_playwright() as p:
browser = await p.chromium.launch(proxy=PROXY)
sem = asyncio.Semaphore(20) # 20 concurrent pages max
async def one(url):
async with sem:
page = await browser.new_page()
try:
await page.goto(url, timeout=30000)
return await page.content()
finally:
await page.close()
return await asyncio.gather(*[one(u) for u in urls])unauthorized — bad or rotated key
The gateway couldn't match your SOCKS5 username to any active client. Three causes, in order of likelihood:
- Typo or whitespace — a trailing space or newline pasted into the key. Strip it.
- Rotated key — someone (maybe you, on the Credentials page) rotated the API key. The old key works through its grace window (default 24h, configurable down to instant cut) and then dies. If your scraper broke overnight, this is probably why.
- Deactivated client — plan expired or bandwidth exhausted. Check the dashboard banner first.
Fix: copy the current key from Dashboard → Credentials (it's masked — hit Show, then Copy) and update every place the old key lives: env files, CI secrets, teammate configs, running containers.
import os
key = os.environ["SNOWPAD_API_KEY"].strip() # strip() kills pasted whitespace
proxy = f"http://{key}:x@gw.snowpad.io:9999"trial_expired — plan ended
Your subscription's gateway clock passed. The dashboard shows a banner ("Your Pro plan ended on …") with a Renew button. No code fix — renew on the Billing page and connections resume immediately (the webhook re-syncs the gateway clock on payment).
If you just paid and still see trial_expired, wait ~1 minute for the hourly limit-sync cron, or open the dashboard once — either path re-pushes the new clock.
no_proxies — empty pool
No phone is currently online for your pool mode. On the shared rotation pool this is rare and transient (phones reconnect within a minute). On a dedicated plan it means your assigned node is offline — check the Credentials page: your sticky/dedicated node cards show per-node status.
Fix: wait 30–60 seconds and retry. If it persists beyond a few minutes on a dedicated plan, contact support — your node may need attention.
import asyncio, random
async def fetch_with_pool_wait(session, url, tries=4):
for attempt in range(tries):
try:
async with session.get(url, proxy=PROXY, timeout=30) as r:
return await r.text()
except aiohttp.ClientProxyConnectionError as e:
if "no_proxies" in str(e).lower() and attempt < tries - 1:
await asyncio.sleep(30 + random.uniform(0, 30))
continue
raiseupstream_dial / upstream_connect — phone-side blips
upstream_dial means the exit phone couldn't open the TCP connection (lost signal, tower handoff); upstream_connect means the target refused or timed out the phone. Both are inherent to mobile networks — phones move, tunnels flap, and Jio/Airtel CGNAT does what it does.
Fix: retry with exponential backoff. These are the only errors where blind retry is correct — the next attempt routes through a different phone.
import asyncio
async def fetch_resilient(session, url, tries=4):
delay = 2
for attempt in range(tries):
try:
async with session.get(url, proxy=PROXY, timeout=30) as r:
r.raise_for_status()
return await r.text()
except (aiohttp.ClientError, asyncio.TimeoutError):
if attempt == tries - 1:
raise
await asyncio.sleep(delay)
delay *= 2 # 2s, 4s, 8sA shared retry wrapper that dispatches on error type keeps this clean across a whole codebase:
RETRYABLE = ("upstream_dial", "upstream_connect", "no_proxies")
FATAL = ("proxy_limit", "unauthorized", "trial_expired")
def classify(exc: Exception) -> str:
msg = str(exc).lower()
if any(k in msg for k in FATAL):
return "fatal" # fix config, don't retry
if any(k in msg for k in RETRYABLE):
return "retry" # backoff and try again
return "retry" # unknown network blip: one cautious retrypool_capacity_exhausted — gateway is full
The whole gateway (not your plan) hit its global in-flight cap. This is rare and lasts seconds to a minute during fleet-wide spikes. Back off for 60 seconds and retry — do not hammer, or you'll extend the spike.
handshake / missing_auth — client misconfiguration
Your client opened a TCP connection to port 9999 but didn't complete a SOCKS5 handshake (handshake), or sent no credentials (missing_auth). Common causes: pointing an HTTP-only client at the SOCKS port (use port 8443 for HTTP CONNECT instead), or a health-check hitting the proxy port. If you see these in your error counts, check that your client is configured with socks5:// scheme and the API key as username.
Putting it together: the dashboard loop
- Recent Errors tells you what is failing in the last day — glance at it before every scraping run.
- If the top reason is
proxy_limitorunauthorized, fix your config (this guide's first two sections). - If it's
upstream_*orno_proxies, add the retry wrapper above and move on. - For row-level forensics — which destinations, which nodes, what time — filter Usage Logs by date range and look at the failure rows.
Errors are data. A scraper that classifies proxy_limit (scale down) separately from upstream_dial (retry) will run for months unattended; one that retries everything will burn its error budget by Tuesday.


