Todo scraper precisa de lógica de retry, e a maioria a desenvolve por acidente: um bloco try aqui, um time.sleep ali, um contador de tentativas adicionado na semana em que algo quebrou. O resultado funciona até o dia em que um alvo passa por uma hora ruim, momento em que o caminho de retry causa mais dano do que a falha original causou. O argumento de por que isso acontece está em retry and backoff; este é a implementação.
A ideia organizadora é que um retry é uma decisão tomada a partir de uma falha classificada, não um loop envolto em uma requisição. Acerte a classificação e o resto segue.
Classifique primeiro
Toda falha se encaixa em uma de quatro classes, e cada uma tem exatamente uma resposta correta.
| Classe | Exemplos | Resposta |
|---|---|---|
| Transitória | timeout, connection reset, 502, 503, 504 | retry com backoff |
| Rate | 429, Retry-After presente | esperar conforme instruído, mesma rota |
| Identidade | página de desafio, 403 persistente, página de bloqueio | nova sessão, depois retry |
| Terminal | 404, 400, 401, 407, falha de parse | não fazer retry, expor o erro |
Duas dessas são as que as pessoas erram. Sinais de Rate são instruções sobre ritmo, então a resposta é esperar em vez de trocar de endereços, porque rotacionar para sustentar o mesmo ritmo é exatamente o comportamento que escala um throttle para um bloqueio. Falhas Terminais nunca devem ser retentadas: um 407 significa credenciais ou uma flag de username malformada e será igualmente errado na quinta tentativa, e uma falha de parse é um bug no seu código que um retry reproduz fielmente para sempre.
O quinto caso é o que nunca aparece em um código de status: uma resposta que parece boa e não é. Uma página de desafio, um conjunto de resultados vazio, ou uma listagem truncada servida com um 200 pertence à classe de identidade, mas só se você perceber, o que significa validar o corpo antes de decidir qualquer coisa. Essa verificação é a base de todo o sistema, conforme detecting blocked or fake content.
Classificação em código
import random, time, requests
TRANSIENT = {502, 503, 504}
def classify(resp, exc, is_valid):
if exc is not None:
return "transient" # timeout, reset, DNS
if resp.status_code == 429:
return "rate"
if resp.status_code in TRANSIENT:
return "transient"
if resp.status_code in (403, 401) or looks_like_challenge(resp):
return "identity"
if resp.status_code == 200 and not is_valid(resp.text):
return "identity" # soft block: 200 but not our data
if resp.ok:
return "ok"
return "terminal" # 404, 400, 407, everything else
looks_like_challenge é específico por alvo e geralmente é uma lista curta de marcadores: uma referência a script de captcha, um título de interstitial conhecido, um corpo bem mais curto que uma página real. Mantenha isso em um único lugar por alvo para que tanto o caminho de retry quanto seu monitoramento usem a mesma definição.
Backoff com jitter, e respeitando o Retry-After
Para a classe transitória, o atraso cresce exponencialmente e deve ser aleatorizado. Atrasos fixos sincronizam seus workers, então cem requisições que falham juntas fazem retry juntas, e a rajada sobrevive ao backoff.
def backoff(attempt, base=1.0, cap=60.0):
ceiling = min(cap, base * (2 ** attempt))
return random.uniform(0, ceiling) # full jitter
Para a classe rate, o alvo pode te dizer exatamente quanto tempo esperar, e essa instrução vence seu próprio cronograma:
def wait_for(resp, attempt):
ra = resp.headers.get("Retry-After")
if ra:
try:
return min(float(ra), 300) # honour it, but cap it
except ValueError:
pass # HTTP-date form, fall through
return backoff(attempt, base=2.0) # rate signals start slower
Juntando tudo
MAX_ATTEMPTS = 4
def fetch(url, country, is_valid, session_id=None):
sid = session_id
for attempt in range(MAX_ATTEMPTS):
proxies = build_proxies(country, sid) # sid=None means rotate
resp = exc = None
try:
resp = requests.get(url, proxies=proxies, timeout=20)
except requests.RequestException as e:
exc = e
kind = classify(resp, exc, is_valid)
if kind == "ok":
return resp
if kind == "terminal":
raise TerminalError(url, getattr(resp, "status_code", None))
if kind == "identity":
sid = new_session_id() if sid else None # retire the session
time.sleep(backoff(attempt))
elif kind == "rate":
time.sleep(wait_for(resp, attempt)) # wait, do not rotate
else:
time.sleep(backoff(attempt))
raise Exhausted(url)
Três detalhes ali importam mais do que a estrutura. A função lança exceção em erros terminais em vez de retornar None, então quem chama não pode confundir uma falha com dados vazios. Falhas de identidade substituem a sessão em vez de reutilizá-la, porque a anterior já é conhecida pelo alvo. E falhas de rate deliberadamente não tocam na sessão, mantendo a mesma rota enquanto desaceleram.
Proteções que evitam que os retries se tornem o problema
A lógica por requisição não é suficiente sozinha, porque não tem visão do sistema. Três adições fazem a maior parte do trabalho de proteção.
Um orçamento de retry limita os retries como uma fração do tráfego total para um alvo, digamos dez por cento. Em operação normal você nunca chega perto dele. Quando um alvo quebra de forma ampla, o orçamento se esgota imediatamente e os retries extras simplesmente não acontecem, que é o comportamento desejado, já que retries ajudam com falhas isoladas e prejudicam ativamente durante uma interrupção geral.
Um circuit breaker por alvo para de enviar completamente assim que a taxa de falha ultrapassa um limite, aguarda um cooldown, depois deixa passar um fluxo pequeno para testar a recuperação. Isso protege o alvo do seu acúmulo de tentativas e protege seus endereços de acumularem falhas contra um site que não está respondendo a ninguém.
Um limitador de taxa compartilhado pelo qual os retries também passam. Se os retries contornam seu ritmo, seu caminho de erro se torna uma inundação sem limite exatamente quando o alvo está menos capaz de suportar. Roteie cada tentativa pelo mesmo limitador, conforme rate limiting and throttling.
Os três pertencem ao componente que já vê todas as requisições, o que é o argumento para a proxy manager em vez de código de retry por job.
Idempotência e a dead-letter queue
Duas preocupações práticas fáceis de esquecer.
Retries assumem que a operação é segura de repetir. Para coleta isso é quase sempre verdade, já que buscar uma página duas vezes custa banda e nada mais. Se alguma parte do seu pipeline escreve como efeito colateral de buscar, torne a escrita idempotente, indexada em algo estável, para que um retry não crie um registro duplicado.
E quando uma requisição esgota suas tentativas, não a descarte. Envie-a para uma dead-letter queue e reprocesse-a bem mais tarde, na próxima execução ou após um longo cooldown, em vez de dentro da rajada atual. A maioria dos itens que falham durante um incidente é bem-sucedida por conta própria uma hora depois, e uma fila transforma uma falha definitiva em uma falha adiada sem custo algum.
Observe a taxa de retry
Retries são o aviso mais precoce que você recebe. A proporção de retry, ou seja, retries como fração de requisições por alvo, sobe antes da taxa de sucesso cair, porque um pipeline que faz retry até chegar a um resultado com aparência normal está escondendo um problema em vez de resolvê-lo. Também é custo puro em um produto cobrado por banda, então é ao mesmo tempo uma métrica de saúde e uma métrica de gasto. Coloque isso no dashboard ao lado da taxa de sucesso validada, conforme proxy KPIs e monitoring your pipeline.
Resumo final
Lógica de retry é um classificador seguido de quatro respostas, não um loop com um contador. Valide o corpo para que bloqueios suaves sejam classificados como falhas, depois espere nos sinais de rate sem rotacionar, aposente a sessão em falhas de identidade, use backoff com jitter nas transitórias, e nunca faça retry de um erro terminal. Limite tentativas e atraso. Depois adicione as proteções que uma visão por requisição não pode fornecer: um orçamento de retry para que uma interrupção ampla não vire uma inundação, um circuit breaker por alvo, e um limitador compartilhado que os retries também respeitam. Envie requisições esgotadas para uma dead-letter queue para uma execução posterior, e observe a proporção de retry como seu sinal mais precoce de que algo está mudando.
A metade de failover disso, ter para onde fazer retry de forma limpa, é o que os residential proxies fornecem: um grande pool de endereços reais de qualidade residencial para que uma sessão aposentada seja substituída em vez de reutilizada, com per-GB pricing que torna retries disciplinados diretamente mais baratos do que retries indisciplinados.