Tutorials

Desduplicando solicitações de resolução CAPTCHA com bloqueio de banco de dados

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.
  • pageurl com 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 o try_) 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 de sitekey e pageurl.

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 (ou pg_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_pct perto de zero com muitos workers ativos → a chave provavelmente não está sendo montada de forma consistente entre eles.
  • deduplicated alto, mas latência ainda ruim → o gargalo está no wait_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:

Os comentários estão desativados para este artigo.