Set Your Sticky Window From the Token TTL, Not From a Guess: Expiry-Aware Session Affinity
Sticky session duration is the most arbitrarily-configured number in a rotating-proxy pipeline. Someone reads "set the sticky session to 10 minutes" in a tutorial, hardcodes duration_seconds=600, and never revisits it. Ten minutes is not wrong for any particular site — it is wrong for every site, because it was chosen by habit and not measured. Set it too long and your session outlives the credentials that made the exit valid, so you pay to crawl logged-out. Set it too short and the exit rotates mid-flow, the token presents from a new IP, and you trip a step-up challenge you could have avoided.
The core point: the correct sticky window is not a constant. It is min(origin_token_lifetime, origin_idle_timeout) minus a safety margin, observed per origin from the responses it actually sends you — and you can derive it in code instead of tuning it by vibes.
Two clocks you are collapsing into one
There are two independent timers that decide how long you may safely hold one exit for one authenticated account:
- The credential lifetime. How long the session cookie or bearer token the origin issued to you stays valid. When it expires, staying on the same exit buys you nothing — you are holding a dead key in a locked door.
- The idle timeout. Many origins silently drop a session if they see no activity for N minutes, or if the IP the session is bound to changes. This one is about continuity of the exit, which is exactly what stickiness preserves.
Your sticky window should be no longer than the smaller of these two, and short enough that you re-establish before either lapses. If you hold the exit for longer than the credential lives, the tail end of the hold is pure waste. If you hold it for longer than the idle timeout, the origin may already consider the session dead the moment you return to it. Both failure modes look like "mysterious logged-out rows," and both are fixed by sizing the hold to observed expiry instead of a guess.
Read the expiry the origin already tells you
Most of the time the origin hands you the number and you ignore it. Set-Cookie carries Max-Age/Expires, Cache-Control carries hints, and many APIs expose a expires_in on the token response. Parse what is there.
from http.cookies import SimpleCookie
from time import monotonic
def credential_ttl(resp) -> float | None:
"""Seconds until the auth-relevant cookie expires, from its own Max-Age."""
best = None
for name, morsel in SimpleCookie(resp.headers.get("Set-Cookie", "")).items():
if name.lower() in {"session_id", "auth", "sid", "connect.sid"}:
ma = morsel.get("max-age")
if ma is not None:
t = int(ma)
best = t if best is None else min(best, t)
return best # None => origin didn't say; caller must fall back
def token_ttl(json_body) -> float | None:
ei = json_body.get("expires_in") if isinstance(json_body, dict) else None
return float(ei) if ei is not None else None
But Max-Age alone over-promises, because it does not model the idle timeout. The reliable estimate of the practical hold is learned empirically, and you can learn it per origin without any special tooling: run a small canary that authenticates once, then probes "am I still logged in?" at geometrically increasing idle gaps until it breaks.
Learning the safe hold with a canary
import time
def _logged_in(resp) -> bool:
return b"/account/logout" in resp.content # a private marker
def learn_sticky_seconds(origin, login, probe, exit_holder,
lo=15, hi=1800, factor=1.5):
"""Binary/linear probe of how long an exit+session stays valid while idle."""
s = login(exit_holder.get_exit()) # authenticate, pin the exit
if not _logged_in(probe(exit_holder.get_exit())):
return lo
safe = lo
gap = lo
while gap <= hi:
time.sleep(gap - (0 if gap == lo else lo)) # total idle = gap
ok = _logged_in(probe(exit_holder.get_exit()))
if ok:
safe = gap
gap = int(gap * factor)
else:
break
return max(lo, int(safe * 0.8)) # 20% safety margin
This gives you a per-origin number you did not have to invent. It is measured against the real idle timeout and the real IP-binding behavior, because it holds the same exit via exit_holder and only tests the passage of time. Run it once per origin per deploy and cache the result; the value drifts slowly, not daily.
Combine the two estimates and cut the margin
def sticky_window(resp, json_body, learned_idle):
cred = credential_ttl(resp) or token_ttl(json_body) or learned_idle
# never outlive either clock; subtract a margin so we refresh
# *before* expiry, not after we've crawled logged-out for a minute.
return max(30, min(cred, learned_idle) - 45)
The margin is the part everyone skips. If the credential lives 600 s and the origin's idle timeout is 900 s, the naive answer is 600. The correct answer is a bit under it, because the moment between "expires at T" and "you noticed it expired" is spent sending authenticated-looking traffic that the origin has already demoted. Forty-five seconds of lead-in turns that dead zone into a clean refresh. Make the margin proportional for long-lived sessions (min(60, window * 0.08)) so a 30-minute hold does not over-refresh and a 30-second hold is not entirely eaten by a fixed margin.
Feed the number back into the pool, not just the client
Here is why this is a proxy decision and not only a cookie decision. Your provider's sticky-session parameter and your in-code session hold must agree. If you tell the pool "hold this exit for 10 minutes" but your measured safe window is 4 minutes, the pool will keep handing you the same exit after the session is dead, and your affinity assertions (see the session-FSM approach) will not fire because the exit genuinely did not change — the credentials died, not the route. Sizing the window from the token TTL is how you keep the pool's hold and the credential's life from drifting apart.
Concretely: set the provider sticky duration to sticky_window(...), and set your local session expiry to the same number, so there is one clock. When the measured window shrinks for a hostile origin, the pool hold shrinks with it automatically and you rotate sooner rather than clinging to a stale exit.
The cost angle
Over-long sticky windows are a budget leak that hides inside a success rate that still looks okay. You keep paying per-GB to crawl on an exit whose credentials quietly expired, and because a logged-out page often returns 200, your "errors per hour" never spikes — your data quality does. A correctly-sized window is usually shorter than the tutorial number, which means you re-authenticate a little more often and pay a little more in login requests, but stop serving logged-out rows at full residential prices. That trade is almost always net-positive: a login is a few hundred bytes; a page of logged-out content you will discard is tens of kilobytes.
When you cannot measure it, derive a floor from one request
Not every origin lets you run a canary without side effects — some will risk-score the extra probes, and some private APIs simply refuse to re-issue a token quickly. When you cannot measure, you can still do better than a magic constant. Read Max-Age from a single successful login (it is in the Set-Cookie you already have) and, if it is absent, fall back to a conservative default per origin class rather than one global number: financial and auth-adjacent endpoints tend to kill idle sessions fast, so a floor of two to four minutes is safer than ten; content and listing sites are usually more forgiving. Keep that fallback in a config map keyed by origin so that the moment you can measure one, the measured value overwrites the guess and the guess is never silently load-bearing across your whole fleet.
Whatever number you land on, log it alongside the exit id for every session. When a wave of logged-out rows appears, the first question to ask is not "did proxies go bad," it is "which origins' measured windows are we currently trusting, and did any of them shrink overnight?" That single join between the window, the exit, and the outcome is what turns a tuning knob back into a debugging instrument.
Measure the two clocks. Take the smaller. Cut a margin. Let the origin set your timer instead of the tutorial.
Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele
Top comments (0)