Unpin on Evidence, Not on a Timer: Event-Driven Exit Affinity for Sticky Sessions
Almost every sticky-session implementation in production is built on the same primitive: a duration. "Pin the account to this exit for 30 minutes." "Hold the pairing for one hour." The number gets picked from a provider default, a blog post, or the one measured value someone collected six months ago — and then it sits there, unwatched, while everything around it changes: traffic volume, origin policy, pool composition, the exit's own health.
Timers have a fundamental defect that no amount of tuning fixes: they are blind to what actually happened. A pairing that got a CAPTCHA at minute 4 stays pinned for the remaining 26. A pairing that served two hundred flawless requests gets unpinned at minute 30 for no reason, discarding accumulated warmth and forcing a cold re-association. The timer is equally wrong in both directions — too sticky when things go bad, too rotational when things go well.
The core idea of this article: the sticky window should not be a duration at all. It should be an evidence state machine, where affinity is kept while evidence says the pairing is healthy and dropped the moment evidence says otherwise. Timers can still exist — as floors and ceilings, not as the decision-maker. Below is a runnable design that replaces timer-driven unpinning with event-driven unpinning, and the reasons it measurably outperforms fixed windows.
What evidence is available, and what each piece means
Every authenticated request through a pinned pairing emits signals. Most code throws them away; the event-driven design treats them as the affinity controller's entire input:
- Successive 2xx responses — positive evidence. Each one slightly raises your confidence that this pairing is warm, accepted, and worth keeping.
- A soft block — 403/429/CAPTCHA-shaped responses — ambiguous evidence. It can mean the exit's reputation dipped, or the origin is throttling the account, or the request itself was malformed. Crucially, it does not yet mean the pairing is bad.
- The same failure repeating across exits — if account X fails on exit A, and you rotate to exit B and fail the same way, the constant in the equation is the account or the request, not the exit. Unpinning was never the right response.
- Exit-level metadata drift — a geo or ASN change on the pinned exit — strong negative evidence (covered in depth in the geo-drift diagnosis approach: a trajectory break is exactly what origins score).
- Response latency and connection churn — a slow, reconnecting exit degrades throughput without failing requests; it's evidence for resheduling, not unpinning.
The key insight is that these signals have different attribution scopes: some indict the exit, some the account, some the request, some the pairing. A timer attributes nothing. An event-driven controller attributes everything, and that attribution is what makes unpinning correct.
The controller
Here is a compact implementation. The affinity controller consumes per-request verdicts and decides: KEEP the pin, RETRY_SAME (back off, stay pinned), REPIN (drop this exit, acquire a new one), or DEMOTE (the account itself looks flagged — reduce all its activity regardless of exit).
import time
from dataclasses import dataclass, field
from enum import Enum
class Action(Enum):
KEEP = "keep" # pin survives, continue on this exit
RETRY_SAME = "retry" # back off, stay pinned (transient origin noise)
REPIN = "repin" # drop exit, acquire a fresh one for this account
DEMOTE = "demote" # account-level trouble: stop, cool down
@dataclass
class Pairing:
account_id: str
exit_id: str
login_geo: tuple = ()
current_geo: tuple = ()
streak_2xx: int = 0
soft_blocks: int = 0
last_429: float = 0.0
cross_exit_same_failure: bool = False
class AffinityController:
def __init__(
self,
max_soft_blocks: int = 2,
min_hold: float = 300.0, # floor: don't repin faster than this
max_hold: float = 7200.0, # ceiling: force re-evaluation anyway
):
self.max_soft_blocks = max_soft_blocks
self.min_hold = min_hold
self.max_hold = max_hold
def on_request(self, pairing: Pairing, status: int,
pinned_since: float) -> Action:
now = time.monotonic()
# Ceiling: even a perfect pairing re-evaluates eventually.
if now - pinned_since > self.max_hold:
return Action.REPIN
# Metadata drift: trajectory break, strongest negative evidence.
if pairing.current_geo and pairing.login_geo \
and pairing.current_geo != pairing.login_geo:
return Action.REPIN
if 200 <= status < 300:
pairing.streak_2xx += 1
pairing.soft_blocks = max(0, pairing.soft_blocks - 1)
return Action.KEEP
if status in (401, 403):
pairing.soft_blocks += 1
# Cross-exit check: same failure already seen on another exit?
if pairing.cross_exit_same_failure:
return Action.DEMOTE # exit isn't the variable
if pairing.soft_blocks >= self.max_soft_blocks:
return Action.REPIN
return Action.RETRY_SAME
if status == 429:
# 429 is origin-side pacing. Stay pinned; the exit is fine.
if now - pairing.last_429 < 60:
return Action.DEMOTE # account-level throttle
pairing.last_429 = now
return Action.RETRY_SAME
return Action.RETRY_SAME
Read the decisions against the attribution logic:
- 429 → RETRY_SAME, never REPIN. Rate limiting is addressed to the request rate, which is a property of your client, and possibly of the account — but not of the exit. Unpinning on 429 (the default in a lot of hand-rolled code) throws away a warm pairing and teaches you nothing.
- 403 twice → REPIN, but only once. A soft block might be exit reputation. Give it one cross-exit test: if the same failure follows on a fresh exit, the next verdict is DEMOTE, and no amount of further repinning will help — you'll just burn exits against a flagged account.
- Success decrements soft_blocks. This is what makes the controller recover: a single ambiguous 403 in an otherwise healthy stream doesn't doom the pairing. Timers can't express recovery at all.
Where timers still belong: floors and ceilings
Going fully event-driven has two failure modes of its own, and floors/ceilings fix both:
-
The thrash problem. Without a floor, a controller that repins on the first ambiguous signal can churn through exits faster than it warms them, producing a fleet of cold, high-risk pairings. The
min_holdfloor guarantees each pairing gets a fair trial — a few minutes of traffic — before it can be dropped for soft evidence. Hard evidence (geo drift, connection refused) should bypass the floor; encode that distinction explicitly. -
The complacency problem. Without a ceiling, a pairing that has never seen a failure keeps its pin forever, and its evidence goes stale: the exit's health two weeks ago says nothing about today. The
max_holdceiling forces a periodic re-evaluation — not a forced rotation, but a moment where the pin must be re-earned, typically by a lightweight probe or simply by the next request being watched more carefully.
With floors and ceilings in place, the duration constants you kept from the timer era get demoted to what they always should have been: guardrails around a decision process, not the decision process itself.
Measuring whether it worked
The event-driven controller pays for its complexity in four metrics you should chart from day one:
- Mean healthy-pairing lifetime. Timers force this to be ≈ the timer value by construction. Event-driven control should extend it — healthy pairings ride their success streak instead of being amputated mid-life.
- Exits consumed per account-day. The thrash metric. If the floor is set right, this drops versus a nervous timer, because fewer pairings are discarded preemptively.
- Soft-block-to-repin conversion rate. The fraction of soft blocks that eventually lead to REPIN. High values mean soft blocks really do predict bad exits (the attribution was right). Low values mean most soft blocks were origin-side noise, and your floor can afford to be longer.
- Post-REPIN failure rate. The acid test: requests immediately after a repin should fail less than requests just before it. If they don't, the controller is unpinning for reasons unrelated to the exit — tighten the attribution, raise the floor, or demand harder evidence.
That last metric closes the loop and turns the whole apparatus into a learning system: the controller's decisions are continuously graded by their own outcomes, which is something a static timer will never do, no matter how carefully you pick its number.
The one-sentence summary: a sticky window is a belief that a pairing is good, and beliefs should be updated by evidence — not by a countdown. Keep the countdown only as the boundary conditions, and let the requests themselves decide when an exit has earned — or lost — the right to carry a session.
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)