๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Guides

Scraper Stopped Working Suddenly? A 10-Minute Triage

Scraper stopped working overnight after weeks of clean runs? Split site changed, site blocked you and proxy changed in 10 minutes, with a fix and code for each.

S SparkProxy 5 20 min read
Share

Your scraper stopped working overnight. It ran for three weeks, the cron job still fires, and this morning the log is full of ProxyError, or 403, or nothing at all: a clean exit with zero rows. Nobody touched the code. Something outside it moved, and there are only three candidates: the site changed, the site blocked you, or your proxy changed. This guide gives you a decision tree that tells them apart from a single saved request, in the order that costs least, then the fix for each branch.

Scraper stopped working: read the log first

Start with the text you actually have. Every string below was either captured by us on 2026-10-30 (Python 3.14.2, requests 2.32.5, urllib3 2.6.3, against the SparkProxy gateway) or is quoted from the documentation named in the row.

What you seeWhere it comes fromMost likely branch
`ProxyError ... Tunnel connection failed: 407 Proxy Authentication Required`requests, HTTPS target, proxy refused your credentials1. Proxy changed
`r.status_code == 407` with header `X-Squid-Error: ERR_CACHE_ACCESS_DENIED 0`requests, plain-HTTP target, same refusal1. Proxy changed
`NameResolutionError ... Failed to resolve 'proxy.sparkproxy.io'`The proxy hostname in your config does not exist1. Proxy changed
`Failed to establish a new connection: [WinError 10061]` (Linux: `[Errno 111] Connection refused`)Wrong proxy port, or the host is not listening1. Proxy changed
Scraping API `402` `{"error": "Insufficient credits", ...}`SparkProxy API docs, account out of credits1. Proxy changed
`403`, `429`, or a `cf-mitigated: challenge` response headerTarget or its CDN refused the request2. Site blocked you
Scraping API `530` with `"reason": "captcha_blocked"`SparkProxy API docs, challenge not passed2. Site blocked you
`200`, page looks normal, `AttributeError: 'NoneType' object has no attribute 'text'`Your selector matched nothing3. Site changed
`requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)`An endpoint you parse as JSON returned HTML3 or 2: check the body

The last row is the trap. An endpoint that used to return JSON and now returns HTML is either a moved API (branch 3) or a challenge page served in place of the data (branch 2). The saved body settles it in seconds, which is why the first step is saving it.

Minute 0 to 2: freeze one failing request

Don't rotate keys, bump retries or edit selectors yet. Each of those destroys evidence. Reproduce one failing URL with the same proxy settings your job uses, and write everything about the outcome to disk: the exception string if there was one, otherwise status, final URL, headers, byte count and page title.

import hashlib
import json
import re
import time
from pathlib import Path

import requests

PROXY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000"  # Random Rotation port
PROXIES = {"http": PROXY, "https": PROXY}
URL = "https://shop.sparkproxy.io/products/widget-42"


def freeze(url, proxies=PROXIES, out="evidence"):
    """Save one request's full outcome so the diagnosis works from facts."""
    Path(out).mkdir(exist_ok=True)
    stamp = time.strftime("%Y%m%d-%H%M%S")
    record = {"url": url, "at": stamp}
    try:
        r = requests.get(url, proxies=proxies, timeout=30)
    except requests.exceptions.RequestException as exc:
        record["exception"] = f"{type(exc).__name__}: {exc}"
    else:
        body = r.content
        m = re.search(rb"<title[^>]*>(.*?)</title>", body, re.I | re.S)
        record.update(
            status=r.status_code,
            final_url=r.url,
            headers=dict(r.headers),
            bytes=len(body),
            sha1=hashlib.sha1(body).hexdigest(),
            title=m.group(1).strip()[:120].decode("utf-8", "replace") if m else None,
        )
        Path(out, f"{stamp}.html").write_bytes(body)
    Path(out, f"{stamp}.json").write_text(json.dumps(record, indent=2))
    return record


if __name__ == "__main__":
    print(json.dumps(freeze(URL), indent=2))

Run it twice, a minute apart. If both runs give the same outcome, you have a stable failure and the tree below will classify it. If one passes and one fails, you are almost certainly in branch 2, because selectors and credentials don't flicker.

