DEV Community

Yuhe He
Yuhe He

Posted on

Circuit Breakers for Scrapers: When to Stop a Run That Is Going Bad

A crawler that never stops on its own is a crawler that will eventually spend your whole IP reputation on a bad hour.

I run a daily collector over public channel preview pages (no login, just the web view). One Tuesday it woke up, got a wall of clean 200s with structurally empty bodies, and kept marching through the queue for forty minutes. Nothing threw. Nothing logged an error. It just politely walked off the cliff, one polite request at a time.

That run taught me the rule I now enforce everywhere: a fetch loop needs a trip condition, not just a try/except.

The pattern in ten lines

class Breaker:
    def __init__(self, threshold=3, cooldown=600):
        self.threshold = threshold
        self.fail = 0
        self.cooldown = cooldown
        self.opened_at = None

    def record(self, ok):
        self.fail = 0 if ok else self.fail + 1
        if self.fail >= self.threshold:
            self.opened_at = time.time()

    @property
    def open(self):
        return (
            self.opened_at is not None
            and time.time() - self.opened_at < self.cooldown
        )
Enter fullscreen mode Exit fullscreen mode

Three consecutive failures: the breaker opens, the run aborts, and the next attempt is deferred by a cooldown. Closed → open → half-open is the same state machine a payment gateway uses, and it maps cleanly onto collection runs.

The trap: your definition of "failure"

The breaker above is useless if ok means "no exception." Twelve months of logs convinced me that a run is only healthy when all three of these are true:

  1. Status is 2xx — the cheap check everyone has.
  2. Body has content — non-empty, above a sane byte floor. A 200 with a 900-byte consent shell is a failure.
  3. Expected structure is present — for me: at least one message element in the preview markup. If the page shape my parser was written for isn't in the response, the response failed, full stop.

Score all three per page, feed the score into the breaker, and the "empty 200" cliff becomes a trip on page three, not a forty-minute walk.

Cooldown, not cancel

The half-open step matters more than the trip. If the breaker opens and your scheduler retries five minutes later from datacenter space that's currently distrusted, you convert a soft block into a hard one. Cooldowns should be boringly long — ten minutes minimum, an hour if the source has a history. A collection run that yields nothing for an hour costs you nothing. A /24 that gets classified as abusive costs you the quarter.

What the trip should do

Three things, in order:

  1. Stop the loop — not "log and continue." Continue is the bug.
  2. Keep the partial — pages collected before the trip are valid data. Persist them; don't throw away ninety percent of a run because the last ten percent went bad.
  3. Emit a distinguishable signal — TRIPPED must not look like 0 new records, and neither should look like success. Your log has three states: good, empty, bad. Collapse any two of them and you will be blindsided by the third.

My daily job trips a breaker roughly twice a month now. Every trip used to be an outage I discovered by hand. Every trip now is a line in a log file that says: the world changed, the pipeline noticed, the pipeline stopped. That's the entire job.


I run this collector daily and sell the sourced briefs it produces. The pay-what-you-want sample (every claim with its source permalink) is here: https://heyuhe.gumroad.com/l/ruldgm — and if you want the wheel this pipeline runs on (pip-installable, no API keys, plus the five recipes above as a file): https://heyuhe.gumroad.com/l/scxtul.

Top comments (2)

Collapse
 
double_chen_70da460344c73 profile image
Double CHEN •

The three-part health check catches that empty-200 case. In the sample, a successful probe resets fail but leaves opened_at set; explicit half-open probing and clearing the timestamp on recovery would make those transitions easier to test. Do you scope the breaker per host or per collector?

Collapse
 
yuhehe profile image
Yuhe He •

Good catch on the state machine — you're right that a probe success resetting fail while opened_at stays set leaves the breaker in a state that's neither closed nor open, and "recovered" vs "recovering" become untestable as distinct states. The clean version: half-open admits exactly one probe; probe success clears both counters and the timestamp (that's the close transition), probe failure re-opens with a fresh opened_at. Your phrasing is the testable invariant; mine shipped the lazy one.

Per host, primary. Rate limits, WAF thresholds, and empty-200 storms are properties of the host, not of the job — if three collectors hammer one domain, per-collector breakers give you three polite daemons feeding one angry server. The failure domain and the breaker domain have to match.

That said, pure per-host leaks one real failure: a single collector with a pathological loop can burn a host's entire recovery budget and the breaker can't tell you who spent it. So the version I run is two-tier: per-host breaker gates the egress, per-collector token budgets bound how much of that gate any one job may consume, and budget exhaustion trips a collector-level alarm without touching the host state. Host health and caller discipline are different instruments; the moment you encode both in one counter you can't debug either.

If you end up implementing the half-open clear, the test I'd write first: probe-success → assert opened_at is null, not just fail == 0. That single assertion is where most implementations (mine included) fail.