“Balanceamento de carga de proxy” é uma frase que hoje significa algo muito diferente do que significava antes. No modelo antigo, você comprava uma lista de IPs e construía um rotator: escolher um IP, monitorar sua saúde, retirá-lo quando morria, recolocá-lo depois. Times ainda escrevem esse middleware por hábito, e em um gateway moderno isso é, na maior parte, código morto.
Em um gateway de proxy residencial, o próprio pool se balanceia. Um único endpoint, e o provedor atribui uma saída a partir de milhões de IPs por requisição ou por sessão, do lado do servidor. Você não está balanceando a carga de IPs. O que você está arquitetando é a camada acima: como o trabalho é mapeado em identidades, quanta concorrência um alvo vai tolerar, e o que acontece quando uma requisição falha. Acerte essas três coisas e o sistema escala; erre e nenhuma qualidade de proxy vai salvar você.
Este é um mergulho profundo nessas três camadas, para times que rodam pipelines de coleta em produção.
O que o gateway faz, e onde sua arquitetura começa
Vale a pena ser preciso sobre essa divisão, porque ela determina o que você deve e não deve construir:
O gateway cuida de: seleção do IP de saída a partir do pool, correspondência geográfica, saúde de IPs individuais e a mecânica de rotação. Você expressa a intenção através do username (country, city, sid, ttl) e ele resolve isso em uma saída.
Você cuida de: a unidade de identidade (o que recebe seu próprio IP, e por quanto tempo), controle de concorrência (quão forte você pressiona cada alvo), política de retry (o que acontece na falha) e observabilidade.
O erro arquitetural mais comum é construir uma camada de proxy-rotator que duplica o gateway. Se você se pegar mantendo uma tabela de saúde de IPs, pare, esse é o trabalho do provedor, e sua versão tem uma visão do pool pior do que a dele. Seu trabalho começa no nível da política de requisição.
Camada 1: arquitetura de rotação, escolhendo a unidade de identidade
Essa é a decisão de design da qual tudo mais depende. A pergunta não é “com que frequência devo rotacionar?”, mas “o que é uma identidade, e que trabalho pertence a ela?”
Os dois primitivos:
- Rotação por requisição (omitindo
sid): cada requisição recebe um IP de saída novo. Diversidade máxima de IP, zero reaproveitamento de conexão, ideal para grandes conjuntos de buscas independentes onde nenhuma requisição depende da anterior. - Sessão fixa (sticky) (
sid+ttl): todas as requisições que carregam aquele id de sessão compartilham um IP de saída até o TTL expirar. Necessário quando um fluxo precisa parecer um único usuário, e é também o que torna as conexões em pool reaproveitáveis (veja sticky vs rotativo).
A regra de design: rotacione na fronteira de uma unidade lógica de trabalho, não arbitrariamente. Uma unidade de trabalho é o que precisa ser internamente consistente, as avaliações paginadas de um produto, um fluxo de busca, a sessão de uma conta. Mapeie isso de forma limpa:
um worker = uma unidade de trabalho = um sid = um IP de saída (durante seu TTL)Derive o id de sessão a partir da unidade de trabalho em vez de aleatoriamente, para que o comportamento seja reproduzível e você possa decidir deliberadamente se um retry mantém ou muda a identidade:
def session_id(job_id: str, attempt: int = 0) -> str: # Same job → same identity. Bump `attempt` to deliberately get a new IP. return f"{job_id}-a{attempt}"
def proxy_for(job_id, attempt=0, country="us", ttl=600): sid = session_id(job_id, attempt) user = f"{USER}-country-{country}-sid-{sid}-ttl-{ttl}" url = f"http://{user}:{PASS}@p.shifter.io:443" return {"http": url, "https": url}Esse único parâmetro attempt está fazendo um trabalho arquitetural real: ele torna “retry em uma identidade diferente” uma operação de primeira classe e intencional, em vez de um acidente.
Camada 2: arquitetura de concorrência, e por que ela é por host
A restrição na concorrência quase nunca é o seu plano de proxy (conexões concorrentes tipicamente não são o limite). É o que o alvo tolera. O que significa que um único limite global de concorrência tem a forma errada: ele permite que um alvo permissivo sufoque um alvo frágil, e permite que um alvo frágil estrangule todo o seu pipeline.
Limite por host, não globalmente:
import asynciofrom collections import defaultdict
# One semaphore per target host, tuned to what that host tolerates_limits = {"tough-site.example": 4, "open-site.example": 32}_sems = defaultdict(lambda: asyncio.Semaphore(8)) # sensible defaultfor host, n in _limits.items(): _sems[host] = asyncio.Semaphore(n)
async def fetch(client, url, host): async with _sems[host]: # backpressure applied per host return await client.get(url, timeout=30)Duas propriedades que isso te traz. Isolamento: um alvo lento ou hostil não pode consumir todos os workers. Ajustabilidade: você pode aumentar a concorrência em alvos que não se importam e reduzi-la naquele que bloqueia, de forma independente.
E resista à vontade de aumentar os números. Além da tolerância de um alvo, paralelismo extra não compra throughput, compra bloqueios, e bloqueios custam mais em retries do que a concorrência jamais ganhou (reduzindo latência cobre essa troca).
Camada 3: arquitetura de retry, a parte que faz ou quebra tudo
A maioria dos pipelines falha aqui. Um for attempt in range(3): retry() ingênuo transforma uma tarde ruim em uma tempestade de retries que amplifica bloqueios e queima banda. Uma camada de retry de verdade faz quatro coisas.
1. Classifique antes de tentar de novo. Nem todas as falhas são iguais, e a resposta é diferente para cada uma:
| Falha | Exemplo | Resposta correta |
|---|---|---|
| Transporte | timeout, conexão resetada | Retry, a mesma identidade está tudo bem |
| Rate limit | 429 | Recue com força, desacelere o host inteiro |
| Bloqueio | 403, CAPTCHA, bloqueio suave com 200 | Retry em uma nova identidade, não reutilize o IP |
| Permanente | 404, 400 | Não tente de novo, registre e siga em frente |
Note o bloqueio suave: um 200 com uma página de CAPTCHA é uma falha. Se você classificar apenas pelo status code, vai contar bloqueios como sucessos e seu pipeline vai silenciosamente se encher de lixo (por que scrapers são bloqueados).
2. Recue com jitter. Backoff exponencial sem jitter sincroniza seus workers em uma manada estrondosa que atinge o alvo em ondas. Sempre adicione aleatoriedade.
3. Mude de identidade nos bloqueios. Tentar de novo um bloqueio no mesmo IP sticky é apenas pedir a mesma rejeição outra vez. Aumente o contador de tentativas para que o id de sessão mude e o gateway te entregue uma saída diferente.
import random, asyncio
async def fetch_with_policy(client, url, job_id, host, max_attempts=4): for attempt in range(max_attempts): proxies = proxy_for(job_id, attempt=attempt) # new identity per attempt try: r = await client.get(url, proxies=proxies, timeout=30) kind = classify(r) # ok | ratelimit | block | permanent if kind == "ok": return r if kind == "permanent": return None # don't waste attempts if kind == "ratelimit": await slow_down(host) # widen the host's pacing except (asyncio.TimeoutError, ConnectionError): pass # transport: retry backoff = (2 ** attempt) + random.uniform(0, 1) # jitter, always await asyncio.sleep(backoff) await dead_letter(job_id, url) # park it, don't block the pipeline return None4. Desista com elegância. Depois de N tentativas, estacione o item em uma dead-letter queue e siga em frente. Retries ilimitados não salvam um job; convertem uma falha em carga sustentada contra um alvo que já está te recusando.
Mais dois padrões que vale a pena construir quando você está em escala: um circuit breaker por host (se a taxa de sucesso desaba, pare de enviar por um tempo em vez de insistir), e itens de trabalho idempotentes, para que um retry seja sempre seguro de executar.
A forma de referência
Junto, o pipeline se parece com isto:
work queue ↓per-host limiter (semaphore + pacing) ↓worker → session id (sid) → gateway → target ↓classify response ├── ok → validate content → emit ├── ratelimit → slow host → backoff + requeue ├── block → new identity → backoff + requeue └── permanent → record, drop ↓ (after N attempts)dead-letter queueCada seta é uma decisão que você, de outra forma, tomaria implicitamente e mal. Tornada explícita, o sistema se degrada com elegância em vez de desmoronar.
Observabilidade: você não pode operar isso às cegas
Instrumente, no mínimo: taxa de sucesso por host (com validação de conteúdo, não status codes), latência p50/p95, taxa de retry e detalhamento por tipo de falha, e bytes por registro bem-sucedido. Esses quatro indicam onde agir, uma taxa de retry crescente em um host significa apertar a concorrência daquele host; um crescimento em bytes por registro significa que você está puxando payload que não analisa (cortando custos de banda). Os métodos de medição estão em como testar velocidade, taxa de sucesso e precisão de localização de proxy.
Anti-padrões a evitar
- Construir um rotator de IP em cima de um gateway. Redundante, e com informação pior do que o próprio balanceamento do provedor.
- Um único limite global de concorrência. Acopla alvos não relacionados; limite por host.
- Tentar de novo bloqueios no mesmo IP sticky. Mesmo IP, mesma resposta.
- Backoff sem jitter. Sincroniza workers em ondas.
- Retries ilimitados. Amplifica bloqueios, queima banda, só adia o inevitável.
- Tratar 200 como sucesso. Bloqueios suaves são 200s; valide o conteúdo ou seus dados apodrecem silenciosamente.
- Rotacionar no meio do fluxo. Mudar de IP dentro de uma sessão de múltiplas etapas é um sinal de detecção, rotacione nas fronteiras das unidades.
FAQ
Preciso construir o balanceamento de carga de proxy eu mesmo? Não no nível de IP. Um gateway atribui saídas a partir do pool do lado do servidor, então um rotator de IP ou uma tabela de saúde do seu lado é redundante. Arquitete a camada acima: unidades de identidade, concorrência por host e política de retry.
Quantas requisições concorrentes devo rodar? Depende do alvo, não do seu plano. Defina um limite por host com base no que aquele host tolera, e ajuste cada um de forma independente. Além desse ponto, mais paralelismo produz bloqueios, não throughput.
Um retry deve usar o mesmo IP ou um novo? Depende da falha. Erros de transporte (timeouts) podem tentar de novo na mesma identidade. Bloqueios e CAPTCHAs devem tentar de novo em uma identidade nova, mudando o id de sessão, já que o IP é o que foi recusado.
Como evito que os retries piorem o bloqueio? Classifique as falhas, recue exponencialmente com jitter, limite as tentativas, mande para dead-letter o que não vai ter sucesso, e adicione um circuit breaker por host. Retries ilimitados e não classificados são como um pequeno problema de bloqueio vira um grande.
O que devo monitorar em um pipeline de proxy? Taxa de sucesso por host (com validação de conteúdo), latência p50/p95, taxa de retry com detalhamento por tipo de falha, e bytes por registro bem-sucedido. Esses quatro revelam quase todo problema que vale a pena corrigir.
Conclusão
Em um gateway, balancear a carga do pool não é o seu problema, e fingir que é leva os times a construir a coisa errada. A arquitetura que realmente determina se um pipeline de coleta escala vive em três decisões: o que constitui uma identidade e que trabalho pertence a ela, quanta concorrência cada alvo individual tolera, e como as falhas são classificadas, recuadas, reidentificadas e eventualmente abandonadas. Acerte essas três coisas, instrumente-as, e o pipeline se degrada com elegância sob pressão em vez de desabar.
O gateway cuida do resto. Se você está construindo isso, o gateway residencial te dá rotação e sessões sticky através do username para que sua arquitetura fique no seu código, não em uma camada de gerenciamento de proxy, e a qualidade do pool determina com que frequência esse caminho de retry é exercido (ou não) (reputação de IP). Para considerações em grande escala, veja a melhor rede de proxy residencial para scraping em grande escala, e a página de preços tem os planos por GB para testar o design contra sua própria carga de trabalho.