DEV Community

Greta
Greta

Posted on

Your Authenticated Sessions Never Die, They Linger: A Lease-Based Reaper for the Proxy Session Store

Your Authenticated Sessions Never Die, They Linger: A Lease-Based Reaper for the Proxy Session Store

Ask a room full of scraper engineers how many authenticated sessions are live in their fleet right now, and almost nobody can answer. They can tell you how many accounts they own and how many proxy exits they rent, but the actual population of in-flight sessions — logged in, half-expired, orphaned, or silently dead — is unknown. That unknown is expensive. Every zombie session holds a reserved exit, a warm cookie jar, and memory. Worse, a stale session that the origin has already invalidated is a liability: the next request through it presents credentials the origin considers compromised, which is precisely the traffic pattern that gets an account flagged.

Most teams reach for a fix that is really a non-fix: a cron job that logs everything out and back in on a schedule. That trades one problem for three — login storms look like a bot pattern, re-authentication burns requests, and you still have no model of what a session is.

The core idea of this article is simple: treat every authenticated proxy session as a lease with an owner, an expiry, and a heartbeat — and run a reaper that reclaims leases, not a scheduler that recreates sessions. Below is a small, runnable Python implementation of a session lease store with a reaper, plus the engineering details that make it survive contact with a real residential proxy pool.

Why "delete on timeout" is not enough

The naive implementation everyone writes first is a dict plus a TTL check:

sessions = {}  # account_id -> session

def get_session(account_id):
    s = sessions.get(account_id)
    if s and time.time() > s["expires_at"]:
        del sessions[account_id]
        return None
    return s
Enter fullscreen mode Exit fullscreen mode

This fails in four ways that only show up at fleet scale:

  1. Lazy expiry leaks. A session is only reclaimed when someone asks for that account. If account 47 goes quiet for a week, its session — and its pinned exit — sits in memory the whole time. With one-account-one-IP discipline, that means a paid exit idles for a week.
  2. No ownership. Two workers can both believe they own the session for account 47, both refresh it, and both drive traffic through two different exits for the same credentials. That split-brain is a classic correlation leak.
  3. No distinction between idle and dead. A session idle for 10 minutes and a session the origin has de-authenticated look identical to the store. You need different handling: the first just needs a heartbeat; the second needs retirement and an exit quarantine.
  4. Expiry is not one number. A real session has several clocks — token TTL, cookie lifetime, sticky-window length on the proxy gateway, and the origin's own idle-timeout policy. A single expires_at silently takes the minimum of clocks you happen to know about.

A lease fixes all four by making the session's lifecycle an explicit object with an owner, multiple clock sources, and a reaper that runs eagerly rather than lazily.

The lease model

A lease is what a hotel front desk hands you: a claim on a resource that is valid until a stated time, extendable on request, and automatically void afterward — whether or not you remember to check out. Translated to a proxy session:

  • resource: the (account_id, exit_id) pairing plus the cookie jar.
  • owner: the worker (coroutine, process, or machine) currently using it.
  • leases: a dict of named clocks, each with its own deadline.
  • heartbeat: periodic evidence that the session still works — e.g., a cheap authenticated endpoint returning 200.

The reaper is a single background task that sweeps the store, decides per lease whether to extend, release, or retire, and does so without blocking any worker.

Here is a complete implementation you can paste into a project:

import asyncio
import time
from dataclasses import dataclass, field
from enum import Enum


class Verdict(Enum):
    KEEP = "keep"          # healthy, clocks extended
    RELEASE = "release"    # idle; return exit to pool, keep cookies on disk
    RETIRE = "retire"      # origin-side death; quarantine exit, drop cookies


@dataclass
class Clock:
    deadline: float          # absolute monotonic deadline
    extend_on_heartbeat: bool


@dataclass
class SessionLease:
    account_id: str
    exit_id: str
    owner: str
    issued_at: float
    clocks: dict = field(default_factory=dict)
    cookie_jar_path: str = ""
    last_heartbeat: float = 0.0
    heartbeat_failures: int = 0

    def expired(self, now: float) -> bool:
        return any(now >= c.deadline for c in self.clocks.values())

    def next_deadline(self) -> float:
        return min(c.deadline for c in self.clocks.values())


class LeaseStore:
    def __init__(self, idle_timeout: float = 600.0):
        self._leases: dict[str, SessionLease] = {}
        self._lock = asyncio.Lock()
        self._idle_timeout = idle_timeout

    async def acquire(self, account_id: str, owner: str) -> SessionLease | None:
        """Take ownership of an existing lease or None if none exists."""
        async with self._lock:
            lease = self._leases.get(account_id)
            if lease is None:
                return None
            if lease.owner != owner and lease.owner is not None:
                raise RuntimeError(
                    f"split-brain on {account_id}: held by {lease.owner}"
                )
            lease.owner = owner
            return lease

    async def register(
        self, account_id: str, exit_id: str, owner: str,
        token_ttl: float, cookie_ttl: float, sticky_window: float,
        jar_path: str,
    ) -> SessionLease:
        now = time.monotonic()
        lease = SessionLease(
            account_id=account_id,
            exit_id=exit_id,
            owner=owner,
            issued_at=now,
            clocks={
                "token": Clock(now + token_ttl, extend_on_heartbeat=True),
                "cookie": Clock(now + cookie_ttl, extend_on_heartbeat=False),
                "sticky": Clock(now + sticky_window, extend_on_heartbeat=True),
                "idle": Clock(now + self._idle_timeout, extend_on_heartbeat=True),
            },
            cookie_jar_path=jar_path,
        )
        async with self._lock:
            self._leases[account_id] = lease
        return lease

    async def heartbeat(self, account_id: str, ok: bool) -> None:
        async with self._lock:
            lease = self._leases.get(account_id)
            if lease is None:
                return
            if ok:
                lease.last_heartbeat = time.monotonic()
                lease.heartbeat_failures = 0
                for clock in lease.clocks.values():
                    if clock.extend_on_heartbeat:
                        clock.deadline = time.monotonic() + (
                            clock.deadline - lease.last_heartbeat
                        )
            else:
                lease.heartbeat_failures += 1

    async def sweep(self) -> list[tuple[str, Verdict]]:
        """One pass of the reaper. Returns the decisions made."""
        now = time.monotonic()
        decisions: list[tuple[str, Verdict]] = []
        async with self._lock:
            for account_id, lease in list(self._leases.items()):
                if lease.heartbeat_failures >= 3:
                    verdict = Verdict.RETIRE
                elif lease.expired(now):
                    verdict = Verdict.RELEASE
                else:
                    verdict = Verdict.KEEP
                if verdict is Verdict.RETIRE:
                    del self._leases[account_id]
                    # caller quarantines lease.exit_id, deletes jar
                elif verdict is Verdict.RELEASE:
                    lease.owner = None
                decisions.append((account_id, verdict))
        return decisions
