Um worker faz uma nova tentativa antes do resultado anterior chegar. Ao mesmo tempo, outro worker está resolvendo o mesmo reCAPTCHA na mesma página. As duas chamadas chegam à API da CaptchaAI, as duas são cobradas — e você paga duas vezes pela mesma resposta.
A correção não exige reescrever o pipeline: uma camada de desduplicação, com Redis ou com bloqueio no banco de dados, garante que a mesma combinação de sitekey e pageurl seja resolvida uma única vez, mesmo com dezenas de workers rodando em paralelo.
Por que os CAPTCHAs duplicados acontecem
| Cenário | Causa | Desperdício |
|---|---|---|
| Nova tentativa antes do resultado chegar | Lógica de retry agressiva | Custo de 2 a 5x por CAPTCHA |
| Vários workers atacando o mesmo alvo | Falta de coordenação entre workers | Resoluções em paralelo desperdiçadas |
| Atualização de página dispara nova tentativa | Retry do frontend após timeout | Uma resolução extra a cada refresh |
| Mensagem da fila reprocessada | Reentrega no modelo "pelo menos uma vez" | Resolução duplicada a cada reprocessamento |
Por onde começar a investigar, na ordem de impacto mais comum:
- Confira os logs de retry do worker antes de tudo — é a causa mais frequente e a mais barata de corrigir.
- Verifique se dois processos (ou duas instâncias do mesmo worker) estão consumindo a mesma fila sem partição.
- Só depois olhe para o comportamento do frontend e para a política de reentrega da fila.
Como montar a chave de desduplicação
Gere uma chave exclusiva a partir dos parâmetros da requisição:
import hashlib
def dedup_key(method, sitekey, pageurl):
"""Generate a deduplication key for a CAPTCHA solve request."""
raw = f"{method}:{sitekey}:{pageurl}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
Composição da chave por tipo de CAPTCHA:
| Tipo de CAPTCHA | Componentes principais |
|---|---|
| reCAPTCHA v2 | method + sitekey + pageurl |
| reCAPTCHA v3 | method + sitekey + pageurl + action |
| hCaptcha | method + sitekey + pageurl |
| Turnstile | method + sitekey + pageurl |
| CAPTCHA de imagem | method + hash de body (conteúdo da imagem) |
Duas armadilhas comuns na hora de montar essa chave:
- Hash curto demais aumenta a chance de colisão entre requisições diferentes — os 16 caracteres do exemplo acima já reduzem bastante o risco, mas para volume muito alto prefira o hash completo.
pageurlcom parâmetros voláteis (por exemplo?ts=173289) quebra a desduplicação, porque a mesma página gera uma chave diferente a cada carregamento — normalize a URL antes de gerar a chave.
Desduplicação com Redis: o padrão mais usado
Redis é a escolha natural quando você já usa cache ou filas na mesma stack: o SET com EX resolve TTL e atomicidade em uma única chamada.
Se os workers rodam em sa-east-1 (São Paulo) e o Redis está na mesma região, o overhead de consultar o cache antes de chamar a API é de poucos milissegundos — irrelevante perto do tempo de resolução do CAPTCHA.
Se os workers já usam Redis para fila ou cache de sessão, a desduplicação reaproveita a mesma instância — não é preciso provisionar infraestrutura nova só para isso.
Implementação em Python
import os
import time
import json
import hashlib
import redis
import requests
r = redis.Redis(
host=os.environ.get("REDIS_HOST", "localhost"),
port=int(os.environ.get("REDIS_PORT", 6379)),
decode_responses=True
)
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
# Dedup window: how long to consider a request "in progress"
DEDUP_TTL = 180 # seconds
def dedup_key(method, sitekey, pageurl, extra=""):
raw = f"{method}:{sitekey}:{pageurl}:{extra}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
def solve_with_dedup(sitekey, pageurl, method="userrecaptcha"):
key = dedup_key(method, sitekey, pageurl)
# Check if this request is already being solved
existing = r.get(key)
if existing:
state = json.loads(existing)
if state["status"] == "solving":
# Wait for the result
return wait_for_result(key)
elif state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_cache"}
elif state["status"] == "error":
pass # Allow retry on error
# Mark as solving
r.set(key, json.dumps({"status": "solving", "started": time.time()}), ex=DEDUP_TTL)
# Submit to CaptchaAI
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": method,
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1
})
data = resp.json()
if data.get("status") != 1:
r.set(key, json.dumps({"status": "error", "error": data.get("request")}), ex=30)
return {"error": data.get("request")}
captcha_id = data["request"]
# Poll for result
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get",
"id": captcha_id, "json": 1
}).json()
if result.get("status") == 1:
solution = result["request"]
# Cache the result for other workers (short TTL since tokens expire)
r.set(key, json.dumps({
"status": "solved",
"solution": solution,
"solved_at": time.time()
}), ex=60) # Cache result for 60 seconds
return {"solution": solution, "source": "api"}
if result.get("request") != "CAPCHA_NOT_READY":
r.set(key, json.dumps({
"status": "error", "error": result.get("request")
}), ex=30)
return {"error": result.get("request")}
r.set(key, json.dumps({"status": "error", "error": "TIMEOUT"}), ex=30)
return {"error": "TIMEOUT"}
def wait_for_result(key, timeout=120):
"""Wait for another worker to finish solving."""
start = time.time()
while time.time() - start < timeout:
data = r.get(key)
if data:
state = json.loads(data)
if state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_wait"}
if state["status"] == "error":
return {"error": state.get("error", "UNKNOWN")}
time.sleep(2)
return {"error": "DEDUP_WAIT_TIMEOUT"}
Implementação em JavaScript
const Redis = require("ioredis");
const axios = require("axios");
const crypto = require("crypto");
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
const DEDUP_TTL = 180;
function dedupKey(method, sitekey, pageurl) {
const raw = `${method}:${sitekey}:${pageurl}`;
const hash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16);
return `captcha:dedup:${hash}`;
}
async function solveWithDedup(sitekey, pageurl, method = "userrecaptcha") {
const key = dedupKey(method, sitekey, pageurl);
// Check existing
const existing = await redis.get(key);
if (existing) {
const state = JSON.parse(existing);
if (state.status === "solving") return await waitForResult(key);
if (state.status === "solved") return { solution: state.solution, source: "dedup_cache" };
}
// Mark as solving
await redis.set(key, JSON.stringify({ status: "solving", started: Date.now() }), "EX", DEDUP_TTL);
// Submit
const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: { key: API_KEY, method, googlekey: sitekey, pageurl, json: 1 },
});
if (submit.data.status !== 1) {
await redis.set(key, JSON.stringify({ status: "error", error: submit.data.request }), "EX", 30);
return { error: submit.data.request };
}
const captchaId = submit.data.request;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const poll = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
});
if (poll.data.status === 1) {
await redis.set(key, JSON.stringify({ status: "solved", solution: poll.data.request }), "EX", 60);
return { solution: poll.data.request, source: "api" };
}
if (poll.data.request !== "CAPCHA_NOT_READY") {
await redis.set(key, JSON.stringify({ status: "error", error: poll.data.request }), "EX", 30);
return { error: poll.data.request };
}
}
await redis.set(key, JSON.stringify({ status: "error", error: "TIMEOUT" }), "EX", 30);
return { error: "TIMEOUT" };
}
async function waitForResult(key, timeout = 120000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const data = await redis.get(key);
if (data) {
const state = JSON.parse(data);
if (state.status === "solved") return { solution: state.solution, source: "dedup_wait" };
if (state.status === "error") return { error: state.error };
}
await new Promise((r) => setTimeout(r, 2000));
}
return { error: "DEDUP_WAIT_TIMEOUT" };
}
Alternativa sem Redis: bloqueio no PostgreSQL
Nem todo time quer adicionar Redis só para desduplicar CAPTCHAs.
Se o PostgreSQL já roda em produção, os locks consultivos (advisory locks) fazem o mesmo trabalho sem infraestrutura extra:
import psycopg2
def solve_with_pg_dedup(conn, sitekey, pageurl):
"""Use PostgreSQL advisory locks for deduplication."""
# Generate a numeric lock key from the dedup key
lock_id = hash(f"{sitekey}:{pageurl}") & 0x7FFFFFFF
cursor = conn.cursor()
# Try to acquire advisory lock (non-blocking)
cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_id,))
acquired = cursor.fetchone()[0]
if not acquired:
# Another worker is solving — wait for result
cursor.execute("SELECT pg_advisory_lock(%s)", (lock_id,))
# Lock acquired means other worker finished — check cache
cursor.execute(
"SELECT solution FROM captcha_cache "
"WHERE sitekey = %s AND pageurl = %s "
"AND created_at > NOW() - INTERVAL '60 seconds'",
(sitekey, pageurl)
)
row = cursor.fetchone()
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
if row:
return {"solution": row[0], "source": "pg_cache"}
return {"error": "NO_CACHED_RESULT"}
try:
# Solve the CAPTCHA
solution = solve_via_api(sitekey, pageurl)
if solution:
cursor.execute(
"INSERT INTO captcha_cache (sitekey, pageurl, solution) "
"VALUES (%s, %s, %s)",
(sitekey, pageurl, solution)
)
conn.commit()
return {"solution": solution} if solution else {"error": "SOLVE_FAILED"}
finally:
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
O
pg_advisory_lock(sem otry_) bloqueia a chamada até o worker que já está resolvendo terminar — é isso que faz o segundo worker esperar em vez de disparar outra resolução para a mesma combinação desitekeyepageurl.
Problemas comuns e como resolver
| Problema | Causa | Correção |
|---|---|---|
| Colisões na chave de desduplicação | Hash muito curto ou parâmetros ausentes | Inclua todos os parâmetros específicos do CAPTCHA na chave; aumente o tamanho do hash |
| Worker em espera nunca recebe resposta | O worker responsável travou ou caiu antes de terminar | O TTL do estado solving expira sozinho (180 s) |
| Resultado em cache ficou obsoleto | Token expirou, mas o cache ainda está válido | Configure o TTL do cache menor que a vida útil do token (60 s para reCAPTCHA) |
Condição de corrida no set |
Dois workers verificam a chave ao mesmo tempo | Use SET NX (set-if-not-exists) para garantir aquisição atômica do lock |
Antes de considerar o problema resolvido, confirme:
- O TTL de
solvingé maior que o tempo médio de resolução do tipo de CAPTCHA mais lento da sua fila. - O TTL do cache de resultado é menor que a validade do token (60 s é seguro para reCAPTCHA e Turnstile).
- O
SET NX(oupg_try_advisory_lock) está realmente em uso — sem ele, a condição de corrida volta a existir mesmo com a chave certa.
Como medir o ganho da desduplicação
Sem números, ninguém sabe se a camada de desduplicação está valendo a manutenção. Acompanhe o quanto ela está economizando:
def track_dedup_stats(source):
"""Increment counters for dedup tracking."""
today = time.strftime("%Y-%m-%d")
r.hincrby(f"dedup:stats:{today}", source, 1)
r.expire(f"dedup:stats:{today}", 7 * 86400)
def get_dedup_report():
today = time.strftime("%Y-%m-%d")
stats = r.hgetall(f"dedup:stats:{today}")
total = sum(int(v) for v in stats.values())
saved = int(stats.get("dedup_cache", 0)) + int(stats.get("dedup_wait", 0))
return {
"total_requests": total,
"deduplicated": saved,
"savings_pct": f"{saved / total * 100:.1f}%" if total else "0%",
"breakdown": stats
}
Sinais de alerta ao ler esse relatório:
savings_pctperto de zero com muitos workers ativos → a chave provavelmente não está sendo montada de forma consistente entre eles.deduplicatedalto, mas latência ainda ruim → o gargalo está nowait_for_result, não na duplicação em si.
Perguntas frequentes
Antes de colocar isso em produção, vale esclarecer alguns pontos que aparecem com frequência:
Preciso de um banco de dados dedicado só para isso?
Não. Use o Redis que já mantém para cache ou fila, ou, se prefere não somar mais uma peça de infraestrutura, o PostgreSQL que provavelmente já roda em produção — os dois exemplos deste guia funcionam de forma independente.
O que acontece se dois workers chegarem no mesmo milissegundo?
Com Redis, SET NX garante que só um processo grava o estado solving. Com PostgreSQL, pg_try_advisory_lock resolve isso de forma atômica dentro do próprio banco, sem precisar de lógica extra na aplicação.
Faz sentido desduplicar CAPTCHAs de imagem?
Sim, mas a chave precisa incluir um hash do conteúdo da imagem, não só a URL da página. Imagens idênticas geram o mesmo texto de resposta, então a desduplicação funciona normalmente.
Preciso incluir o proxy usado na chave de desduplicação?
Não. O token retornado pela CaptchaAI vale independentemente de qual proxy foi usado para resolver o CAPTCHA. Incluir o proxy na chave só cria chaves diferentes para a mesma requisição e anula a desduplicação.
Próximas etapas
Pare de pagar duas vezes pela mesma resposta — obtenha sua chave de API da CaptchaAI e coloque a desduplicação no ar ainda hoje.
Guias relacionados:
- Gerenciando TTL de token no Redis
- Estado de sessão em workers distribuídos
- Recuperação de erros em lote na API