DevOps & Scaling

Planejamento de recuperação de desastres para pipelines de solução de CAPTCHA

Quantos minutos de fila represada sua operação aguenta antes que isso vire um problema para o cliente? É essa a pergunta que qualquer time que resolve CAPTCHA em produção precisa responder antes da falha, não durante ela. Um worker que reinicia sozinho, uma partição de rede ou uma chave de API revogada não deveriam custar tarefas perdidas — mas custam, sempre que a fila vive só na memória do processo. Recuperação de desastres (DR) é o conjunto de decisões de arquitetura, dados e processo que transforma uma queda de infraestrutura em um incidente de minutos, em vez de uma manhã inteira reprocessando tarefas na mão.

Metas de RPO, RTO e MTTR para o seu pipeline CAPTCHA

Antes de escrever qualquer runbook, defina três números. Eles orientam toda decisão de arquitetura que vem a seguir — do tipo de fila escolhido ao tempo de propagação do DNS no failover.

Métrica Definição Alvo do pipeline CAPTCHA
RPO (objetivo de ponto de recuperação) Máxima perda de dados tolerável < 5 minutos de tarefas na fila
RTO (objetivo de tempo de recuperação) Tempo máximo para restaurar o serviço < 15 minutos
MTTR (tempo médio para recuperação) Tempo médio de recuperação < 10 minutos

Três práticas tornam essas metas reais:

  • Separe o que pode ser reprocessado depois do que exige recuperação imediata — nem toda tarefa enfileirada tem o mesmo custo de perda.
  • Atribua um RPO e um RTO por componente do pipeline (fila, workers, chamada à API), em vez de tratar o sistema inteiro como uma caixa única.
  • Documente por escrito quem aciona o failover, quem confirma que a recuperação funcionou e quando é seguro voltar ao roteamento normal — a decisão não pode depender de quem estava de plantão naquele dia.

Times que rodam workers na região sa-east-1 (São Paulo) da AWS por causa da latência até o Brasil normalmente definem us-east-1 como secundária. Isso reduz o RTT no dia a dia, mas o failover entre continentes tem um efeito direto na meta: o RTO da região secundária inclui o tempo de propagação de DNS e, quase sempre, um cold start de workers que nunca processaram tráfego real. Meça esse número antes de assumir que ele cabe nos 15 minutos da tabela acima.

Cenários de falha que toda pipeline de CAPTCHA deve mapear

Cinco causas cobrem a maior parte dos incidentes reais em produção. Mapeie a resposta para cada uma antes de precisar dela:

Scenario 1: Worker crash         → Restart workers, replay queue
Scenario 2: Queue data loss      → Restore from persistent backup
Scenario 3: Network partition    → Failover to secondary region
Scenario 4: API key compromised  → Rotate key, update workers
Scenario 5: Config corruption    → Rollback to last known good

Nenhum desses cinco cenários exige um plano exótico — exige um plano escrito, testado e fácil de seguir sob pressão.

Por que a fila em memória não sobrevive a uma queda

Nunca resolva CAPTCHAs a partir de uma fila que existe só na RAM do processo. Um worker reiniciado — por deploy, por OOM kill, por queda de contêiner — apaga silenciosamente qualquer tarefa que ainda não tenha sido persistida. A correção é sempre a mesma: gravar a tarefa em disco antes de processá-la, não depois.

Fila de tarefas persistente em Python com SQLite

O exemplo abaixo grava cada tarefa em SQLite antes de processá-la e recupera automaticamente qualquer uma que tenha ficado travada em processing quando o processo caiu:

