Tutorials

Orçamento de erros para confiabilidade na resolução de CAPTCHA

Um orçamento de erros responde a uma pergunta prática: quantas falhas de resolução de CAPTCHA sua automação pode acumular antes de estourar o SLO combinado com o time de produto? Sem esse número, uma queda de 95% para 94,2% na taxa de sucesso vira debate de plantão — "isso é grave ou é ruído normal?" Com o orçamento calculado, a resposta fica objetiva: faltam X falhas para a janela estourar, e você decide com dado na mão se segue automatizando, investiga ou pausa novas implantações.

A prática vem do SRE (site reliability engineering) e encaixa bem em pipelines de CAPTCHA porque o "sucesso" já é probabilístico por natureza — reCAPTCHA v2, Turnstile e CAPTCHAs de imagem nunca fecham em 100%, então tratar cada falha isolada como incidente esgota o time rápido. Este guia mostra como definir o SLO, calcular o orçamento, ler a taxa de queima (burn rate) e automatizar o alerta, com rastreadores completos em Python e JavaScript.

O que é um orçamento de erros na resolução de CAPTCHA

Quatro conceitos sustentam o cálculo:

Conceito Definição Exemplo
SLO Taxa de sucesso desejada 95% de soluções bem-sucedidas
Orçamento de erros Taxa de falha permitida 5% do total de soluções pode falhar
Taxa de queima (burn rate) Velocidade de consumo do orçamento 2× significa orçamento esgotado na metade da janela
Janela Período de medição 24 horas ou 7 dias, em janela móvel

Com SLO de 95% numa janela de 24 horas e 10.000 soluções, o orçamento é de 500 falhas. Passou disso, pare novas implantações ou mudanças arriscadas até a taxa se recuperar.

Isso é diferente de tratar cada erro como incidente isolado. Times que rodam QA de checkout com reCAPTCHA v2 e Turnstile em staging usam esse número para decidir se uma alteração no fluxo de login pode seguir para produção ou precisa de mais uma rodada de testes. Ao registrar esses eventos para auditoria, vale lembrar da LGPD sobre retenção e anonimização de log — guarde contadores agregados, não o payload completo de cada tentativa.

Trate tipos de CAPTCHA diferentes como orçamentos diferentes: reCAPTCHA v2, Turnstile e CAPTCHAs de imagem têm taxas de erro distintas, e um orçamento único esconde qual tipo está realmente com problema.

Rastreador de orçamento de erros em Python

O rastreador abaixo mede o SLO numa janela deslizante, calcula o burn rate a cada evento e dispara callbacks quando o status muda — de healthy para warning, critical ou exhausted. Ligue a chamada budget.record(...) direto no seu wrapper de in.php/res.php:

import time
import threading
from dataclasses import dataclass, field
from collections import deque
from enum import Enum

API_KEY = "YOUR_API_KEY"


class BudgetStatus(Enum):
    HEALTHY = "healthy"          # Budget > 50% remaining
    WARNING = "warning"          # Budget 10-50% remaining
    CRITICAL = "critical"        # Budget < 10% remaining
    EXHAUSTED = "exhausted"      # Budget depleted


@dataclass
class SLOConfig:
    """Service Level Objective configuration."""
    target_success_rate: float = 0.95  # 95%
    window_seconds: int = 86400        # 24 hours
    warning_threshold: float = 0.50    # Alert at 50% budget
    critical_threshold: float = 0.10   # Alert at 10% budget


@dataclass
class ErrorBudgetEvent:
    timestamp: float
    success: bool