Also open the URL in a normal browser on your own connection. If the data you want is visibly there, write down one exact value (a price, a product name). You'll grep for it later.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Minute 2 to 3: the three-way split

The tree asks the cheapest question first. Reading an exception costs nothing. Reading a status code and headers costs nothing. Only the last question needs you to look inside the page.

Q1. Did the request raise, or come back 407?
 โ”œโ”€ yes, and it names the proxy (ProxyError, 407, the gateway host or port)
 โ”‚     โ†’ BRANCH 1: your proxy changed
 โ””โ”€ no, the target answered
       Q2. Is status 403 / 429 / 503, is cf-mitigated: challenge set,
           or is the <title> a challenge or "access denied" page?
       โ”œโ”€ yes โ†’ BRANCH 2: the site blocked you
       โ””โ”€ no, it is a 200 that looks like the real site
             Q3. Is the value you saw in the browser inside the saved bytes?
             โ”œโ”€ yes, but your selector finds nothing โ†’ BRANCH 3: the site changed
             โ””โ”€ no โ†’ run the rotation probe in Branch 2 before touching selectors

The same logic as a function you can drop next to freeze():

BLOCK_TITLES = (
    "just a moment", "attention required", "access denied",
    "verify you are human", "are you a robot", "pardon our interruption",
)


def classify(record, html=b"", known_value=None):
    """Return 'proxy', 'blocked', 'site-changed', 'network' or 'unclear'."""
    exc = record.get("exception", "")
    if exc:
        if "407" in exc or "ProxyError" in exc:
            return "proxy"
        return "network"  # DNS, TLS or timeout talking to the target itself
    if record["status"] == 407:
        return "proxy"
    headers = {k.lower(): v for k, v in record["headers"].items()}
    title = (record.get("title") or "").lower()
    if (record["status"] in (403, 429, 503)
            or headers.get("cf-mitigated") == "challenge"
            or any(t in title for t in BLOCK_TITLES)):
        return "blocked"
    if known_value and known_value.encode() not in html:
        return "unclear"  # real data missing from a 200: probe before editing selectors
    return "site-changed"

The cf-mitigated check is the one deterministic marker in that list. Cloudflare's challenge detection docs say every challenge page carries the header and that "challenge is the only valid value". The title strings are a starter list we have seen on challenge pages, not documented by any vendor, so extend them from your own saved bodies. Building a full block detector is its own job, and it is covered in how to detect when your scraper is blocked.

Branch 1: your proxy changed

This branch is the cheapest to confirm and the most common reason for a clean break after "weeks of working". Three things change on the proxy side without you touching code: the plan, the credentials, or the IP your server presents.

Confirm it from the shell. These three commands separate them in under a minute:

# 1. Does the gateway accept these credentials? A healthy run prints connect=200.
curl -s -o /dev/null -w "connect=%{http_connect} code=%{http_code}\n" \
  -x http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000 https://www.sparkproxy.io/

# 2. Which public IP is this server using today? (matters for IP-whitelist auth)
curl -s https://checkip.amazonaws.com

# 3. Does the proxy hostname in your config still resolve?
nslookup gateway.sparkproxy.io

With bad credentials, command 1 printed connect=407 code=000 and curl exited with code 56 in our test. The gateway's refusal carries Proxy-Authenticate: Basic realm="SparkProxy" and X-Squid-Error: ERR_CACHE_ACCESS_DENIED 0.

One measured quirk is worth knowing before you grep logs. With requests, a refused proxy shows up two different ways depending on the target's scheme. An https:// target raises ProxyError with "Tunnel connection failed: 407", because the refusal happens during CONNECT. A plain http:// target raises nothing at all: you get a normal response object whose status_code is 407. A scraper that only logs status codes will file that under "blocked", and you'll go hunting in the wrong branch. This check reports either form:

def proxy_state(proxies):
    """Ask the gateway directly; plain HTTP so a 407 arrives as a status."""
    try:
        r = requests.get("http://www.sparkproxy.io/", proxies=proxies,
                         timeout=20, allow_redirects=False)
    except requests.exceptions.ProxyError as exc:
        return f"unreachable: {exc}"
    if r.status_code == 407:
        return "rejected: " + r.headers.get("X-Squid-Error", "407")
    return f"accepted (target answered {r.status_code})"

Against our deliberately wrong credentials it returned rejected: ERR_CACHE_ACCESS_DENIED 0.

