DEV Community

Greta
Greta

Posted on

Rotation Is a Session Decision, Not a Request Decision: A Beginner's Stickiness Rulebook

Rotation Is a Session Decision, Not a Request Decision: A Beginner's Stickiness Rulebook

The default mental model for new proxy users is: rotate more, block less. Every request gets a fresh exit; if one gets blocked, the next one is clean. And for a certain class of scraping — a thousand independent GETs of public product pages — that model works. The moment your crawler touches anything with server-side state, per-request rotation quietly becomes the bug: carts that empty themselves, CSRF tokens that reject valid submissions, paginators that loop forever, login flows that fail only on the second hop.

The reframe: rotation and stickiness are properties of a transaction, not of a request. Define the transaction boundary first, then choose the policy for each side of it. This article is the rulebook beginners wish they had been handed on day one.

Why per-request rotation breaks state

Every mechanism below stores something server-side and expects it back on the next request. The server's "next request from the same actor" heuristic is dominated by source IP — so a mid-transaction IP change looks like either a different user or an attack:

  • Session cookies bound to origin IP. Common in checkout and banking-adjacent flows. The cookie is valid, but the fingerprint check (cookie, IP) fails and you get bounced to a fresh login.
  • CSRF and nonce tokens. The token was minted for session S; session S is pinned to exit E; your next request arrives via exit F. Rejected, and the error message tells you nothing about why.
  • Server-side cursors and pagination. Many search and listing endpoints keep result-set state server-side keyed by (query, IP, session). Rotate mid-pagination and you either get duplicate pages or an endless loop from page one.
  • Risk-engine session reconstruction. Modern anti-bot systems stitch a "session" from IP + timing + TLS + cookie graph. Rotating between requests of the same session creates more sessions than users — a signal in itself.
  • Cart, quote, or draft objects. Server-side keyed, IP-linked, and suddenly empty.

None of these failures produce an error mentioning proxies. That is what makes them so expensive to debug: the code is right, the site is "randomly" flaky, and you blame the origin.

The rulebook

Six rules. When two rules conflict, the transaction boundary wins — that is the whole point.

Rule 1 — Any response that mints state must be answered from the same exit. If the server gave you a cookie, token, cart id, session id, or nonce that the next request must echo, requests #N and #N+1 share an exit. Simplest safe form: pin the whole handshake group.

Rule 2 — A paginated flow is one session. The first page and the "next" pages belong to the same server-side cursor. Pin the entire listing walk to one exit; rotate between listings, not within one.

Rule 3 — A login-through-logout flow is one session, with a bounded window. Pin for the login, the authenticated GETs, and the logout. The window should be sized from your auth token TTL, not a guessed number: your pin expires when the session it protects expires.

Rule 4 — Independent anonymous idempotent GETs rotate freely. Public detail pages, static assets, marketing pages. No state crosses requests, so rotation is pure benefit.

Rule 5 — Origin rate limits are enforced per origin per actor, and the actor is the exit. Five requests per second split across five exits is still five requests per second aimed at the origin in aggregate — but it also means nobody's per-IP budget saves you when you overshoot. Do the politeness math at (origin) level with a global budget, and the per-exit math at (exit) level with a lease pool.

Rule 6 — Two identities never share an exit at the same time. If a pin ends for account A, drain and retire that exit before account B inherits it, or accept that cross-account correlation is now one fingerprint away.

Implementing a stickiness router

The data model is a domain: a short string that names the transaction, plus an exit pinned to that domain until its window closes. Everything outside a named domain rotates.

import asyncio, time, uuid
from dataclasses import dataclass, field

@dataclass
class Pin:
    exit_url: str
    expires_at: float
    used: int = 0

class ProxyRouter:
    """Sticky per transaction-domain, rotating everywhere else."""

    def __init__(self, pool, *, sticky_ttl: float = 900.0):
        self._pool = list(pool)
        self._ttl = sticky_ttl
        self._pins: dict[str, Pin] = {}
        self._lock = asyncio.Lock()

    async def exit_for(self, domain: str | None) -> str:
        # Unnamed (Rule 4) traffic always rotates.
        if domain is None:
            return self._pool[uuid.uuid4().int % len(self._pool)]
        async with self._lock:
            now = time.monotonic()
            pin = self._pins.get(domain)
            if pin and pin.expires_at > now:
                pin.used += 1
                return pin.exit_url
            # new pin for this domain — pick an exit nobody else holds
            held = {p.exit_url for p in self._pins.values()
                    if p.expires_at > now}
            free = [e for e in self._pool if e not in held] or self._pool
            exit_url = free[uuid.uuid4().int % len(free)]
            self._pins[domain] = Pin(exit_url, now + self._ttl)
            return exit_url

    def release(self, domain: str, *, retire: bool = True) -> None:
        """Close the transaction; optionally blacklist the exit for reuse."""
        pin = self._pins.pop(domain, None)
        if retire and pin:
            self._pool = [e for e in self._pool if e != pin.exit_url]

    def sweep(self) -> int:
        now = time.monotonic()
        before = len(self._pins)
        self._pins = {k: p for k, p in self._pins.items() if p.expires_at > now}
        return before - len(self._pins)
Enter fullscreen mode Exit fullscreen mode

Usage reads like the rulebook:

router = ProxyRouter(EXITS, sticky_ttl=900)

async def fetch(url, *, domain=None):
    px = await router.exit_for(domain)
    async with session.get(url, proxy=px) as r:
        return r

# Rule 2: one listing walk, one exit
async def crawl_listing(seed_url, pages):
    dom = f"listing-{uuid.uuid4().hex[:8]}"
    urls = [f"{seed_url}&page={i}" for i in range(pages)]
    results = [await fetch(u, domain=dom) for u in urls]
    router.release(dom, retire=False)      # walk done; exit still healthy
    return results

# Rules 1+3+6: the authed transaction is one atomic unit
async def login_and_checkout(account):
    dom = f"acct-{account.id}"
    px_first = await router.exit_for(dom)
    await post_login(account, proxy=px_first)      # mints cookies+CSRF
    await post_cart(items, proxy=await router.exit_for(dom))
    await post_payment(proxy=await router.exit_for(dom))
    router.release(dom, retire=True)                # Rule 6: never hand to B
Enter fullscreen mode Exit fullscreen mode

Two design details worth copying. First, release(retire=True) implements Rule 6: after an authenticated transaction, the exit is removed from the pool rather than returned, so a second account never inherits it. Second, sweep() runs on a timer: expired pins must be garbage-collected or your dict grows unboundedly and your "sticky" mapping quietly becomes a permanent one-account-one-exit table.

Choosing the TTL

Beginners set sticky TTL to something huge because "staying is safer than switching." It is not. A pin longer than the session it protects is dead weight that ties up an exit (Rule 6 makes exits a consumable, not a free resource), and a pin shorter than the session breaks Rule 1 the moment the transaction pauses. The number should come from the slowest gap in your flow, plus a margin: if checkout can take seven minutes of human-like dwell, a 300-second pin will fail. If your auth token dies at 900 seconds, a 3600-second pin is a lie.

Set TTL to (max inter-request gap in the transaction) × 3, and cap it at (shortest token TTL among the services you hit). Everything else is noise.

The takeaway

Rotation is not a safety setting you turn up. It is a policy applied to transaction boundaries, and the boundaries are defined by server-side state — cookies, tokens, cursors, carts, sessions. Name the stateful units, pin them, rotate everything else, retire exits after authenticated use, and do your politeness math per origin. Half of what beginners experience as "the proxy provider is unreliable" is really "we rotated at the wrong layer."

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)