class ErrorBudgetTracker:
    """Tracks error budget consumption for CAPTCHA solving."""

    def __init__(self, config: SLOConfig = SLOConfig()):
        self.config = config
        self._events: deque[ErrorBudgetEvent] = deque()
        self._lock = threading.Lock()
        self._callbacks: dict[BudgetStatus, list[callable]] = {
            status: [] for status in BudgetStatus
        }
        self._last_status = BudgetStatus.HEALTHY

    def on_status_change(self, status: BudgetStatus, callback: callable):
        """Register a callback for status transitions."""
        self._callbacks[status].append(callback)

    def record(self, success: bool):
        """Record a solve attempt."""
        now = time.monotonic()
        event = ErrorBudgetEvent(timestamp=now, success=success)

        with self._lock:
            self._events.append(event)
            self._prune(now)
            new_status = self._compute_status()

            if new_status != self._last_status:
                self._last_status = new_status
                for cb in self._callbacks.get(new_status, []):
                    try:
                        cb(self.get_report())
                    except Exception as e:
                        print(f"[BUDGET] Callback error: {e}")

    def _prune(self, now: float):
        """Remove events outside the window."""
        cutoff = now - self.config.window_seconds
        while self._events and self._events[0].timestamp < cutoff:
            self._events.popleft()

    def _compute_status(self) -> BudgetStatus:
        remaining = self.remaining_fraction
        if remaining <= 0:
            return BudgetStatus.EXHAUSTED
        if remaining < self.config.critical_threshold:
            return BudgetStatus.CRITICAL
        if remaining < self.config.warning_threshold:
            return BudgetStatus.WARNING
        return BudgetStatus.HEALTHY

    @property
    def total_events(self) -> int:
        with self._lock:
            return len(self._events)

    @property
    def success_count(self) -> int:
        with self._lock:
            return sum(1 for e in self._events if e.success)

    @property
    def failure_count(self) -> int:
        with self._lock:
            return sum(1 for e in self._events if not e.success)

    @property
    def current_success_rate(self) -> float:
        total = self.total_events
        return self.success_count / total if total > 0 else 1.0

    @property
    def error_budget_total(self) -> float:
        """Total allowed failures in the window."""
        total = self.total_events
        if total == 0:
            return 0
        return total * (1 - self.config.target_success_rate)

    @property
    def error_budget_remaining(self) -> float:
        """Remaining failure allowance."""
        return max(0, self.error_budget_total - self.failure_count)

    @property
    def remaining_fraction(self) -> float:
        """Fraction of error budget remaining (0.0 to 1.0)."""
        budget = self.error_budget_total
        if budget <= 0:
            return 1.0 if self.failure_count == 0 else 0.0
        return max(0, self.error_budget_remaining / budget)

    @property
    def burn_rate(self) -> float:
        """How fast the budget is being consumed (1.0 = normal, 2.0 = 2× faster)."""
        total = self.total_events
        if total == 0:
            return 0.0
        expected_failures = total * (1 - self.config.target_success_rate)
        if expected_failures == 0:
            return 0.0
        return self.failure_count / expected_failures

    def get_report(self) -> dict:
        return {
            "status": self._last_status.value,
            "slo_target": self.config.target_success_rate,
            "current_rate": round(self.current_success_rate, 4),
            "total_events": self.total_events,
            "successes": self.success_count,
            "failures": self.failure_count,
            "budget_total": round(self.error_budget_total, 1),
            "budget_remaining": round(self.error_budget_remaining, 1),
            "budget_remaining_pct": round(self.remaining_fraction * 100, 1),
            "burn_rate": round(self.burn_rate, 2),
        }


# --- Integration with solver ---

budget = ErrorBudgetTracker(SLOConfig(
    target_success_rate=0.95,
    window_seconds=3600,  # 1-hour window for demo
))

# Register alerts
budget.on_status_change(BudgetStatus.WARNING, lambda r:
    print(f"[ALERT] Budget warning: {r['budget_remaining_pct']}% remaining"))

budget.on_status_change(BudgetStatus.CRITICAL, lambda r:
    print(f"[ALERT] Budget critical: {r['budget_remaining_pct']}% remaining"))

budget.on_status_change(BudgetStatus.EXHAUSTED, lambda r:
    print(f"[ALERT] Budget EXHAUSTED — throttle new requests"))