The fixes, by cause.

  • Plan lapsed or trial ended. The 24-hour free trial ends on its own, and on 2026-10-30 the SparkProxy pricing card listed the $75 Starter pack as "Billed every 30 days". A job that ran for roughly a month and then started returning 407 fits a renewal that didn't go through. From the client, a lapsed plan and a wrong password look identical, so check the dashboard before anything else. Plans are packs with unlimited bandwidth, so you can't have "run out of traffic" mid-cycle; the billing unit is the plan and its concurrency.
  • Credentials rotated. Someone regenerated the password or removed a sub-user. Copy them fresh from the dashboard. If the new password contains @, : or /, percent-encode it in the URL.
  • Your server's IP changed. If you authenticate by IP whitelist rather than user:pass, a redeploy, a new NAT gateway or a cloud instance restart can hand your job a new public IP. Compare command 2's output with the whitelist. The same pricing card listed 5 whitelisted IPs on Starter, so an autoscaling fleet can quietly exceed it. Switching that job to user:pass auth removes the problem.
  • Stale hostname or port. Use gateway.sparkproxy.io with port 11000 for HTTP/HTTPS (Random Rotation), 11002 for Sticky Sessions, and 13000 for SOCKS5. A snippet copied from an old config that names a hostname which no longer resolves fails with the NameResolutionError from the table.

On the Scraping API, the account answers for itself. The Scraping API docs separate account errors from target errors by status code, so read them before suspecting the site:

API = "https://scrape.sparkproxy.io/api/v1"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}

r = requests.get(API, headers=HEADERS,
                 params={"url": URL, "render_js": "false"}, timeout=120)
if r.status_code in (401, 402, 429):
    # 401 bad key, 402 out of credits, 429 rate or concurrency limit.
    print("account side:", r.status_code, r.json())

A 402 comes back as {"error": "Insufficient credits", "credits_required": 10, "credits_remaining": 3}. Remember that render_js defaults to true at 5 credits, so the 1,000 free credits cover 200 default requests, or 1,000 with render_js=false. A prototype that "stopped" on day four may simply have spent them. A 429 with "Concurrency limit reached" means you raised parallelism on your side.

If the proxy is accepted but the target still fails, you are done with this branch. For a full health check of the proxy itself (exit IP, HTTPS, latency), use how to test if your proxy is working.

Branch 2: the site blocked you

The target answered, and the answer was a refusal. Before choosing a fix you need one more fact: is the block tied to specific IPs, to the whole class of IP you use, or to the request itself? Those three have completely different fixes, and only two of them involve a proxy.

The rotation probe. Send six requests through the Random Rotation port, where each request can exit from a different IP, with a short pause between them:

def rotation_probe(url, n=6, pause=2.0):
    outcomes = []
    for _ in range(n):
        try:
            r = requests.get(url, proxies=PROXIES, timeout=30)
            refused = (r.status_code in (403, 429, 503)
                       or r.headers.get("cf-mitigated") == "challenge")
            outcomes.append("block" if refused else "ok")
        except requests.exceptions.RequestException:
            outcomes.append("error")
        time.sleep(pause)
    return outcomes

Read the result like this:

Probe resultWhat it meansFix
All `ok` at a slow paceYou were rate limited, not bannedLower concurrency, honour `Retry-After`
Mix of `ok` and `block`Some exit IPs are flaggedKeep rotating, retry blocked URLs on a new IP
All `block`Your IP class or your request fingerprint is refusedRun the class test below

The class test. To tell "datacenter IPs are refused" from "this request shape is refused", fetch the same URL through the Scraping API twice: once on the default pool and once with proxy_type=premium, the residential pool. Both use json_response=true, so the API returns an envelope and the page itself arrives base64-encoded in body. Decode it before you search it for anything.

import base64


def api_fetch(url, **extra):
    params = {"url": url, "render_js": "false", "json_response": "true", **extra}
    r = requests.get(API, headers=HEADERS, params=params, timeout=120)
    r.raise_for_status()
    env = r.json()
    html = base64.b64decode(env["body"]) if env.get("body") else b""
    return env.get("status_code"), env.get("credits_used"), html


