Retry-Logik für Web Scraper: 6 Fehler, die Aus Fehlern Sperren Machen
Ein paar hundert Seiten nach dem Start bekommt dein Scraper 429 Too Many Requests. Der Retry-Wrapper schickt diese Anfragen erneut. Kurz darauf sind die meisten Antworten 403 oder Challenge-Seiten.
Dein Parser hat sich nicht verändert. Dein Traffic schon: Die Retries haben Anfragen hinzugefügt, genau als die Seite begann, sie abzulehnen. Eine Retry-Strategie, die jeden Fehler als vorübergehendes Serverproblem behandelt, kann ein Rate Limit oder eine Anti-Bot-Sperre verschlimmern.
Die Lösung beginnt damit, die Antwort einzuordnen. Danach entscheidest du, ob du wartest, die Identität des Clients wechselst oder aufhörst. Diese sechs Fehler untergraben genau diese Entscheidung.
1. Sofort Erneut Versuchen
Mit dieser Retry-Schleife fangen die meisten Scraper an:
for attempt in range(3):
response = session.get(url)
if response.status_code == 200:
break
Aus einer abgelehnten Anfrage werden drei Anfragen kurz hintereinander. War die Antwort ein Rate Limit, erhöht die Schleife den Traffic genau im falschen Moment.
Die Standardwerte der Bibliotheken bauen nicht unbedingt eine Pause ein:
urllib3.util.Retry, das die Retries derrequests-Adapter übernimmt, nutzt standardmäßigbackoff_factor=0. Ohne eigene Konfiguration gibt es kein Backoff.- Scrapys
RetryMiddlewarewiederholt408,429,500,502,503,504,522und524bis zuRETRY_TIMES = 2zusätzliche Male. Eine Einstellung für die Wartezeit zwischen Retries gibt es nicht. Mit dem StandardwertRETRY_PRIORITY_ADJUST = -1landet der Retry mit niedrigerer Priorität wieder im Scheduler und kann laufen, sobald ein Download-Slot frei ist.
Nutze exponentielles Backoff mit Jitter. Backoff verlängert die Wartezeit nach wiederholten Fehlern. Jitter macht sie zufällig, damit Worker, die gleichzeitig scheitern, nicht alle gleichzeitig erneut anfragen.
Der “Full Jitter”-Ansatz aus dem Artikel Exponential Backoff And Jitter im AWS Architecture Blog wählt eine zufällige Wartezeit zwischen null und min(cap, base * 2 ** attempt).
Diese Konfiguration nutzt requests mit urllib3 2.x, um Backoff und Jitter auf der Ebene des HTTP-Adapters hinzuzufügen. Setze sie nur ein, wenn diese Ebene für deine Retries zuständig ist.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
retry = Retry(
total=3,
backoff_factor=1, # 1 s, 2 s, 4 s ... capped by backoff_max (120 s)
backoff_jitter=1.0, # adds 0-1 s of random spread
status_forcelist=[429, 500, 502, 503, 504],
respect_retry_after_header=True,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retry))
backoff_jitter in urllib3 addiert einen zufälligen Betrag zum berechneten Backoff. Das ist additiver Jitter, kein Full Jitter: Die Worker teilen sich weiterhin dieselbe Backoff-Kurve. Mit tenacity bekommst du über wait_random_exponential(multiplier=1, max=60) direkt Full Jitter.
Sind die Retries bei einem Status aus status_forcelist aufgebraucht, wirft requests eine requests.exceptions.RetryError, statt die letzte Antwort zurückzugeben. Äußere Schichten sollten diesen Fehler protokollieren und keine weitere Retry-Schleife starten.
2. Retry-After Ignorieren
Bevor du deine eigene Wartezeit berechnest, prüfe Retry-After. Ein 429 oder 503 kann dir damit sagen, wann du es wieder versuchen sollst.
Laut MDN enthält der Header entweder eine Wartezeit in Sekunden, etwa Retry-After: 120, oder ein HTTP-Datum. Dein Code muss beide Formen verarbeiten.
Verlangt der Server 120 Sekunden und dein Backoff wählt zwei, bist du noch im Sperrfenster. Manche Rate Limiter zählen auch abgelehnte Anfragen mit, also können zu frühe Versuche dich darin festhalten.
Das Verhalten unterscheidet sich je nach Bibliothek:
- urllib3:
respect_retry_after_headerist standardmäßig aktiv. Die Sonderbehandlung gilt für413,429und503; diese Antworten können mitRetry-Afterauch ohne Eintrag instatus_forcelistwiederholt werden, sofern die übrigen Retry-Bedingungen es erlauben. Versionen mitretry_after_maxbegrenzen diese Wartezeit standardmäßig auf sechs Stunden. - tenacity: Es interpretiert keine HTTP-Antworten. Deine Wartefunktion muss den Header selbst lesen und parsen.
- httpx:
HTTPTransport(retries=3)wiederholtConnectErrorundConnectTimeout, aber keine HTTP-Statuscodes. Ein429landet direkt in deinem Anwendungscode.
Diese Wartefunktion für tenacity nutzt die Vorgabe des Servers, wenn es eine gibt, und fällt sonst auf Full Jitter zurück:
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import httpx
from tenacity import (retry, retry_if_exception_type,
stop_after_attempt, wait_random_exponential)
class Retryable(Exception):
def __init__(self, response):
super().__init__(response.status_code)
self.response = response
def retry_after_seconds(response):
value = response.headers.get("Retry-After", "").strip()
if not value:
return None
if value.isdigit():
return int(value)
try:
when = parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
fallback = wait_random_exponential(multiplier=1, max=60)
def wait_for_server(retry_state):
exc = retry_state.outcome.exception()
if isinstance(exc, Retryable):
seconds = retry_after_seconds(exc.response)
if seconds is not None:
return min(seconds, 300) + random.uniform(0, 2)
return fallback(retry_state)
@retry(
retry=retry_if_exception_type((Retryable, httpx.TransportError)),
wait=wait_for_server,
stop=stop_after_attempt(3),
reraise=True,
)
def fetch(client: httpx.Client, url: str) -> httpx.Response:
response = client.get(url)
if response.status_code in (429, 502, 503, 504):
raise Retryable(response)
response.raise_for_status()
return response
Weite diese statusbasierte Strategie nicht auf jedes 403 aus. Eine Challenge-Antwort muss vor einem weiteren Versuch eingeordnet werden, wie Fehler 5 zeigt.
3. Antworten Wiederholen, die Keine Fehler Sind
Eine kurze Antwort ist nicht automatisch eine fehlgeschlagene. Paginierte JSON- oder AJAX-Endpunkte können nach der letzten Seite eine leere Liste, einen leeren Body oder ein kleines HTML-Fragment liefern. So ein Fragment kann einen Doctype und ein paar <li>-Elemente enthalten, ohne vollständiges <html> oder <head>.
Wenn dein Validator all diese Formen als kaputt behandelt, holen die Retries immer wieder dieselbe gültige Antwort.
Prüfe gegen das, was der Endpunkt liefern soll, nicht gegen eine Mindestlänge des Bodys. Geben zwei verschiedene Client-Identitäten dieselbe kurze Antwort zurück, ist sie wahrscheinlich die echte. Prüfe den Stand der Paginierung, bevor du es erneut versuchst.
Der umgekehrte Fehler ist, eine Challenge-Seite zu akzeptieren, weil ihr Status 200 ist. Wer nur den Statuscode prüft, lässt dieses HTML in den Datensatz, statt die Sperrbehandlung auszulösen.
Prüfe sowohl den Status als auch den Inhalt, den du extrahieren willst. Siehe HTTP 200 But Blocked: Detecting Soft Blocks für Challenge-Merkmale und Truncated HTML Responses für unvollständige Bodys.
4. Das Proxy-Land bei Jedem Versuch Festlegen
Länder-Targeting schränkt ein, welche Proxy-Exits dein Scraper nutzen kann. Ein Anbieter kann insgesamt einen großen Residential-Pool haben, aber nur relativ wenige Exits in dem Land, das du auswählst.
Wenn jede Anfrage und jeder Retry diese Teilmenge nutzen muss, konzentrieren sich die Anfragen auf weniger IP-Adressen. Der Traffic pro IP steigt, und die Seite antwortet womöglich mit Sperren oder Slider-CAPTCHAs. Den Proxy zu rotieren und die Länderbindung beizubehalten, ergibt nicht unbedingt einen wesentlich anderen Pool.
Kläre zuerst, ob die Länderbindung nötig ist:
- Rufe dieselbe Seite über zwei Länder ab.
- Vergleiche die Felder, die dein Scraper tatsächlich nutzt, etwa Preis, Währung, Lagerbestand und Sprache.
- Stimmen die relevanten Felder überein, entferne die Länderbindung.
- Unterscheiden sie sich, behalte das nötige Land. Warte beim Retry und senke die Parallelität, statt einen weiteren Exit aus demselben kleinen Pool zu ziehen.
Ein benachbarter Markt ist nur eine Alternative, wenn du geprüft hast, dass er dieselbe relevante Version der Seite ausliefert. Tausche korrekte Daten nicht gegen eine einfachere Anfrage.
5. Eine Sperre mit Derselben Identität Wiederholen
Ein 403 allein sagt dir nicht, warum der Zugriff scheiterte: Es kann eine Login-Pflicht, eine Regel der Seite oder eine Anti-Bot-Entscheidung sein. Wenn Cloudflare, DataDome oder Akamai eine Challenge zurückgeben, fließen deine IP-Adresse, dein TLS-Fingerprint, deine Header und deine Session-Cookies in die Entscheidung ein. Wer denselben Client-Zustand wiederholt, sendet dieselben Signale erneut.
Cloudflare markiert seine Challenge-Antworten ausdrücklich: cf-mitigated: challenge. Prüfe diesen Header zusätzlich zum Body. Andere Challenge-Antworten brauchen eigene Inhaltsprüfungen.
Wähle den nächsten Schritt anhand der Einordnung der Antwort:
| Antwort | Wahrscheinliche Bedeutung | Vor dem Nächsten Versuch |
|---|---|---|
429 mit Retry-After | Rate Limit | Die angegebene Zeit warten und die Parallelität für diesen Host senken |
502, 503, 504, 522 oder 524 ohne Challenge-Merkmale | Fehler beim Origin oder am Edge | Backoff; dieselbe Identität kann in Ordnung sein |
403 mit cf-mitigated: challenge oder erkennbarem Challenge-HTML | Anti-Bot-Challenge | Neue IP und frische Session-Cookies oder ein anderer Client |
200 mit CAPTCHA oder “Bestätige, dass du ein Mensch bist”-HTML | Soft Block | Wie eine Challenge behandeln, nicht wie eine erfolgreiche Seite |
| Verbindungsabbruch während des TLS-Handshakes | Netzwerkfehler oder mögliche Sperre am Edge | Einen Retry über eine neue Verbindung und einen neuen Proxy erlauben |
404 oder 410 | Seite fehlt oder wurde entfernt | Nicht als vorübergehenden Fehler wiederholen |
Bei einer bestätigten Sperre eskalierst du von günstigen zu teuren Änderungen: eine neue Proxy-Session, dann ein anderer Client-Fingerprint, dann ein Browser mit Playwright. curl_cffi bietet zum Beispiel ein impersonate-Ziel, mit dem du den Client-Fingerprint wählst. Ändere es gezielt, statt dieselbe gesperrte Konfiguration erneut abzuspielen.
Jede Eskalationsstufe kostet mehr Zeit und Geld. Gehe erst zur nächsten Stufe, wenn die vorherige gescheitert ist, und halte die ganze Leiter innerhalb eines einzigen Versuchsbudgets.
Isoliere außerdem URLs, die immer wieder scheitern. Wird eine URL mit zwei oder drei Identitäten gesperrt, während andere Seiten desselben Hosts problemlos laden, verschwende keine funktionierenden Identitäten mehr daran. Vielleicht gilt für diesen Pfad eine strengere Regel, oder es ist eine Falle. Stelle die URL zur Prüfung zurück.
6. Jede Schicht Wiederholen Lassen
Unabhängige Retry-Schichten multiplizieren die Versuche. Sie addieren sie nicht nur.
Angenommen, dein HTTP-Client wiederholt dreimal, also vier Versuche. Eine Scrapy-Middleware oder dein eigener Wrapper fügt zwei Retries hinzu, also drei Ausführungen dieser Client-Operation. Dann startet die Task-Queue den gescheiterten Task erneut; Celerys autoretry_for nutzt standardmäßig max_retries gleich drei, also vier Task-Läufe.
Das ergibt 4 × 3 × 4 = 48 Anfragen für eine einzige URL, die dauerhaft scheitert. Wenn viele URLs gleichzeitig scheitern, verstärken all diese Schichten den Traffic, während die Seite bereits Anfragen ablehnt.
Das Kapitel über den Umgang mit Überlast im Google-SRE-Buch beschreibt genau diese Verstärkung durch Retries. Seine Regeln lassen sich direkt auf Scraper übertragen:
- Lege einen einzigen Zuständigen für Retries fest. Die anderen Schichten geben Fehler an ihn weiter, statt eigene Schleifen zu starten.
- Begrenze die Versuche pro Anfrage. Das Buch erlaubt drei Fehlschläge, bevor der Fehler an den Aufrufer weitergereicht wird.
- Setze ein Retry-Budget. Im Beispiel des Buchs sind Retries nur erlaubt, solange sie unter 10 % aller Anfragen bleiben.
Führe dieses Budget in einem Scraper pro Host. Ergänze einen Circuit Breaker, der Anfragen an einen Host vorübergehend stoppt, wenn die Sperrquote deinen Schwellenwert überschreitet. Ohne ihn kann selbst eine begrenzte Retry-Schleife einen Host überlasten, wenn genug Worker sie gleichzeitig ausführen. Das Budget begrenzt den zusätzlichen Traffic; der Breaker pausiert einen Host, der zu viele Anfragen ablehnt:
import random
import time
from collections import defaultdict, deque
from urllib.parse import urlsplit
class HostGuard:
"""Per-host retry budget plus a circuit breaker."""
def __init__(self, retry_ratio=0.1, window=200,
block_threshold=0.3, cooldown=600):
self.retry_ratio = retry_ratio
self.block_threshold = block_threshold
self.cooldown = cooldown
self.sent = defaultdict(lambda: deque(maxlen=window)) # True = retry
self.blocked = defaultdict(lambda: deque(maxlen=window)) # True = block
self.open_until = {}
def can_send(self, host):
return time.monotonic() >= self.open_until.get(host, 0)
def can_retry(self, host):
sent = self.sent[host]
budget = self.retry_ratio * max(len(sent), 10)
return self.can_send(host) and sum(sent) < budget
def record(self, host, is_retry, blocked):
self.sent[host].append(is_retry)
recent = self.blocked[host]
recent.append(blocked)
if len(recent) >= 50 and sum(recent) / len(recent) >= self.block_threshold:
self.open_until[host] = time.monotonic() + self.cooldown
recent.clear()
def crawl(urls, fetch_once, max_attempts=3):
"""fetch_once(url, attempt) returns an object with .ok, .blocked, .retryable."""
guard = HostGuard()
parked = []
for url in urls:
host = urlsplit(url).hostname
if not guard.can_send(host):
parked.append(url)
continue
result = fetch_once(url, attempt=0)
guard.record(host, is_retry=False, blocked=result.blocked)
attempt = 1
while (not result.ok and result.retryable
and attempt < max_attempts and guard.can_retry(host)):
time.sleep(random.uniform(0, min(60, 2 ** attempt)))
result = fetch_once(url, attempt=attempt)
guard.record(host, is_retry=True, blocked=result.blocked)
attempt += 1
if not result.ok:
parked.append(url)
return parked
fetch_once muss die Einordnung der Antworten aus den Fehlern 3 und 5 übernehmen. Ist nach einer Sperre ein weiterer Versuch erlaubt, muss es den Identitätswechsel vorbereiten, bevor dieser Versuch läuft.
Die Beispielschleife ist sequenziell. Mit asyncio oder einem Thread-Pool teilen sich alle Worker, die denselben Host ansprechen, einen einzigen HostGuard. Getrennte Guards würden jedem Worker ein eigenes Budget geben und die gesamte Fehlerquote verschleiern.
Der Preis dafür: Diese Kontrollen lassen absichtlich einige URLs unerledigt. Du verzichtest auf einen vollständigen Lauf, damit du den Host nicht für die nächsten zehn verlierst. Stelle den Rest für einen späteren Lauf mit geringerer Rate zurück.
Außerdem kann ein Breaker einen Ausfall der Seite nicht von einer Sperre deiner Identität unterscheiden; beides löst ihn aus. Sieh dir gespeicherte Antworten an, bevor du entscheidest, ob du den Client wechselst oder einfach wartest.
FAQ
Wie oft sollte ein Scraper eine fehlgeschlagene Anfrage wiederholen?
Zwei oder drei Versuche pro URL insgesamt, verwaltet von einer einzigen Schicht, reichen für vorübergehende Fehler wie Timeouts und 503-Antworten. Scheitert sie weiterhin, beende die aktuelle Schleife, stelle die URL zurück und versuche sie in einem späteren Lauf.
Sollte ich eine 403-Forbidden-Antwort wiederholen?
Nicht blind mit derselben Anfrage. Unterscheide zuerst eine Anti-Bot-Antwort von einer Zugriffsbeschränkung. Bei einer Anti-Bot-Sperre änderst du IP, Fingerprint oder Session-Zustand vor dem nächsten Versuch. Ein Identitätswechsel ersetzt keinen fehlenden Login und keine fehlende Berechtigung.
Was ist der Unterschied zwischen exponentiellem Backoff und Jitter?
Exponentielles Backoff verlängert die Wartezeit zwischen Versuchen, zum Beispiel 1, 2, 4 und 8 Sekunden. Jitter macht die Wartezeit zufällig, damit Worker, die gemeinsam scheitern, nicht gemeinsam erneut anfragen. Nutze beides und halte dich an Retry-After, wenn der Server es mitschickt.
Wartet Scrapy zwischen Retries?
Nein. RetryMiddleware plant die Anfrage mit niedrigerer Priorität und ohne Pause neu ein. Aktiviere AutoThrottle (AUTOTHROTTLE_ENABLED = True), das eine Nicht-200-Antwort nie die Wartezeit senken lässt, halte RETRY_TIMES niedrig und schreibe eine eigene Middleware, wenn du echtes Backoff pro Anfrage brauchst.
Zähle Zuerst Deine Retry-Schichten
Öffne deinen Scraper und liste jede Stelle auf, an der wiederholt wird: den HTTP-Adapter, die Scrapy-Einstellungen, deinen Wrapper, die Task-Queue und jeden geplanten Neustart gescheiterter Batches. Multipliziere die Versuche. Liegt das Ergebnis über drei, bestimme einen Zuständigen und schalte die anderen Retry-Schleifen ab, bevor du an den Wartezeiten drehst.
Wenn du die Wechsel von Proxy, Fingerprint und Browser zwischen den Versuchen lieber nicht selbst pflegen willst, übernimmt ScrapeUnblocker Proxys, Rendering und Sperren auf seiner Seite. Laut seiner Fehlerdokumentation werden eine 403-Sperre, ein Timeout oder ein 429 nicht berechnet. So bleibt deine Retry-Strategie an einer Stelle, rund um diesen einen API-Aufruf.
ScrapeUnblocker kostenlos testen
Über 95 % Erfolgsquote · ab 0,55 € pro 1.000 Aufrufe (ggf. zzgl. MwSt.) · 500 kostenlose Anfragen bei der Registrierung.