def solve_with_budget(params: dict) -> str:
    """Solve CAPTCHA while tracking error budget."""
    import requests

    if budget._last_status == BudgetStatus.EXHAUSTED:
        raise RuntimeError("Error budget exhausted — solving paused")

    try:
        submit_params = {**params, "key": API_KEY, "json": 1}
        resp = requests.post(
            "https://ocr.captchaai.com/in.php", data=submit_params, timeout=30
        ).json()
        if resp.get("status") != 1:
            budget.record(False)
            raise RuntimeError(f"Submit: {resp.get('request')}")

        task_id = resp["request"]
        start = time.monotonic()
        while time.monotonic() - start < 180:
            time.sleep(5)
            poll = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": API_KEY, "action": "get", "id": task_id, "json": 1,
            }, timeout=15).json()

            if poll.get("request") == "CAPCHA_NOT_READY":
                continue
            if poll.get("status") == 1:
                budget.record(True)
                return poll["request"]

            budget.record(False)
            raise RuntimeError(f"Solve: {poll.get('request')}")

        budget.record(False)
        raise RuntimeError("Timeout")

    except Exception:
        budget.record(False)
        raise


# Usage
for i in range(100):
    try:
        token = solve_with_budget({
            "method": "turnstile",
            "sitekey": "0x4XXXXXXXXXXXXXXXXX",
            "pageurl": "https://example.com",
        })
    except RuntimeError as e:
        if "exhausted" in str(e):
            print(f"Stopped at iteration {i}")
            break

print(budget.get_report())

O mesmo rastreador em JavaScript