default_pool = api_fetch(URL)                       # 1 credit
premium_pool = api_fetch(URL, proxy_type="premium")  # 10 credits without JS
for label, (status, credits, html) in (("default", default_pool),
                                       ("premium", premium_pool)):
    print(label, status, credits, len(html), b"widget-42" in html)

If premium returns the real page and the default pool doesn't, the site is refusing the IP class. If both are refused, the IP was never the problem: the site is judging headers, TLS or behaviour, and a different proxy will not change the verdict. Retry with render_js=true (5 credits, or 25 on the premium pool) so a real browser makes the request.

Rate limits deserve their own fix. A 429 is defined in RFC 6585 as "too many requests in a given amount of time", and the response "MAY include a Retry-After header". If you get one, the wait is the instruction:

from email.utils import parsedate_to_datetime


def retry_after_seconds(response, default=60):
    value = response.headers.get("Retry-After", "").strip()
    if value.isdigit():
        return int(value)
    try:
        when = parsedate_to_datetime(value)  # the HTTP-date form
        return max(0, int(when.timestamp() - time.time()))
    except (TypeError, ValueError):
        return default

A Cloudflare page that reads "Error 1020: Access denied" is different. Cloudflare's 1020 documentation says a security rule configured by the site owner blocked the request and that it is returned with HTTP 403. That rule may match on country, ASN or request properties, so the class test above is the fastest way to see which.

Pacing and rotation strategy in depth live in how to avoid getting your proxy blocked, and backoff schedules in retry and backoff strategies for web scraping.

Branch 3: the site changed

Status 200, the page title is right, the proxy is fine, and your parser comes back empty or throws AttributeError: 'NoneType' object has no attribute 'text'. That is a markup change, and no proxy setting will help. The question is where the value moved.

Take the exact value you wrote down from the browser and search the saved bytes for it. If it is there, print the path of tags that contains it, and check whether the same value also appears in structured data, which tends to survive redesigns better than CSS classes:

from bs4 import BeautifulSoup


def locate(html, known_value):
    soup = BeautifulSoup(html, "html.parser")
    for text in soup.find_all(string=lambda s: s and known_value in s):
        if text.parent.name in ("script", "style"):
            continue
        el, parts = text.parent, []
        while el is not None and el.name != "[document]":
            classes = "".join(f".{c}" for c in el.get("class", [])[:2])
            parts.append(el.name + classes)
            el = el.parent
        print(" > ".join(reversed(parts)))
    for tag in soup.find_all("script", type="application/ld+json"):
        if known_value in (tag.string or ""):
            print("also present in JSON-LD: parse that instead of the DOM")


html = Path("evidence").joinpath("20261030-071500.html").read_bytes()
locate(html, "$24.99")

Three outcomes, three fixes:

  • The value is in the HTML under new class names. Rewrite the selector against something stable, such as a data- attribute, an itemprop, or the JSON-LD block. Anchoring rules are covered in XPath and CSS selectors for web scraping.
  • The value is absent from the raw HTML but visible in the browser. The site moved that data to client-side rendering. Fetch it rendered (render_js=true on the Scraping API, the default) or find the XHR in the browser's Network tab and call that endpoint directly.
  • An endpoint you parsed as JSON now returns HTML. Look at the saved body. If it is a challenge page, you are back in Branch 2. If it is a 404 page or a redirect to a new path, the API moved: find the new request in the Network tab.

If you'd rather not maintain selectors at all, extract_rules on the Scraping API returns an extracted object built from CSS selectors you pass per request. Don't combine it with json_response: extract_rules replaces the normal response, so there is no base64 body to decode.

When none of the three branches fits

Sometimes the proxy is accepted, the target serves real data from your laptop, and the job still fails on the server. Then the change happened on your side, even though nobody edited the scraper:

  • An unpinned dependency upgraded. A rebuilt container pulled a new major version of a parser or HTTP client. Compare pip freeze today against the last good image.
  • The cron environment differs from your shell. An HTTP_PROXY or HTTPS_PROXY variable set (or missing) on the server overrides what the code says, because requests reads them by default. Print requests.utils.get_environ_proxies(URL) inside the job.
  • Disk or output. A full disk or a revoked database credential makes a scraper "return no data" while every request succeeds. Check the write path, not the fetch.

None of these is a proxy problem, and changing proxies will only add noise to the investigation.

What a proxy fixes, and what it never will

