๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now
Proxy Basic

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.

S SparkProxy 2 25 min read
Share
Understanding Proxy Timeouts and Retry Logic

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.

#LayerClock starts whenClock stops whenTypical failure
1DNS resolutionClient needs the proxy's IPResolver returns an addressResolver down, no cache, IPv6 fallback delay
2TCP connectSYN sent to the proxySYN-ACK receivedFirewall drop, dead proxy node, wrong port
3TLS handshakeClientHello sentHandshake finishedCert chain issues, slow entropy, MITM inspection
4Proxy CONNECT`CONNECT host:443` sent`200 Connection established`Target unreachable from exit, auth rejected
5Time to first byteRequest bytes flushedFirst response byte arrivesSlow origin, rendering, queueing, silent block
6Read / totalFirst byte receivedLast byte receivedSlow drip body, stalled stream, huge payload
7Idle socketResponse completeSocket reused or reapedPool 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.

Free trial

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 timeoutMean service timeThroughputWorker time spent on the stalled 2%
120 s0.98(1.2) + 0.02(120) = **3.58 s**14.0 req/s67%
60 s0.98(1.2) + 0.02(60) = **2.38 s**21.0 req/s51%
30 s0.98(1.2) + 0.02(30) = **1.78 s**28.1 req/s34%
15 s0.98(1.2) + 0.02(15) = **1.48 s**33.9 req/s20%

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:

Phasep50p95p99Rule appliedTimeout
DNS (proxy host)4 ms22 ms61 msflat floor2 s
TCP connect28 ms61 ms143 msnear p99.9, floor 3 s5 s
TLS handshake45 ms112 ms258 msfolded into connect budget5 s
Proxy CONNECT180 ms520 ms1.4 snear p99.9, floor 3 s8 s
TTFB0.9 s3.8 s9.1 s1.5x p9914 s
Total (no JS)1.2 s4.6 s11.2 s1.5x p9917 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:

  1. Bounded wall time. The function returns within budget_s plus a small overshoot, regardless of how attempts fail. You can now size a worker pool honestly.
  2. No pointless final attempt. If only 1.5 seconds remain, the loop exits instead of launching an attempt that's guaranteed to time out.
  3. 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.

FailureDid the request reach the origin?Safe to retry
DNS failure for the proxy hostNoYes, any method
TCP connect timeout / refusedNoYes, any method
TLS handshake failureNoYes, any method
Proxy CONNECT rejected (non-200)NoYes, any method
Reset on a reused idle socket, before write completedNoYes, any method, once
Read timeout after request sentUnknownIdempotent methods only
Connection reset mid-response bodyYes, partiallyIdempotent methods only
502 from the proxyUnknown, upstream replied badlyIdempotent methods only
504 from the proxyUnknown, upstream replied nothingIdempotent methods only
429 with `Retry-After`Yes, and it was rejectedYes, after honoring the header
503Yes, and it was rejectedYes, after backoff
401 / 402 / 422Yes, and it was rejectedNever, 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:

  1. 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.
  2. 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.

LayerPython `requests`Python `httpx``aiohttp`Node `undici`Go `net/http``curl`
DNS + TCP + TLS`timeout[0]``connect``sock_connect``connectTimeout``Transport.DialContext``--connect-timeout`
TLS onlynot separablefolded into `connect`not separablefolded`TLSHandshakeTimeout`not separable
TTFBnot separablefolded into `read`not separable`headersTimeout``ResponseHeaderTimeout`not separable
Read (per byte gap)`timeout[1]``read``sock_read``bodyTimeout`via `context`not separable
Total wall clocknonenone`total``AbortSignal.timeout``context.WithTimeout``--max-time`
Pool waitnone`pool`nonequeue behavior`MaxConnsPerHost` blockingnone
Idle socketvia 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

SymptomLikely causeFix
Error rate jumps at low traffic hoursIdle sockets reaped by the proxyClient idle timeout below the proxy's, retry once on reuse
Success rate drops after "tuning" timeoutsConnect timeout below the SYN retransmit floorRaise handshake timeouts to 5 s
Throughput falls while error rate stays flatRead timeout too long, workers parkedCut read to 1.5x p99 of successes
p99 latency equals your timeout exactlyCircular measurementCompute percentiles from successful responses only
Jobs finish long after the caller gave upNo shared deadline across retriesDeadline-derived per-attempt timeouts
Rendered pages always fail, static pages fineClient timeout below the API's 90 s navigation budgetRaise to 120 s or switch to `callback_url`
Duplicate records after network errorsRetrying non-idempotent requestsGate retries on method, add an idempotency key
Request outlives its timeout by minutesRead timeout is per-byte, not totalAdd 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.

Special Discount ยท 20% off

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

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's proxy infrastructure: datacenter proxies, residential proxies, and the SparkProxy Scraping API. We spend our days looking at connection-level telemetry across millions of daily requests, which is where the timing distributions, failure modes, and retry patterns in this article come from. Full parameter documentation for the Scraping API, including the navigation timeout ladder and the async callback_url flow, lives at sparkproxy.io/docs/scraping-api. Questions about tuning timeouts for a specific workload can go to support@sparkproxy.io.

Keep reading

Related articles

Proxy Failover and Redundancy: Design for Failure

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.

SparkProxyยทProxy Basic