DEV Community

Greta
Greta

Posted on

Mobile Proxies Churn Their Own Way: Handling the Silent CGNAT Re-Attach in Your Session Logic

A mobile exit is not just "a residential IP that happens to live on a phone network." The one property that separates it from every other proxy class is that its public identity can change underneath you while you are doing nothing at all. You did not call rotate. Your sticky-session timer says you still own this exit for another seven minutes. And yet the next request leaves from a different carrier NAT address, in a different /22, sometimes a different city. The reason is CGNAT re-attach, and almost every session-affinity design I have reviewed gets it wrong because the whole abstraction is built on a timer you control rather than an event the carrier controls.

This is a field guide to that event. The core thesis in one line: your sticky-session logic assumes the exit identity is stable until you change it; on mobile that assumption is false, so session state must be keyed to the carrier's attach, not to your clock.

The assumption that breaks

Residential and datacenter affinity logic shares a hidden premise. When you ask for a sticky session, you get an identifier — a token in the proxy URL, a username suffix, a port — and the contract is "this identity holds for N minutes." That is a you-initiated lifecycle. Rotation, if it happens, happens on schedule or when you request it.

Mobile breaks the contract at the layer below the proxy. The handset behind the exit is a client of a carrier core network, and that network reassigns reachability on its own terms. When the device transitions from active radio state back to idle, the carrier releases the connection. When it comes back — a paging response, a Tracking Area Update, a fresh PDN/PDP context after the idle timer expires — the Carrier-Grade NAT gateway sits down and renegotiates a mapping. The public IP you had is frequently not the public IP you get back, and even when the address survives, the set of public ports handed to that subscriber (its port-address, or port-block, allocation) is rebuilt. From your crawler's seat, the upstream endpoint is unchanged and the connection keeps working; from the target site's seat, a brand-new source address just appeared. Nothing you control fired that event.

Re-attach is not "rotation"

It is tempting to file this under "rotating residential" and move on. Do not; the two behave differently enough to wreck your rate accounting.

A rotating residential gateway changes exits because you or the provider decided to. The cadence is deterministic, it is independent of what the origin is doing, and a "rotate every request" or "rotate every 5 minutes" policy is honest about its own clock.

A mobile re-attach changes exits because the radio and core network decided to, mid-window, unprompted, and — this is the nasty part — correlated with your own traffic pattern. Idle-to-active re-attach is more likely right after you go quiet between batches, which is exactly when you assume the exit has settled and reuse a cookie. So the churn is asynchronous, externally triggered, and biased toward the moments you least expect it. A "sticky for 10 minutes" timer is doubly wrong here: it lets you carry state past a re-attach that happened at minute 3, and it forces you to rotate early on an attach that was genuinely stable for 20 minutes, paying for churn you never needed.

Why per-IP accounting over-counts

Site-side defenses usually meter a client by IP, sometimes by cookie, sometimes by (IP, cookie) coherence. Each of those breaks differently when the IP silently moves under a session that looks continuous to you.

A per-IP token bucket on your side assumes IP == this logical identity. After a re-attach, the bucket you were draining belongs to an address that no longer represents you. If you keep honoring it, you throttle against a dead exit and starve a fresh one — underusing budget you actually have. If you naively "reset on new IP," you get the mirror failure: the same human account now looks like several independent identities, each with a clean budget, and your polite crawl becomes a distributed burst that trips the origin's per-account and per-cookie limits you never touched.

The coherence bug is the one that gets accounts flagged. Your HTTP client keeps its cookie jar across the re-attach — cookies are per-host, the client has no idea the egress IP moved — so request 41 carries the session cookie minted from IP A but leaves from IP B, in a new ASN. To the target that reads as "stolen session, presented from a jump in the internet," which is precisely the fingerprint anti-abuse systems are built to catch. The status code you get back is often a soft 200 serving a logged-out shell, so the corruption is silent until your data quality drifts.

A fingerprint that names the attach

The fix starts with observation: stop assuming the exit is stable and measure it. The tuple that actually identifies a mobile session is (egress_ip, asn, attach_generation), where attach_generation is a local, monotonic counter you bump every time the carrier — not you — moves the address. Poll an IP-echo endpoint through the proxy and diff it.

import time
import requests

ECHO_URL = "https://ipinfo.io/json"   # -> {"ip": ..., "org": "AS22394 ...", "country": ...}

def probe_exit(proxy_url: str, timeout: float = 10.0) -> dict:
    """Return the exit identity observed *through* a given proxy endpoint."""
    r = requests.get(
        ECHO_URL,
        proxies={"http": proxy_url, "https": proxy_url},
        timeout=timeout,
    )
    r.raise_for_status()
    d = r.json()
    org = d.get("org", "")                 # e.g. "AS22394 Verizon Wireless"
    asn = org.split()[0] if org else "AS?"
    return {"ip": d["ip"], "asn": asn, "country": d.get("country", "")}