CauseCan a proxy change fix it?What actually fixes it
Lapsed plan, rotated credentials, whitelist IP driftYes, it is the proxyRenew, re-copy credentials, update whitelist or use user:pass
A few exit IPs flaggedYesRandom Rotation, retry on a fresh IP
Whole datacenter class refusedPartlyScraping API with `proxy_type=premium`
Rate limited (429)No, slowing down doesLower concurrency, honour `Retry-After`
Request fingerprint refusedNoA real browser (`render_js=true`), real headers
Selectors broke after a redesignNoNew selectors or structured data
Data moved to JavaScriptNoRender, or call the XHR endpoint
Dependency or environment drift on your serverNoPin versions, fix the environment

Two rows say yes, one says partly. That ratio is the reason to run the tree before buying anything: most overnight breaks are fixed in your code or your dashboard.

The whole triage as one runnable file

Put freeze(), classify(), proxy_state() and rotation_probe() from above in one file with this entry point. It prints the branch and the next thing to run, so whoever is on call starts from an answer.

import sys

NEXT_STEP = {
    "proxy": "Branch 1: check the dashboard plan, credentials and whitelist IP",
    "blocked": "Branch 2: run rotation_probe(), then the premium class test",
    "site-changed": "Branch 3: run locate() with a value copied from the browser",
    "unclear": "Real data missing from a 200: run rotation_probe() first",
    "network": "Target unreachable: DNS, TLS or timeout, not the proxy",
}


def triage(url, known_value=None):
    record = freeze(url)
    html = b""
    if "exception" not in record:
        html = Path("evidence", f"{record['at']}.html").read_bytes()
    branch = classify(record, html, known_value)
    print("proxy:", proxy_state(PROXIES))
    print("branch:", branch, "->", NEXT_STEP[branch])
    if branch in ("blocked", "unclear"):
        print("rotation probe:", rotation_probe(url))
    return branch


if __name__ == "__main__":
    triage(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)

Run it as python triage.py https://shop.sparkproxy.io/products/widget-42 '$24.99'. Including proxy_state() in every run matters: a target can block you and the plan can lapse in the same week, and the cheaper fix should never hide behind the expensive one.

Make the next break loud

The reason this break cost you a morning is that it was silent until someone noticed missing rows. Two cheap changes help. Log the exception string and status of the first failure per run, not just a count, so the tree can run from the log alone. And alert on zero extracted rows, not only on exceptions, because a branch 3 failure never raises. For a complete detection layer with baselines and canary URLs, see the detection guide linked in the three-way split section; for running jobs on a schedule with alerting, how to schedule and automate web scrapers covers the setup.

Frequently asked questions

FAQ

Because the failure came from outside your code: the target site changed its markup, the site started blocking your IPs or request pattern, or your proxy plan, credentials or whitelisted IP changed. Save one failing request and read its exception, status and headers to see which.

A block shows up as a 403, 429 or 503, a cf-mitigated: challenge header, or a challenge page title. A layout change returns a normal 200 page that still contains the value you can see in the browser, while your selector finds nothing.

Only for some causes. A proxy fixes flagged IPs, and a residential pool can get past a site that refuses datacenter IPs, but it cannot fix broken selectors, rate limits, fingerprint blocks, or data that moved into JavaScript.

A 407 after a long clean run usually means the plan lapsed, the trial ended, the credentials were regenerated, or your server's public IP no longer matches the whitelist. The gateway gives the same 407 for all of these, so confirm the plan in the dashboard first.

The server presents a different public IP, which may be missing from your proxy whitelist or sit in a range the target distrusts. It may also run with different environment variables, such as HTTPS_PROXY, or with different library versions than your laptop.

It means the site is rate limiting you and telling you how long to wait. Pause for the number of seconds (or until the date) in Retry-After, then resume at lower concurrency; rotating to new IPs without slowing down tends to bring the 429s back.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter proxy network (1M+ premium IPs across 80+ countries, Random Rotation and Sticky Sessions, HTTP/HTTPS/SOCKS5) and the SparkProxy Scraping API. The error strings and gateway responses in this guide were captured from our own test runs on 2026-10-30. Plans start from $75/month with unlimited bandwidth and a 24-hour free trial, no credit card required. Questions about a failing job: support@sparkproxy.io.

Keep reading

Related articles