import os
import json
import time
import sqlite3
import threading
import requests
from datetime import datetime

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class PersistentTaskQueue:
    """SQLite-backed task queue that survives crashes."""

    def __init__(self, db_path="captcha_tasks.db"):
        self.db_path = db_path
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.lock = threading.Lock()
        self._init_db()

    def _init_db(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS tasks (
                id TEXT PRIMARY KEY,
                payload TEXT NOT NULL,
                status TEXT DEFAULT 'pending',
                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                started_at TEXT,
                completed_at TEXT,
                result TEXT,
                attempts INTEGER DEFAULT 0
            )
        """)
        self.conn.commit()

    def enqueue(self, task_id, payload):
        with self.lock:
            self.conn.execute(
                "INSERT INTO tasks (id, payload) VALUES (?, ?)",
                (task_id, json.dumps(payload))
            )
            self.conn.commit()

    def dequeue(self):
        with self.lock:
            cursor = self.conn.execute(
                "SELECT id, payload FROM tasks "
                "WHERE status = 'pending' ORDER BY created_at LIMIT 1"
            )
            row = cursor.fetchone()
            if not row:
                return None

            task_id, payload = row
            self.conn.execute(
                "UPDATE tasks SET status = 'processing', "
                "started_at = ?, attempts = attempts + 1 WHERE id = ?",
                (datetime.utcnow().isoformat(), task_id)
            )
            self.conn.commit()
            return {"id": task_id, "payload": json.loads(payload)}

    def complete(self, task_id, result):
        with self.lock:
            self.conn.execute(
                "UPDATE tasks SET status = 'completed', "
                "completed_at = ?, result = ? WHERE id = ?",
                (datetime.utcnow().isoformat(), json.dumps(result), task_id)
            )
            self.conn.commit()

    def fail(self, task_id, error):
        with self.lock:
            # Requeue if under retry limit
            cursor = self.conn.execute(
                "SELECT attempts FROM tasks WHERE id = ?", (task_id,)
            )
            row = cursor.fetchone()
            if row and row[0] < 3:
                self.conn.execute(
                    "UPDATE tasks SET status = 'pending' WHERE id = ?",
                    (task_id,)
                )
            else:
                self.conn.execute(
                    "UPDATE tasks SET status = 'failed', "
                    "result = ? WHERE id = ?",
                    (json.dumps({"error": error}), task_id)
                )
            self.conn.commit()

    def recover_stale(self, timeout_seconds=600):
        """Reset tasks stuck in 'processing' after a crash."""
        with self.lock:
            cutoff = datetime.utcnow().timestamp() - timeout_seconds
            self.conn.execute(
                "UPDATE tasks SET status = 'pending' "
                "WHERE status = 'processing' "
                "AND started_at < datetime(?, 'unixepoch')",
                (cutoff,)
            )
            count = self.conn.total_changes
            self.conn.commit()
            return count

    @property
    def stats(self):
        cursor = self.conn.execute(
            "SELECT status, COUNT(*) FROM tasks GROUP BY status"
        )
        return dict(cursor.fetchall())


# On startup: recover tasks that were processing during a crash
queue = PersistentTaskQueue()
recovered = queue.recover_stale(timeout_seconds=600)
print(f"Recovered {recovered} stale tasks after restart")

Checkpoint e recuperação em Node.js

Em Node.js, a mesma ideia funciona com checkpoints em disco: grave o estado antes, durante e depois do lote, para que o próximo processo saiba exatamente de onde retomar em vez de reprocessar tudo do zero.

const axios = require("axios");
const fs = require("fs");

const API_KEY = process.env.CAPTCHAAI_API_KEY;

class DisasterRecoveryManager {
  constructor(checkpointDir = "./dr-checkpoints") {
    this.checkpointDir = checkpointDir;
    if (!fs.existsSync(checkpointDir)) {
      fs.mkdirSync(checkpointDir, { recursive: true });
    }
  }

  checkpoint(label, data) {
    const filename = `${this.checkpointDir}/${label}-${Date.now()}.json`;
    fs.writeFileSync(filename, JSON.stringify(data, null, 2));
    this.pruneOldCheckpoints(label, 10); // Keep last 10
    return filename;
  }

  restore(label) {
    const files = fs.readdirSync(this.checkpointDir)
      .filter((f) => f.startsWith(label) && f.endsWith(".json"))
      .sort()
      .reverse();

    if (files.length === 0) return null;
    const latest = fs.readFileSync(
      `${this.checkpointDir}/${files[0]}`, "utf8"
    );
    return JSON.parse(latest);
  }

  pruneOldCheckpoints(label, keep) {
    const files = fs.readdirSync(this.checkpointDir)
      .filter((f) => f.startsWith(label) && f.endsWith(".json"))
      .sort();

    while (files.length > keep) {
      const old = files.shift();
      fs.unlinkSync(`${this.checkpointDir}/${old}`);
    }
  }

  async healthCheck() {
    try {
      const resp = await axios.get("https://ocr.captchaai.com/res.php", {
        params: { key: API_KEY, action: "getbalance", json: 1 },
        timeout: 10000,
      });
      return {
        healthy: resp.data.status === 1,
        balance: parseFloat(resp.data.request || 0),
      };
    } catch (err) {
      return { healthy: false, error: err.message };
    }
  }
}

class ResilientSolver {
  constructor() {
    this.dr = new DisasterRecoveryManager();
    this.pendingTasks = [];
  }

  async solveBatch(tasks) {
    // Checkpoint before starting
    this.dr.checkpoint("batch-pending", {
      tasks,
      startedAt: new Date().toISOString(),
    });

    const results = [];
    for (const task of tasks) {
      try {
        const result = await this.solveSingle(task);
        results.push({ taskId: task.id, ...result });
      } catch (err) {
        results.push({ taskId: task.id, error: err.message });
      }

      // Checkpoint progress periodically
      if (results.length % 10 === 0) {
        this.dr.checkpoint("batch-progress", { results, remaining: tasks.length - results.length });
      }
    }

    // Final checkpoint
    this.dr.checkpoint("batch-complete", { results });
    return results;
  }

  async recover() {
    // Check for incomplete batch
    const progress = this.dr.restore("batch-progress");
    const pending = this.dr.restore("batch-pending");

    if (progress) {
      const completedIds = new Set(progress.results.map((r) => r.taskId));
      const remaining = pending?.tasks.filter((t) => !completedIds.has(t.id));
      console.log(
        `Recovering: ${progress.results.length} done, ${remaining?.length || 0} remaining`
      );
      return remaining || [];
    }

    if (pending) {
      console.log(`Recovering full batch: ${pending.tasks.length} tasks`);
      return pending.tasks;
    }

    return [];
  }

  async solveSingle(task) {
    const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
      params: {
        key: API_KEY,
        method: "userrecaptcha",
        googlekey: task.sitekey,
        pageurl: task.pageurl,
        json: 1,
      },
    });

    if (resp.data.status !== 1) throw new Error(resp.data.request);

    const captchaId = resp.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) return { solution: poll.data.request };
      if (poll.data.request !== "CAPCHA_NOT_READY")
        throw new Error(poll.data.request);
    }
    throw new Error("TIMEOUT");
  }
}

// Start with recovery check
const solver = new ResilientSolver();
solver.recover().then((remaining) => {
  if (remaining.length > 0) {
    console.log(`Resuming ${remaining.length} tasks from checkpoint`);
    solver.solveBatch(remaining);
  }
});

Runbook de recuperação: do alerta ao post-mortem

Um runbook só funciona se alguém sem contexto do incidente conseguir segui-lo às 3h da manhã. Use esta estrutura como ponto de partida e adapte às suas próprias fontes de alerta:

RUNBOOK: CAPTCHA Pipeline Recovery
====================================

1. DETECT
   - Alert fires: [PagerDuty / Slack / Email]
   - Symptom: [Queue growing / Workers offline / Error spike]

2. ASSESS
   - Check worker health: curl http://workers/health
   - Check API status: GET /res.php?action=getbalance
   - Check queue depth: SELECT COUNT(*) FROM tasks WHERE status='pending'

3. RECOVER
   If: Workers crashed
     → Restart worker containers: docker-compose up -d workers
     → Run stale task recovery: recovery.py --recover-stale

   If: Network partition
     → Failover to secondary region
     → Update DNS or load balancer routing

   If: API key compromised
     → Generate new key at captchaai.com
     → Update secret store
     → Rolling restart workers

4. VERIFY
   - Confirm solve rate > 90%
   - Confirm queue draining
   - Confirm no duplicate solves

5. POST-MORTEM
   - Document root cause
   - Update runbook if needed

Como testar o plano sem esperar por uma queda real

Um runbook não testado é só uma suposição bem escrita. Rode um exercício controlado — um "game day" — pelo menos uma vez por trimestre:

  1. Escolha uma janela de baixo tráfego e avise o time com antecedência.
  2. Force o cenário de verdade: mate o processo do worker, derrube a conexão com a região primária ou revogue uma chave de teste.
  3. Cronometre o RTO real e compare com a meta da tabela de RPO/RTO/MTTR.
  4. Registre toda divergência entre o que o runbook diz e o que realmente aconteceu — é ali que mora o próximo incidente evitável.

Erros comuns e como corrigir

Problema Causa Correção
Tarefas perdidas durante a falha Fila somente em memória Use fila persistente (SQLite, Redis com AOF)
Resolve duplicado após a recuperação Tarefas obsoletas reprocessadas sem deduplicação Adicione chaves de idempotência; verifique se a tarefa já foi resolvida antes de reenviar
Recuperação leva mais que o RTO Backup do banco de dados muito antigo Aumente a frequência dos checkpoints
Failover para a região errada TTL do DNS muito alto Reduza o TTL para 60 s antes de failovers planejados
Alerta dispara, mas ninguém confirma a causa Runbook não define quem assume a triagem Nomeie um responsável por turno antes do próximo game day

Perguntas frequentes

Com que frequência devo fazer checkpoint das tarefas?

A cada 5 a 10 tarefas concluídas ou a cada 30 segundos — o que ocorrer primeiro. Checkpoints mais frequentes reduzem o RPO, mas aumentam a sobrecarga de I/O.

SQLite ou Redis: qual escolher para persistir a fila de CAPTCHA?

SQLite para um único nó (mais simples, sem infraestrutura extra). Redis com persistência AOF quando o pipeline roda distribuído em vários workers.

Preciso de mais threads no plano da CaptchaAI para sustentar um failover?

Depende do tráfego que a região secundária precisa absorver sozinha. Se ela assume 100% da carga durante o failover, precisa do mesmo número de threads que a primária — dimensione com folga, porque um plano BASIC (US$ 15/mês, 5 threads) não sustenta o mesmo pico que um ADVANCE (US$ 90/mês, 50 threads).

Como testo o runbook sem impactar produção?

Use dados fictícios contra um endpoint de staging e simule a falha de infraestrutura (worker, rede, chave) em vez da falha do CAPTCHA em si — o objetivo é validar o processo de recuperação, não repetir o teste de resolução.

A meta de RPO de 5 minutos serve para qualquer volume de fila?

Não necessariamente. Em filas muito grandes, 5 minutos de tarefas em memória podem ser milhares de itens; ajuste o RPO por volume real e não copie o número de outro time sem medir o seu.

Próximos passos

Planeje para o pior cenário — crie sua conta e gere a chave de API da CaptchaAI e trate a recuperação de desastres como parte do pipeline desde o primeiro deploy, não como item de backlog.

Guias relacionados:

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