Understanding Proxy Timeouts and Retry Logic
Proxy timeouts fail at seven layers, not one. Learn what DNS, connect, TLS, CONNECT, TTFB and read timeouts measure, and how to budget them across retries.

Proxy timeouts are the single most misconfigured setting in production scrapers, and the reason is simple: most HTTP clients present one number called timeout, while a proxied request can stall at seven independent points. Set that number too low and you manufacture failures that never existed. Set it too high and a handful of slow requests eat the throughput of your entire worker pool.
This article breaks a proxied request into its real timeout layers, shows how to pick values from measured percentiles instead of round numbers, and covers the part almost everyone skips: bounding total wall time across a retry chain, and deciding which failures are actually safe to retry. For the backoff math itself (exponential growth, jitter, Retry-After), see the companion piece on retry and backoff strategies for web scraping. This one is about the clocks.
The seven timeouts hiding behind one setting
A direct request has four phases. A proxied HTTPS request has seven, because the proxy hop adds its own resolution, connection, and tunnel establishment before the target is even contacted.
| # | Layer | Clock starts when | Clock stops when | Typical failure |
|---|---|---|---|---|
| 1 | DNS resolution | Client needs the proxy's IP | Resolver returns an address | Resolver down, no cache, IPv6 fallback delay |
| 2 | TCP connect | SYN sent to the proxy | SYN-ACK received | Firewall drop, dead proxy node, wrong port |
| 3 | TLS handshake | ClientHello sent | Handshake finished | Cert chain issues, slow entropy, MITM inspection |
| 4 | Proxy CONNECT | `CONNECT host:443` sent | `200 Connection established` | Target unreachable from exit, auth rejected |
| 5 | Time to first byte | Request bytes flushed | First response byte arrives | Slow origin, rendering, queueing, silent block |
| 6 | Read / total | First byte received | Last byte received | Slow drip body, stalled stream, huge payload |
| 7 | Idle socket | Response complete | Socket reused or reaped | Pool hands you a socket the proxy already closed |
Layers 1 through 4 are the handshake family. They either succeed in tens of milliseconds or they never succeed. Layers 5 and 6 are the response family, and their distribution has a long right tail: a slow page is still a real page. Layer 7 is neither, and it's the one that produces the confusing ECONNRESET at 3 a.m.
That split matters more than any individual number. Handshake timeouts should be generous relative to the p99 because being generous costs almost nothing. Response timeouts should be tight relative to the p99 because being generous costs worker capacity. Treating all seven as one timeout=30 throws that distinction away.
What each layer actually measures
You can see all seven in one command. curl exposes per-phase timing through its write-out format, which is the fastest honest way to find out where your requests actually spend time.
curl -x http://user:pass@gate.sparkproxy.io:10000 \
-o /dev/null -s \
-w 'dns=%{time_namelookup}s connect=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
https://www.sparkproxy.io/
Sample output from a healthy datacenter exit:
dns=0.004s connect=0.031s tls=0.098s ttfb=0.412s total=0.489s
These values are cumulative, not per phase. TLS handshake time is time_appconnect - time_connect, so 0.098 minus 0.031 gives 67 ms. TTFB attributable to the origin is time_starttransfer - time_appconnect, or 314 ms. Body download is time_total - time_starttransfer, 77 ms here.
A few layer-specific details that change how you configure things:
DNS with a proxy resolves the wrong name. When you use an HTTP proxy, your client resolves the proxy's hostname, not the target's. The target hostname is resolved at the exit. Same for socks5h:// (the h means remote DNS), while plain socks5:// resolves locally. So a client-side DNS timeout protects you against a slow resolver for gate.sparkproxy.io and does nothing at all about a slow lookup for the site you're scraping. If target DNS is slow, it shows up in your CONNECT or TTFB numbers, and no amount of local resolver tuning fixes it.
Proxy CONNECT is its own round trip. For HTTPS through an HTTP proxy, the client sends CONNECT target:443 HTTP/1.1, and the proxy replies 200 Connection established before any TLS to the target begins. Most client libraries fold this into "connect", which means a target that's unreachable from the exit node looks identical to a proxy that's down. The HTTP tunnel mechanics behind that exchange explain why you sometimes see two TLS handshakes on the wire for a single request.
Read timeout is usually per-read, not total. This is the gotcha that produces support tickets. In Python's requests, the read timeout is the maximum gap between bytes, not the maximum duration of the response. A server that dribbles one byte every 5 seconds will happily hold a timeout=(5, 10) connection open indefinitely, because no single gap exceeds 10 seconds. If you need a hard ceiling on total duration, you need a separate mechanism.
import requests
# WRONG assumption: this does NOT cap the request at 10 seconds.
# 10s is the maximum silence between bytes.
r = requests.get(
"https://www.sparkproxy.io/",
proxies={"https": "http://user:pass@gate.sparkproxy.io:10000"},
timeout=(5, 10), # (connect, read)
stream=True,
)
httpx behaves the same way for read, and adds a pool timeout for waiting on a free connection. Only a wall-clock guard, an AbortSignal.timeout() in Node, or a context.WithTimeout in Go gives you a true total ceiling.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
How a short connect timeout manufactures failures
Here's the pattern. Someone sees average connect times of 30 ms, sets connect_timeout=1, and ships it. Success rate drops two points and nobody knows why.
Connect latency isn't normally distributed. It's roughly bimodal: a working path completes in one round trip, and a broken path never completes at all. But the "working path" bucket has a fat edge, and that edge is made of things that have nothing to do with the proxy's health:
- TCP retransmission. If the initial SYN is lost, Linux retries after roughly 1 second (
tcp_syn_retries, initial RTO of 1s), then 3s, then 7s. A single dropped SYN on a lossy path pushes a 30 ms connect to 1030 ms. A 1-second timeout kills every request that hits one packet of loss. - Cross-region round trips. A worker in Frankfurt reaching a Singapore exit pays 160 ms RTT before anything else. Three round trips for TCP plus TLS is roughly 500 ms of pure physics.
- IPv6 fallback. If your host advertises IPv6 but the path is broken, Happy Eyeballs takes 250 ms before trying IPv4, and older clients take far longer.
- Cold pool warmup. The first connection to a fresh exit does full TLS. Subsequent ones may resume in one round trip.
- Local resource contention. A saturated event loop or a GIL-blocked thread delays the socket call itself. The clock is running while your own process is busy.
None of those mean the proxy is broken, and every one of them is retried away by a client that just waits a bit longer. When you cut them off at 1 second, you convert transient network noise into hard failures, then you retry, which doubles your connection setup cost and pushes concurrency up, which makes local contention worse. The failure is self-reinforcing.
The rule that holds up in production: handshake timeouts should sit near the p99.9, with a floor of about 3 seconds and a ceiling of about 10. A failed connect costs you one wasted socket and a retry, so a few extra seconds of patience is cheap. Being wrong in the other direction is not.
There's a second-order effect worth naming. Aggressive connect timeouts inflate your measured error rate, which inflates your retry volume, which counts against concurrent connection limits on your plan. Teams then see 429s, conclude they need a bigger plan, and buy capacity to serve traffic that only exists because of a bad timeout.
How a long read timeout collapses throughput
The opposite mistake is quieter and more expensive. Long read timeouts don't cause errors, they cause your fleet to sit still.
A worker blocked on a read holds its slot for the entire duration. Little's Law gives you the arithmetic: with a fixed pool of N workers, throughput equals N / mean_service_time. The mean is what matters, and a small fraction of very slow requests dominates the mean.
Take 50 workers, a normal response time of 1.2 seconds, and 2 percent of requests that stall until they hit the read timeout.
| Read timeout | Mean service time | Throughput | Worker time spent on the stalled 2% |
|---|---|---|---|
| 120 s | 0.98(1.2) + 0.02(120) = **3.58 s** | 14.0 req/s | 67% |
| 60 s | 0.98(1.2) + 0.02(60) = **2.38 s** | 21.0 req/s | 51% |
| 30 s | 0.98(1.2) + 0.02(30) = **1.78 s** | 28.1 req/s | 34% |
| 15 s | 0.98(1.2) + 0.02(15) = **1.48 s** | 33.9 req/s | 20% |
Read that last column again. At a 120-second read timeout, two percent of your requests consume sixty-seven percent of your total worker time. The other 98 percent of the work is being crowded out by requests that were probably never going to return anything useful. Dropping to 15 seconds costs you nothing except the tiny slice of genuinely slow pages that would have completed between 15 and 120 seconds, and those get a retry anyway.
The counterintuitive conclusion: cutting a stalled request early and retrying it is usually faster than waiting for it. A retry costs one fresh handshake, roughly 100 to 400 ms, plus a new attempt on a different exit that will probably succeed at p50 speed. Waiting costs you the full remaining timeout on a connection that has already demonstrated it's unhealthy.
The exception is expensive work you can't cheaply repeat. A JavaScript-rendered page, a multi-step browser scenario, a large PDF export: those genuinely take longer, so the timeout has to be per-route. Static JSON endpoints and headless-browser renders should never share a number.
Measure first: setting timeouts from percentiles
Round numbers are a tell. 30, 60, 10 show up in config files because they're memorable, not because anyone measured. Timeouts should come from the observed distribution of your own traffic against your own targets.
Collect the phases, then compute percentiles per layer:
import statistics
import time
import httpx
PROXY = "http://user:pass@gate.sparkproxy.io:10000"
TARGET = "https://www.sparkproxy.io/"
phases = {"connect": [], "tls": [], "ttfb": [], "total": []}
def sample(n: int = 500) -> None:
with httpx.Client(proxy=PROXY, timeout=httpx.Timeout(30.0)) as client:
for _ in range(n):
marks = {}
def on_connect(_): marks["connect"] = time.perf_counter()
def on_tls(_): marks["tls"] = time.perf_counter()
start = time.perf_counter()
try:
with client.stream(
"GET",
TARGET,
extensions={"trace": lambda name, info: (
on_connect(info) if name == "connection.connect_tcp.complete"
else on_tls(info) if name == "connection.start_tls.complete"
else None
)},
) as r:
first_byte = time.perf_counter()
r.read()
end = time.perf_counter()
except httpx.HTTPError:
continue
phases["connect"].append((marks.get("connect", start) - start) * 1000)
phases["tls"].append((marks.get("tls", start) - marks.get("connect", start)) * 1000)
phases["ttfb"].append((first_byte - start) * 1000)
phases["total"].append((end - start) * 1000)
def pct(values, p):
return statistics.quantiles(sorted(values), n=1000)[p - 1]
sample()
for name, values in phases.items():
print(f"{name:8s} p50={pct(values, 500):8.1f}ms "
f"p95={pct(values, 950):8.1f}ms p99={pct(values, 990):8.1f}ms")
A representative run against a datacenter pool looks like this, and the rightmost column is where the judgment lives:
| Phase | p50 | p95 | p99 | Rule applied | Timeout |
|---|---|---|---|---|---|
| DNS (proxy host) | 4 ms | 22 ms | 61 ms | flat floor | 2 s |
| TCP connect | 28 ms | 61 ms | 143 ms | near p99.9, floor 3 s | 5 s |
| TLS handshake | 45 ms | 112 ms | 258 ms | folded into connect budget | 5 s |
| Proxy CONNECT | 180 ms | 520 ms | 1.4 s | near p99.9, floor 3 s | 8 s |
| TTFB | 0.9 s | 3.8 s | 9.1 s | 1.5x p99 | 14 s |
| Total (no JS) | 1.2 s | 4.6 s | 11.2 s | 1.5x p99 | 17 s |
Two rules, applied differently by family:
- Handshake layers: set near p99.9, with a 3 second floor. The distribution is bimodal, the tail is thin, and generosity is cheap.
- Response layers: set at 1.5x to 2x the p99 of successful responses. Compute that p99 from successes only. If you include timeouts in the sample, your p99 converges to your current timeout and the metric becomes circular.
Re-measure whenever you change target mix, exit type, or region. Residential exits routinely run 3 to 5 times the connect latency of datacenter exits because the last hop is a consumer link, so a single global config is wrong for a mixed pool. See proxy uptime and reliability for how to keep that measurement running continuously rather than as a one-off.
Timeout budgets across a retry chain
This is the part most retry code gets wrong. A fixed per-attempt timeout plus a retry loop produces an unbounded worst case, and nobody notices until a queue backs up.
Consider a common config: 20-second timeout, 4 attempts, exponential backoff starting at 1 second.
attempt 1: 20s + sleep 1s
attempt 2: 20s + sleep 2s
attempt 3: 20s + sleep 4s
attempt 4: 20s
------------------------------
worst case: 87 seconds
If that job sits behind an HTTP request with a 30-second gateway timeout, the caller gave up 57 seconds before your retry loop finished. You burned three attempts of proxy bandwidth and credits producing a result nobody will read.
The fix is a deadline, not a timeout. Set one wall-clock budget for the whole operation, then derive each attempt's timeout from what remains.
import time
import httpx
class Deadline:
"""One wall-clock budget shared by every attempt in a retry chain."""
def __init__(self, seconds: float):
self.expires_at = time.monotonic() + seconds
@property
def remaining(self) -> float:
return max(0.0, self.expires_at - time.monotonic())
def expired(self) -> bool:
return self.remaining <= 0
def fetch(url: str, budget_s: float = 25.0, max_attempts: int = 4) -> httpx.Response:
deadline = Deadline(budget_s)
proxy = "http://user:pass@gate.sparkproxy.io:10000"
last_error = None
for attempt in range(max_attempts):
if deadline.expired():
break
backoff = min(2 ** attempt, 8)
# Reserve room for the sleep that follows a failure, so the last
# attempt still gets a usable slice instead of 0.3 seconds.
reserved = backoff if attempt < max_attempts - 1 else 0
attempt_timeout = min(12.0, max(2.0, deadline.remaining - reserved))
try:
with httpx.Client(proxy=proxy, timeout=httpx.Timeout(
connect=5.0,
read=attempt_timeout,
write=5.0,
pool=2.0,
)) as client:
r = client.get(url)
if r.status_code < 500 and r.status_code != 429:
return r
last_error = f"status {r.status_code}"
except httpx.HTTPError as exc:
last_error = repr(exc)
if deadline.remaining <= backoff:
break
time.sleep(backoff) # add jitter here, see the backoff article
raise TimeoutError(f"budget of {budget_s}s exhausted: {last_error}")
Three properties make this work:
- Bounded wall time. The function returns within
budget_splus a small overshoot, regardless of how attempts fail. You can now size a worker pool honestly. - No pointless final attempt. If only 1.5 seconds remain, the loop exits instead of launching an attempt that's guaranteed to time out.
- The connect timeout stays fixed. Only the read timeout shrinks. Squeezing the connect timeout down to 2 seconds on the last attempt would reintroduce exactly the manufactured failures described earlier.
If your scraper runs behind an API, propagate the caller's deadline down instead of inventing one. A caller with 8 seconds left should not trigger a 60-second retry chain.
The clean way to state the invariant: the sum of every attempt timeout plus every backoff sleep must be less than the deadline of whoever is waiting on you. Write it in the config comment. Teams that skip this end up with queue depth graphs that look like a sawtooth and no idea why.
Which errors are safe to retry
Retry safety isn't determined by the error's name. It's determined by one question: did any byte of your request reach the origin? If nothing crossed, the request definitionally had no side effects, and retrying is safe for any HTTP method. If bytes crossed and you never got a response, you don't know whether the origin processed it.
| Failure | Did the request reach the origin? | Safe to retry |
|---|---|---|
| DNS failure for the proxy host | No | Yes, any method |
| TCP connect timeout / refused | No | Yes, any method |
| TLS handshake failure | No | Yes, any method |
| Proxy CONNECT rejected (non-200) | No | Yes, any method |
| Reset on a reused idle socket, before write completed | No | Yes, any method, once |
| Read timeout after request sent | Unknown | Idempotent methods only |
| Connection reset mid-response body | Yes, partially | Idempotent methods only |
| 502 from the proxy | Unknown, upstream replied badly | Idempotent methods only |
| 504 from the proxy | Unknown, upstream replied nothing | Idempotent methods only |
| 429 with `Retry-After` | Yes, and it was rejected | Yes, after honoring the header |
| 503 | Yes, and it was rejected | Yes, after backoff |
| 401 / 402 / 422 | Yes, and it was rejected | Never, nothing will change |
GET, HEAD, PUT, DELETE, and OPTIONS are idempotent by RFC 9110. POST is not. For scraping this is mostly academic, since almost everything is a GET, but it stops mattering the moment you POST to a search endpoint, a GraphQL API, or a form-driven listing page that records a submission.
import httpx
IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"}
# Failures that provably happened before any request byte left for the origin.
PRE_ORIGIN = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.ProxyError,
)
def should_retry(method: str, exc: Exception | None, status: int | None,
has_idempotency_key: bool = False) -> bool:
if isinstance(exc, PRE_ORIGIN):
return True # nothing was sent, always safe
safe_method = method.upper() in IDEMPOTENT or has_idempotency_key
if isinstance(exc, (httpx.ReadTimeout, httpx.RemoteProtocolError)):
return safe_method # request sent, outcome unknown
if status in (429, 503):
return True # explicitly rejected, no side effect
if status in (502, 504):
return safe_method
if status in (401, 402, 422, 407):
return False # deterministic, fix the request
return False
If you must retry a non-idempotent request, send an idempotency key so the origin can deduplicate:
import uuid
key = str(uuid.uuid4())
headers = {"Idempotency-Key": key} # same key on every attempt of THIS request
Generate the key once per logical operation, not once per attempt. A key regenerated inside the retry loop is decorative.
One more distinction that catches people: a status code returned by the proxy and a status code forwarded from the target mean different things and take different fixes. A 429 from your provider is a plan concurrency limit, a 429 from the target is a rate limit on the exit IP. The proxy error code reference covers how to tell them apart from the response body.
The idle socket problem
Layer 7 is the one that isn't really a timeout at all, and it produces the most confusing incident reports: intermittent ECONNRESET or Connection aborted errors that correlate with low traffic rather than high traffic.
The mechanism is a race. Your connection pool holds a keep-alive socket. The proxy has its own idle timeout, say 60 seconds, and reaps the socket server-side. Your client hasn't seen the FIN yet, so it hands that socket to the next request, which writes into a connection that's already gone. The write fails, or the read returns nothing.
Two fixes, and you want both:
- Set your client's idle timeout below the proxy's. If the proxy reaps at 60 seconds, expire pooled connections at 30. The client then closes cleanly on its own schedule instead of discovering the closure by failing.
- Retry once on a reused connection, unconditionally. Because nothing reached the origin, this is safe even for POST. Most mature clients do this already, but many wrappers disable it by mistake.
// Node 18+ with undici. Idle timeout deliberately below the proxy's.
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
const dispatcher = new ProxyAgent({
uri: 'http://gate.sparkproxy.io:10000',
token: 'Basic ' + Buffer.from('user:pass').toString('base64'),
keepAliveTimeout: 30_000, // below the proxy's 60s reap
keepAliveMaxTimeout: 30_000,
connectTimeout: 5_000, // handshake family
headersTimeout: 14_000, // TTFB ceiling
bodyTimeout: 17_000, // read ceiling
});
setGlobalDispatcher(dispatcher);
const res = await fetch('https://www.sparkproxy.io/', {
signal: AbortSignal.timeout(20_000), // hard wall-clock ceiling
});
AbortSignal.timeout() is the wall-clock guard that headersTimeout and bodyTimeout don't give you individually. Use all three: the granular ones tell you where it stalled, the signal guarantees the request can't outlive its budget.
Timeouts with the SparkProxy Scraping API
A managed scraping API changes the shape of the problem. Layers 1 through 4 are handled server-side, and your client is left with one connection to the API and one long-running response. That simplifies the config, but it introduces a constraint that trips up almost every first integration.
Per the SparkProxy Scraping API docs, the page navigation timeout is 90 seconds on the first attempt, with up to 3 retries at 120 and 180 seconds. The API is running its own retry ladder on your behalf. A hard target can therefore legitimately take several minutes before the API gives up.
The implication: a 60-second client timeout guarantees you can never receive a successful render from a slow target. You'll pay for the work, abandon it mid-flight, retry from your side, and pay again. The most common symptom is a scraper that "works in testing" against fast pages and fails at 100 percent against the hard ones it was actually built for.
Align the client timeout with the server's ladder for synchronous calls:
import httpx
API = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "sk-your-key"}
# The API's own ladder is 90s, then 120s, then 180s. A synchronous client
# waiting on a rendered page needs headroom past the first rung, not a
# round number pulled from a template.
timeout = httpx.Timeout(connect=5.0, read=120.0, write=10.0, pool=5.0)
with httpx.Client(timeout=timeout, headers=HEADERS) as client:
r = client.get(API, params={
"url": "https://www.sparkproxy.io/",
"render_js": "true",
"wait_for": "#pricing", # 30s selector timeout, server-side
"country_code": "de",
"json_response": "true",
})
r.raise_for_status()
data = r.json()
print(data["status_code"], data["duration_ms"], data["credits_used"])
Note the layering inside the request itself. wait_for has its own 30-second server-side ceiling, and wait is capped at 30 seconds. Those sit inside the 90-second navigation budget, which sits inside your read timeout. Each one has to be smaller than the one containing it, or the outer timeout fires while the inner one is still legitimately working.
For anything genuinely slow, stop holding a socket open at all. Pass callback_url and the API returns 202 immediately, then POSTs the result when it's done:
r = client.get(API, params={
"url": "https://www.sparkproxy.io/",
"render_js": "true",
"stealth": "true",
"callback_url": "https://hooks.sparkproxy.io/scrape-result",
"tag": "nightly-crawl-2026-08-18",
})
assert r.status_code == 202 # queued; no client timeout to tune at all
This is the single biggest timeout win available to most scrapers. A webhook turns a 180-second blocked worker into a sub-second request, which removes the slow tail from your service-time mean entirely. Worker capacity stops being a function of target latency.
duration_ms in the JSON envelope is server execution time. Compare it against your own measured wall time: a large gap means the time is going to your own network path or client queueing, not to the scrape.
Client configuration reference
Parameter names differ wildly across libraries, and the same word means different things. This table maps them to the layers.
| Layer | Python `requests` | Python `httpx` | `aiohttp` | Node `undici` | Go `net/http` | `curl` |
|---|---|---|---|---|---|---|
| DNS + TCP + TLS | `timeout[0]` | `connect` | `sock_connect` | `connectTimeout` | `Transport.DialContext` | `--connect-timeout` |
| TLS only | not separable | folded into `connect` | not separable | folded | `TLSHandshakeTimeout` | not separable |
| TTFB | not separable | folded into `read` | not separable | `headersTimeout` | `ResponseHeaderTimeout` | not separable |
| Read (per byte gap) | `timeout[1]` | `read` | `sock_read` | `bodyTimeout` | via `context` | not separable |
| Total wall clock | none | none | `total` | `AbortSignal.timeout` | `context.WithTimeout` | `--max-time` |
| Pool wait | none | `pool` | none | queue behavior | `MaxConnsPerHost` blocking | none |
| Idle socket | via adapter | `keepalive_expiry` | `keepalive_timeout` | `keepAliveTimeout` | `IdleConnTimeout` | none |
Go gives you the most granular control of any mainstream client, which is why it's worth showing in full:
package main
import (
"context"
"net"
"net/http"
"net/url"
"time"
)
func client() *http.Client {
proxyURL, _ := url.Parse("http://user:pass@gate.sparkproxy.io:10000")
tr := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // TCP connect
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second, // TLS only
ResponseHeaderTimeout: 14 * time.Second, // TTFB
IdleConnTimeout: 30 * time.Second, // below the proxy's reap
MaxIdleConnsPerHost: 64,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{Transport: tr} // no global Timeout, use contexts
}
func fetch(u string, budget time.Duration) (*http.Response, error) {
ctx, cancel := context.WithTimeout(context.Background(), budget)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
return client().Do(req)
}
Leaving http.Client.Timeout unset and using a per-request context is deliberate. Client.Timeout is a fixed ceiling that can't shrink as a deadline is consumed, so it fights the budget model from the previous section. A context is the deadline.
Aiohttp's ClientTimeout is the closest Python equivalent to a real budget:
import aiohttp
timeout = aiohttp.ClientTimeout(
total=20, # true wall clock for the whole request
connect=5, # pool acquisition + connection
sock_connect=5, # socket connect only
sock_read=14, # gap between reads
)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
"https://www.sparkproxy.io/",
proxy="http://user:pass@gate.sparkproxy.io:10000",
) as resp:
body = await resp.text()
total is the one to set first. Everything else is diagnostics.
Common mistakes and how to spot them
| Symptom | Likely cause | Fix |
|---|---|---|
| Error rate jumps at low traffic hours | Idle sockets reaped by the proxy | Client idle timeout below the proxy's, retry once on reuse |
| Success rate drops after "tuning" timeouts | Connect timeout below the SYN retransmit floor | Raise handshake timeouts to 5 s |
| Throughput falls while error rate stays flat | Read timeout too long, workers parked | Cut read to 1.5x p99 of successes |
| p99 latency equals your timeout exactly | Circular measurement | Compute percentiles from successful responses only |
| Jobs finish long after the caller gave up | No shared deadline across retries | Deadline-derived per-attempt timeouts |
| Rendered pages always fail, static pages fine | Client timeout below the API's 90 s navigation budget | Raise to 120 s or switch to `callback_url` |
| Duplicate records after network errors | Retrying non-idempotent requests | Gate retries on method, add an idempotency key |
| Request outlives its timeout by minutes | Read timeout is per-byte, not total | Add a wall-clock guard |
The measurement habit that catches most of these: log the phase of every failure, not just the exception class. A counter broken down by connect_timeout, tls_error, read_timeout, reset_on_reuse, and status_5xx tells you which knob to turn. A single errors counter tells you nothing, and it's what most scrapers ship with. Pair it with a regular connectivity check, as described in how to test if your proxy is working, so you can separate a target problem from a path problem before you start changing config.
Frequently asked questions
FAQ
A connect timeout caps how long the client waits to establish the TCP connection and TLS session to the proxy, before any request is sent. A read timeout caps how long it waits for response data after the request goes out. In most libraries the read timeout is the maximum gap between received bytes, not the total duration of the response, so it does not put a hard ceiling on request length.
There is no universal number, which is why round values like 30 or 60 seconds are usually wrong. Measure your own traffic, then set handshake timeouts (DNS, TCP, TLS, CONNECT) near the p99.9 with a floor of about 5 seconds, and set response timeouts at 1.5x to 2x the p99 of successful responses. Datacenter and residential exits need different values, and headless rendering needs its own.
Almost always because the read timeout is per-read rather than total. A server that sends a byte every few seconds never exceeds the per-read gap, so the connection stays open indefinitely. Add a true wall-clock guard: total in aiohttp's ClientTimeout, AbortSignal.timeout() in Node, context.WithTimeout in Go, or --max-time with curl.
Only if you know the request never reached the origin. A connect timeout, a TLS failure, or a rejected proxy CONNECT are all safe because nothing was sent. A read timeout after the request went out is not safe, since the origin may have processed it, so either skip the retry or send a stable Idempotency-Key header generated once per logical operation and reused on every attempt.
Yes. Residential exits terminate on consumer connections and commonly show 3 to 5 times the connect latency of a datacenter pool, with a much fatter tail from mobile and congested links. Running one global connect timeout across a mixed pool means it is either too tight for residential or pointlessly loose for datacenter. Measure and configure each pool separately.
Set a wall-clock budget first and let it decide. Three or four attempts inside a 25-second deadline is a reasonable default for most scraping, with each attempt's timeout derived from the remaining budget minus the next backoff sleep. Counting attempts without a budget produces unbounded worst-case latency, which is what causes queue backups.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

TCP vs UDP Proxies: What the Transport Layer Decides
TCP vs UDP proxies explained: HTTP proxies are TCP-only, SOCKS5 UDP ASSOCIATE is rare, and DNS, QUIC, HTTP/3 and WebRTC all change once UDP is gone.

TCP Keepalive and Connection Pooling for Proxies
TCP keepalive and connection pooling set your real proxy throughput. Handshake costs, correct pool size, half-open sockets, and the IP rotation tax.

Proxy Failover and Redundancy: Design for Failure
Proxy failover means moving work off a failing component. Learn the five failure modes, circuit breakers, multi-provider ASN traps, and RTO/RPO for scrapers.