class ExitIdentity:
    """One mobile exit, tracked across polls. attach_generation is bumped
    whenever the *carrier* changes our egress IP, never because we rotated."""

    def __init__(self, proxy_url: str):
        self.proxy_url = proxy_url
        self.ip: str | None = None
        self.asn: str | None = None
        self.attach_generation = 0

    def refresh(self) -> bool:
        """Poll the echo endpoint. Returns True if this is a NEW attach."""
        obs = probe_exit(self.proxy_url)
        churned = self.ip is not None and obs["ip"] != self.ip
        if churned:
            self.attach_generation += 1
        self.ip, self.asn = obs["ip"], obs["asn"]
        return churned


def detect_churn(proxy_url: str, polls: int = 5, sleep: float = 2.0):
    ident = ExitIdentity(proxy_url)
    ident.refresh()                        # establishes the baseline attach
    for _ in range(polls):
        time.sleep(sleep)
        if ident.refresh():                # a re-attach happened in the window
            return ident
    return None
Enter fullscreen mode Exit fullscreen mode

detect_churn is a diagnostic, not a control loop: point it at a real mobile endpoint, leave it running for a few minutes, and it will tell you — faster than any dashboard — how short your observed attach lifetimes actually are. In practice I see the first surprise re-attach land well inside the advertised sticky window, which is the whole argument against trusting the timer.

Event-driven affinity, not timer-driven

Once you can observe the attach, allocate work to it. A session is valid for exactly one (ip, generation). When the egress IP moves, the old attach is retired — its cookies, its token budget, its in-flight accounting drop on the floor rather than leaking into the new identity.

from dataclasses import dataclass, field

@dataclass
class Attach:
    ip: str
    asn: str
    generation: int
    budget_left: int
    cookies: dict = field(default_factory=dict)
    requests_made: int = 0


class MobileSessionAllocator:
    """Treats a mobile exit's lifetime as an event. State lives and dies with
    one CGNAT attach, keyed by (ip, generation)."""

    def __init__(self, proxy_url: str, work_per_attach: int = 25):
        self.identity = ExitIdentity(proxy_url)
        self.work_per_attach = work_per_attach
        self.current: Attach | None = None
        self.retired: list[Attach] = []

    def _ensure_attach(self) -> Attach:
        churned = self.identity.refresh()          # observe, don't assume
        if self.current is None or churned:
            if self.current is not None:           # carrier moved us: retire
                self.retired.append(self.current)
            self.current = Attach(
                ip=self.identity.ip,
                asn=self.identity.asn,
                generation=self.identity.attach_generation,
                budget_left=self.work_per_attach,
            )
        return self.current

    def session_key(self) -> tuple:
        a = self._ensure_attach()
        return (a.ip, a.generation)                # the only safe affinity key

    def get(self, url: str, **kw):
        a = self._ensure_attach()
        if a.budget_left <= 0:                     # cap work per attach
            self.current = None                    # force a clean re-attach
            return self.get(url, **kw)
        px = {"http": self.identity.proxy_url, "https": self.identity.proxy_url}
        resp = requests.get(url, proxies=px, cookies=a.cookies, timeout=15)
        a.budget_left -= 1
        a.requests_made += 1
        a.cookies.update(resp.cookies.get_dict())   # cookies never cross attaches
        return resp
Enter fullscreen mode Exit fullscreen mode

Two properties make this mobile-safe. First, cookies are scoped to the Attach, so a re-attach cannot carry a session cookie onto a new ASN — the coherence bug is structurally impossible. Second, session_key() returns (ip, generation), so any downstream rate limiter or dedupe store you bind to the session is bound to the attach, not to a sticky token that outlived its own egress IP.

The mitigations that actually hold

Three rules carry most of the value. Re-bind session state to (ip, attach_generation), never to the provider's sticky identifier alone — on mobile that identifier is a lease on a socket, not on a public identity. Cap work per attach so that even if you fail to observe a re-attach cleanly, you cannot carry more than a bounded amount of cookie or budget into a wrong identity; a budget of tens of requests, not thousands, keeps the blast radius of an unobserved churn small. And observe rather than assume: a cheap echo probe between batches turns "the sticky window says we are stable" into "we measured that the address is stable right now."

The mental model to keep is that a mobile exit has a half-life you do not own. Treat every request as borrowing an identity the carrier can revoke between two lines of your loop, and design your session logic to notice, retire, and re-bind — quickly, loudly, and without ever assuming the IP is still the one you last saw.

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)