A versão em JavaScript segue a mesma lógica — janela deslizante, cálculo de burn rate e callbacks por mudança de status — usando campos privados de classe (#) em vez de um dataclass:

class ErrorBudgetTracker {
  #events = [];
  #config;
  #callbacks = {};

  constructor(config = {}) {
    this.#config = {
      targetRate: config.targetRate || 0.95,
      windowMs: config.windowMs || 3600_000,
      warningThreshold: config.warningThreshold || 0.5,
      criticalThreshold: config.criticalThreshold || 0.1,
    };
    this.lastStatus = "healthy";
  }

  on(status, callback) {
    this.#callbacks[status] = this.#callbacks[status] || [];
    this.#callbacks[status].push(callback);
  }

  record(success) {
    const now = Date.now();
    this.#events.push({ time: now, success });
    this.#prune(now);

    const newStatus = this.#computeStatus();
    if (newStatus !== this.lastStatus) {
      this.lastStatus = newStatus;
      for (const cb of this.#callbacks[newStatus] || []) {
        cb(this.report());
      }
    }
  }

  #prune(now) {
    const cutoff = now - this.#config.windowMs;
    while (this.#events.length && this.#events[0].time < cutoff) {
      this.#events.shift();
    }
  }

  #computeStatus() {
    const frac = this.remainingFraction;
    if (frac <= 0) return "exhausted";
    if (frac < this.#config.criticalThreshold) return "critical";
    if (frac < this.#config.warningThreshold) return "warning";
    return "healthy";
  }

  get total() { return this.#events.length; }
  get successes() { return this.#events.filter((e) => e.success).length; }
  get failures() { return this.#events.filter((e) => !e.success).length; }
  get currentRate() { return this.total ? this.successes / this.total : 1; }

  get budgetTotal() {
    return this.total * (1 - this.#config.targetRate);
  }

  get budgetRemaining() {
    return Math.max(0, this.budgetTotal - this.failures);
  }

  get remainingFraction() {
    const bt = this.budgetTotal;
    if (bt <= 0) return this.failures === 0 ? 1 : 0;
    return Math.max(0, this.budgetRemaining / bt);
  }

  get burnRate() {
    const expected = this.total * (1 - this.#config.targetRate);
    return expected > 0 ? this.failures / expected : 0;
  }

  report() {
    return {
      status: this.lastStatus,
      currentRate: Math.round(this.currentRate * 10000) / 10000,
      total: this.total,
      failures: this.failures,
      budgetRemainingPct: Math.round(this.remainingFraction * 1000) / 10,
      burnRate: Math.round(this.burnRate * 100) / 100,
    };
  }
}

// Usage
const budget = new ErrorBudgetTracker({ targetRate: 0.95, windowMs: 3600_000 });

budget.on("warning", (r) => console.log(`[WARN] ${r.budgetRemainingPct}% budget left`));
budget.on("exhausted", (r) => console.log("[ALERT] Budget exhausted!"));

// Record results from your solver
budget.record(true);   // success
budget.record(false);  // failure
console.log(budget.report());

Taxa de queima: quando o orçamento está acelerando

Taxa de queima Significado Ação
< 1,0 Consumindo mais devagar do que o esperado Nenhuma ação necessária
1,0 No ritmo para esgotar exatamente no fim da janela Monitore de perto
2,0 Orçamento esgotado na metade da janela Investigue e desacelere
5,0+ Consumo acelerado do orçamento Pause soluções não críticas

Um worker em sa-east-1 (São Paulo) acumulando timeouts de rede costuma aparecer primeiro como burn rate alto — antes mesmo de a taxa de sucesso cair de forma visível. Configure o alerta de warning para abrir um ticket, e o de critical/exhausted para pausar automaticamente as rotas não essenciais.

Solução de problemas comuns

Sintomas comuns depois que o rastreador entra em produção:

Problema Causa provável Correção
Orçamento esgota rápido demais SLO apertado demais para as condições reais Meça a taxa de sucesso atual e defina o SLO com base nela, não numa meta arbitrária
Orçamento nunca é consumido SLO generoso demais Aperte o SLO aos poucos para forçar ganhos reais de confiabilidade
Status oscila entre estados Janela curta demais Use uma janela mais longa (24h em vez de 1h)
Burn rate enganoso em volume baixo Poucos eventos distorcem o cálculo Exija uma contagem mínima de eventos antes de calcular o burn rate
Uso de memória do rastreador cresce Eventos não são removidos da janela Confirme que _prune/#prune roda em todo record()

Perguntas frequentes

Qual é um SLO realista para resolver CAPTCHA?

Depende do tipo. O reCAPTCHA v2 costuma girar na faixa de 90–95% entre implementações automatizadas; o Turnstile tende a ficar mais estável; CAPTCHAs de imagem variam conforme a fonte. Meça sua taxa de sucesso atual por uma semana e defina o SLO 2–3 pontos abaixo dessa linha de base — isso cria um orçamento que significa algo, não uma meta chutada.

O que fazer quando o orçamento de erros zera?

Escale a resposta em vez de ignorar. Da menos à mais agressiva: alerte o time, limite novas requisições, pause soluções não essenciais e, em último caso, migre temporariamente para tratamento manual do CAPTCHA. Nunca deixe o EXHAUSTED passar em silêncio — é exatamente o sinal que o orçamento existe para dar.

Esse orçamento substitui os alertas de saldo e status da CaptchaAI?

Não. O painel da CaptchaAI mostra saldo e o status de cada tarefa enviada à API — informação por requisição. O orçamento de erros é uma camada de SLO que você constrói no seu lado, agregando esses resultados ao longo do tempo para decidir sobre alertas e pausas. As duas coisas se complementam.

Quantos eventos preciso registrar antes de confiar no burn rate?

Na prática, algo entre 30 e 50 eventos na janela. Com menos que isso, um único erro isolado pode disparar burn rate de 5× ou mais sem que nada de fato esteja errado. Abaixo desse piso, olhe para o número bruto de falhas em vez do burn rate calculado.

Faz sentido calcular o orçamento junto para tipos beta como CaptchaFox e Lemin?

Não misture. CaptchaFox (beta), Friendly Captcha (beta) e Lemin (beta) ainda não têm taxa de sucesso publicada, então incluí-los no mesmo orçamento de tipos GA como reCAPTCHA ou Turnstile distorce o burn rate dos dois lados. Mantenha um orçamento separado — mesmo que simples — para os tipos beta, e revise o SLO deles conforme mais dados chegarem.

Artigos relacionados

Próximas etapas

Pare de adivinhar se a confiabilidade da sua resolução de CAPTCHA está dentro do esperado — obtenha sua chave de API da CaptchaAI e implemente o rastreamento de orçamento de erros no seu pipeline ainda hoje.

Guias relacionados:

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