Checkpointing Scraper Sessions: Surviving Crashes Without Re-Logging In
A logged-in scraper session is the most expensive object in your pipeline. Acquiring one costs a residential IP session, a fingerprint that matches your headers, a login flow (possibly a CAPTCHA solve), and — the part nobody prices in — the trust that a fresh identity simply doesn't have. A cookie jar that has been quietly browsing a site for six hours is worth several multiples of a new one, because every anti-bot system on the planet scores "session age with consistent behavior" as a strong human signal.
And then your process crashes, your deploy restarts the worker, Kubernetes evicts the pod, or you deploy new code at 2 PM because extraction broke — and every session in memory evaporates. The restart path logs in again from scratch. From the target's point of view, a population of well-aged, trusted identities just vanished and was replaced by a burst of fresh logins from new IPs, which is one of the most bot-looking events you can produce. I have watched a routine Friday deploy trigger account flags across an entire fleet for no reason other than the fact that nobody persisted session state.
The core claim of this article: a logged-in session is a durable artifact, not in-memory state. Serialize it after every successful request, restore it on startup, and validate before reuse — and your crashes stop being security events. This is the same insight that moved stream-processing systems from at-most-once to effectively-once semantics, applied to scraper identity. Here's the implementation.
What actually constitutes a session
Before you can checkpoint a session, you need to know what's in it. For a typical authenticated scraper it's more than the cookie jar:
- Cookies — the obvious part: session tokens, CSRF tokens, regional preferences, A/B buckets.
- Session-bound server state — some sites bind CSRF tokens to a server-side session and reject requests carrying a stale pair. You can't checkpoint the server side, but you can record which tokens were issued together.
- Identity metadata — which exit IP the session was born on (critical for sticky residential proxies), which user agent and header set it was presented with, and the fingerprint parameters if you're driving a real browser.
- Behavioral history — at minimum a timestamped list of pages visited. Not for sentiment; for pacing. A restored session that immediately hits a deep page a brand-new session would never reach is a red flag.
The checkpoint format needs all four. The mistake I see most often is persisting only the cookies — that gets you a session that's rejected on the first request after restore because the User-Agent changed across restarts (you randomized it per process, remember?) and the site's token-to-fingerprint binding noticed.
A session store in Python
Here's a working session store built on requests and pickle. It's deliberately boring — the value is in the invariants, not the code:
import pickle
import time
import requests
from pathlib import Path
from dataclasses import dataclass, field
@dataclass
class SessionRecord:
account_id: str
proxy_session_id: str # sticky residential session / IP identity
user_agent: str
cookies: dict
created_at: float
last_success_at: float
request_count: int
page_history: list = field(default_factory=list) # [(ts, url)]
burn_reason: str | None = None
class SessionStore:
"""Durable session registry: checkpoint after success, restore on start."""
def __init__(self, root: str = "./sessions"):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
def _path(self, account_id: str) -> Path:
return self.root / f"{account_id}.pkl"
def checkpoint(self, rec: SessionRecord) -> None:
tmp = self._path(rec.account_id).with_suffix(".tmp")
with open(tmp, "wb") as f:
pickle.dump(rec, f)
tmp.replace(self._path(rec.account_id)) # atomic on POSIX & Windows
def restore(self, account_id: str) -> SessionRecord | None:
p = self._path(account_id)
if not p.exists():
return None
with open(p, "rb") as f:
return pickle.load(f)
def burn(self, rec: SessionRecord, reason: str) -> None:
"""A burned session is never silently reused — it's marked, not deleted."""
rec.burn_reason = reason
self.checkpoint(rec)
Two details matter more than they look. First, the checkpoint write is atomic — write to a temp file, then rename. If the process dies mid-checkpoint you keep the previous good copy instead of a truncated pickle, which is the difference between "restore and continue" and "fleet-wide re-login". Second, burn() marks rather than deletes: when you're debugging why an account died three days later, the autopsy data in the record is gold, and a deleted record is an unexplained disappearance.
Restoring safely: validate before you trust
Restoring a checkpointed session is not free — the session may have expired server-side, the sticky proxy session may have rotated, the CSRF token may be stale. So the restore path always runs a validation probe before the session rejoins the working set:
def validate_restored(rec: SessionRecord, timeout: float = 15.0) -> str:
"""Probe a restored session. Returns 'fresh', 'degraded', or 'dead'."""
age_hours = (time.time() - rec.last_success_at) / 3600
proxies = {"http": f"http://customer-x-{rec.proxy_session_id}:pass@proxy:8080",
"https": f"http://customer-x-{rec.proxy_session_id}:pass@proxy:8080"}
try:
r = requests.get("https://target.example.com/account",
cookies=rec.cookies,
headers={"User-Agent": rec.user_agent},
proxies=proxies, timeout=timeout)
except requests.RequestException:
return "dead" # proxy session gone; identity must be rebuilt
if r.status_code == 200 and "Sign in" not in r.text[:2000]:
# Same proxy session, same UA, cookies still valid.
# Degrade if stale: old sessions get shorter leashes.
return "fresh" if age_hours < 24 else "degraded"
if r.status_code in (401, 403) or "Sign in" in r.text[:2000]:
return "dead"
return "degraded" # challenge pages, soft blocks -> quarantine, retry later
The validation request must be the same kind of request the session would naturally make — hitting /account is fine for a session that visits account pages; hitting it from a session that only ever browsed public listings is a behavioral anomaly you just introduced yourself. Route validation through the same request-building code as production traffic. And crucially, send it through the same sticky proxy session the record was born on: if the exit IP changed since the checkpoint, that's not a restored session, that's a new session wearing a dead one's cookies, and sites with IP-bound session tokens will log it out — or worse, flag the account.
The validation result drives policy: fresh sessions rejoin immediately, degraded sessions go to a quarantine queue that retries with backoff and a reduced request budget, and dead sessions route to burn() and a controlled re-login lane — rate-limited, spread over time, so the fleet never produces a synchronized burst of fresh logins. That last part is the quiet payoff: even the failures become orderly instead of bot-shaped.
The crash that proves the design
The test is not "does checkpointing work" — it's "what does a crash cost now". Before persistence, a worker restart on a 20-account fleet meant 20 re-logins within the restart window: 20 fresh proxy sessions, 20 fingerprint resets, 20 login flows. After persistence, the same restart restores 17 sessions validated fresh, 2 degraded (retried over the next hour), 1 dead (re-login in the spread-out lane). The target sees… almost nothing. Ninety seconds of reduced traffic and one ordinary-looking new login.
The operational wins compound: deploys stop being scheduled around session amortization; worker autoscaling becomes possible (spin up, restore, contribute); and your session-age distribution stops resetting to zero every time something breaks, which — if you're measuring success rate against session age like I described in the sticky-window sizing post — is the difference between a fleet that gets more trusted over time and one that perpetually behaves like a wave of newcomers.
Failure modes to design for
Cookie-jar format drift. When you upgrade the store schema, old pickles break. Version the records (rec.schema = 2) and write a migration that runs at restore time; a session that can't migrate is dead, never an exception.
Persisting secrets you shouldn't. The record holds live auth cookies. If those land in a shared backup or a log aggregator, you've leaked every account. Encrypt at rest (even cryptography.fernet with a KMS-managed key) and keep the store directory out of anything that syncs.
Restoring into changed code. The record says user_agent: "Mozilla/5.0 ... Chrome/124". If your new release ships Chrome/126 headers for everyone, restored sessions must keep presenting Chrome/124 until they naturally die — otherwise every restart silently rewrites every session's fingerprint mid-life, which is exactly the correlation leak anti-bot systems look for.
Clock skew across workers. Session-age math on restored records assumes monotonic-ish wall clocks. NTP-drifted workers make age_hours lies; tolerate ±5 minutes and don't build anything that depends on sub-minute ordering.
The uncomfortable summary: most "we got blocked after the deploy" incidents were never about the deploy's code. They were about what the restart did to your session population. Treat sessions as checkpointed, validated, durable state, and crashes become what they should be — an operational nuisance, not a security incident.
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)