Enter fullscreen mode Exit fullscreen mode

And the reaper itself is a loop, not a cron:

async def reaper(store: LeaseStore, interval: float = 30.0):
    while True:
        decisions = await store.sweep()
        for account_id, verdict in decisions:
            if verdict is Verdict.RELEASE:
                metrics.incr("session.released", 1)
            elif verdict is Verdict.RETIRE:
                metrics.incr("session.retired", 1)
        await asyncio.sleep(interval)
Enter fullscreen mode Exit fullscreen mode

The four clocks, and why they disagree

The reason the lease carries multiple clocks is that each one is owned by a different system, and they fail independently:

  • Token TTL is dictated by the origin's auth system. If the origin issues 24-hour tokens, no amount of heartbeating extends it — but a refresh endpoint usually can, which is why extend_on_heartbeat=True plus a refresh call in the heartbeat path is the right pairing.
  • Cookie lifetime is set by the origin's session middleware, sometimes shorter than the token, sometimes longer, and occasionally rotated on every response. It is not extendable from your side; when it lapses, the session is dead regardless of token state. That is why extend_on_heartbeat=False.
  • The sticky window is a property of your proxy plan, not of the origin. Most residential gateways will hold an exit for a fixed duration (10 or 30 minutes is typical) per session ID. If your lease outlives the sticky window and you keep sending requests, the gateway silently hands you a different exit mid-session — the exact invariant break that gets authenticated sessions flagged. Model it as a clock, and when it fires, either rotate cleanly or request a new sticky session for the same exit if your provider supports pinning.
  • Idle timeout is yours. It exists to return warm-but-unused exits to the pool. Release, don't retire: the cookie jar goes to disk, the account keeps its pairing record, and the next run for that account re-warms instead of re-authenticating.

A subtle but important detail: when multiple clocks run concurrently, the effective session lifetime is the minimum. The next_deadline() helper tells you exactly when the reaper will next act on a lease, which makes session lifetime a queryable, chartable number instead of a vibe.

Retire is not release: the exit quarantine

The most consequential line in the reaper is the RETIRE branch. When three consecutive heartbeats fail, the store concludes the origin has killed the session. What you do next determines whether this incident stays contained:

  1. Drop the cookie jar, not to disk-storage but to deletion. Those cookies are now credentials the origin considers invalid or compromised; replaying them from any exit is a risk signal.
  2. Quarantine the exit. The origin associated that exit IP with a failed/auth-suspicious pattern. Sending a different account through the same exit immediately after a retirement is how one burned pairing becomes five. Move the exit into a quarantine set with a cool-down (an hour is a reasonable default) before it is eligible to receive any authenticated pairing again.
  3. Log the pairing, not just the event. (account_id, exit_id, issued_at, retired_at) is the row that lets you answer, a month later, "do retirements cluster on particular exit subnets?" Without the pairing record, every retirement is an isolated mystery.

None of this is exotic. It is the same hygiene you already apply to connection pools — but applied to authenticated identity, where the blast radius of sloppy reclamation is an account, not a socket.

What this buys you in production

Teams that move from "dict + TTL" to a lease store typically see three concrete changes:

  • Exit utilization becomes visible and bounded. Because idle leases release eagerly, the ratio of exits-in-use to exits-paid stops drifting upward over time. The reaper's RELEASE counter is your direct measure of how much warm capacity you were silently hoarding.
  • Split-brain becomes an exception, not an ambient condition. The acquire() ownership check turns the worst correlation leak — two workers driving one account from two exits concurrently — into a loud, rare, debuggable error.
  • Retirement forensics become possible. With pairing logs plus quarantine, you can compute the metric that actually matters for fleet health: retirements per 1,000 account-hours per exit subnet. High-scoring subnets get down-weighted in your assignment layer — a feedback loop that no amount of per-request retry logic can replicate.

The deeper point is that a session store is not a cache. A cache can evict silently with no consequence. A session store holds identity, and identity has an origin-side counterpart with opinions about how it should behave. A lease with multiple clocks, an owner, and a reaper is the smallest object that encodes those opinions honestly.

Start by adding the heartbeat — even a naive daily-startime-based one — and the RETIRE-vs-RELEASE distinction. Those two alone will eliminate the majority of zombie-session incidents, and they will give you the vocabulary to reason about every other session-lifecycle decision you make.


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)