Tutoriales

Manejo de errores en callbacks de CaptchaAI: patrones de reintento y dead letter queue

La regla de oro con los callbacks de CaptchaAI es corta: nunca dependas solo de ellos. Un pingback te ahorra el sondeo constante, pero si tu servidor está caído, devuelve un error o agota el tiempo de espera cuando CaptchaAI entrega el resultado, esa resolución puede perderse. Para evitarlo montamos tres capas de resiliencia:

  • Sondeo alternativo — recupera las tareas cuyo callback nunca llegó.
  • Dead letter queue — conserva los resultados que fallan al procesarse.
  • Manejador idempotente — evita procesar dos veces la misma entrega.

¿Qué puede fallar en la entrega de un callback?

Piensa en un worker que resuelve reCAPTCHA v2 para el QA de tus checkouts: si un despliegue reinicia el servidor mientras CaptchaAI entrega resultados, esas resoluciones se pierden y hay que reenviarlas. Estos son los fallos habituales:

Modo de fallo Síntoma Consecuencia
Servidor caído CaptchaAI recibe conexión rechazada La resolución no se entrega
El servidor devuelve 5xx CaptchaAI recibe una respuesta de error Puede no reintentar (según la implementación)
Tiempo de espera de red La conexión de CaptchaAI se queda colgada Resolución potencialmente perdida
El manejador se cae La solicitud se acepta pero el resultado no se guarda La resolución se pierde en silencio

En todos los casos:

  • El resultado existe en CaptchaAI, pero no siempre llega a tu sistema.
  • Por eso ningún callback debe ser tu única vía de entrega.

Patrón 1: callback con sondeo alternativo

Acepta el callback cuando llega y sondea las tareas que no reciben respuesta dentro de un tiempo de espera: el callback es el camino rápido y el sondeo, la red de seguridad.

En Python, con Flask y un hilo de sondeo en segundo plano:

import os
import time
import threading
import requests
from flask import Flask, request

app = Flask(__name__)
API_KEY = os.environ["CAPTCHAAI_API_KEY"]

# Track task state
pending_tasks = {}  # task_id -> {"submitted_at": timestamp, "status": "pending"}
results = {}
lock = threading.Lock()


def submit_captcha(sitekey, pageurl, callback_url):
    """Submit with callback, but track for fallback polling."""
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "pingback": callback_url,
        "json": 1
    })
    data = resp.json()

    if data.get("status") == 1:
        task_id = data["request"]
        with lock:
            pending_tasks[task_id] = {
                "submitted_at": time.time(),
                "status": "pending"
            }
        return task_id
    return None


@app.route("/callback")
def captcha_callback():
    """Primary result delivery — CaptchaAI sends results here."""
    task_id = request.args.get("id")
    solution = request.args.get("code")

    with lock:
        results[task_id] = solution
        pending_tasks.pop(task_id, None)

    return "OK", 200


def fallback_poller():
    """Poll for any tasks that missed their callback."""
    while True:
        time.sleep(30)  # Check every 30 seconds

        with lock:
            stale_tasks = [
                tid for tid, info in pending_tasks.items()
                if time.time() - info["submitted_at"] > 120  # 2 min callback timeout
                and info["status"] == "pending"
            ]

        for task_id in stale_tasks:
            resp = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": API_KEY,
                "action": "get",
                "id": task_id,
                "json": 1
            })
            data = resp.json()

            if data.get("status") == 1:
                with lock:
                    results[task_id] = data["request"]
                    pending_tasks.pop(task_id, None)
                print(f"Fallback poll recovered: {task_id}")
            elif data.get("request") != "CAPCHA_NOT_READY":
                # Permanent error — remove from pending
                with lock:
                    pending_tasks.pop(task_id, None)
                print(f"Task failed: {task_id} — {data.get('request')}")


# Start fallback poller in background
poller_thread = threading.Thread(target=fallback_poller, daemon=True)
poller_thread.start()

La misma lógica en Node.js con Express y Axios:

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

const app = express();
const API_KEY = process.env.CAPTCHAAI_API_KEY;

const pendingTasks = new Map(); // taskId -> { submittedAt, status }
const results = new Map();

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

  if (resp.data.status === 1) {
    const taskId = resp.data.request;
    pendingTasks.set(taskId, {
      submittedAt: Date.now(),
      status: "pending",
    });
    return taskId;
  }
  return null;
}

// Primary callback endpoint
app.get("/callback", (req, res) => {
  const taskId = req.query.id;
  const solution = req.query.code;

  results.set(taskId, solution);
  pendingTasks.delete(taskId);

  res.sendStatus(200);
});

// Fallback poller
setInterval(async () => {
  const now = Date.now();
  const staleTasks = [];

  for (const [taskId, info] of pendingTasks) {
    if (now - info.submittedAt > 120000 && info.status === "pending") {
      staleTasks.push(taskId);
    }
  }

  for (const taskId of staleTasks) {
    try {
      const resp = await axios.get("https://ocr.captchaai.com/res.php", {
        params: { key: API_KEY, action: "get", id: taskId, json: 1 },
      });

      if (resp.data.status === 1) {
        results.set(taskId, resp.data.request);
        pendingTasks.delete(taskId);
        console.log(`Fallback recovered: ${taskId}`);
      } else if (resp.data.request !== "CAPCHA_NOT_READY") {
        pendingTasks.delete(taskId);
        console.log(`Task failed: ${taskId} — ${resp.data.request}`);
      }
    } catch (err) {
      console.error(`Poll error for ${taskId}: ${err.message}`);
    }
  }
}, 30000);

app.listen(3000);

Patrón 2: dead letter queue para resultados problemáticos

Cuando tu manejador procesa un resultado pero se topa con un error (la base de datos está caída, falla una validación), no descartes los datos: muévelos a una dead letter queue y reintenta cuando el problema de fondo esté resuelto.

En Python, escribiendo cada fallo a disco:

import json
import os
import time
from pathlib import Path

DEAD_LETTER_DIR = Path("dead_letter")
DEAD_LETTER_DIR.mkdir(exist_ok=True)


@app.route("/callback")
def captcha_callback_with_dlq():
    task_id = request.args.get("id")
    solution = request.args.get("code")

    try:
        # Attempt normal processing
        store_result(task_id, solution)
        return "OK", 200
    except Exception as e:
        # Processing failed — save to dead-letter queue
        dead_letter = {
            "task_id": task_id,
            "solution": solution,
            "error": str(e),
            "received_at": time.time()
        }
        dlq_path = DEAD_LETTER_DIR / f"{task_id}.json"
        dlq_path.write_text(json.dumps(dead_letter))

        print(f"DLQ: {task_id} — {e}")
        return "OK", 200  # Still return 200 to CaptchaAI


def reprocess_dead_letters():
    """Retry processing dead-letter items."""
    for dlq_file in DEAD_LETTER_DIR.glob("*.json"):
        item = json.loads(dlq_file.read_text())

        try:
            store_result(item["task_id"], item["solution"])
            dlq_file.unlink()  # Remove after successful processing
            print(f"DLQ reprocessed: {item['task_id']}")
        except Exception:
            pass  # Leave in DLQ for next retry

Y la versión equivalente en Node.js:

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

const DLQ_DIR = path.join(__dirname, "dead_letter");
if (!fs.existsSync(DLQ_DIR)) fs.mkdirSync(DLQ_DIR);

app.get("/callback-dlq", (req, res) => {
  const taskId = req.query.id;
  const solution = req.query.code;

  try {
    storeResult(taskId, solution);
    res.sendStatus(200);
  } catch (err) {
    // Save to dead-letter queue
    const deadLetter = {
      task_id: taskId,
      solution: solution,
      error: err.message,
      received_at: Date.now(),
    };

    fs.writeFileSync(
      path.join(DLQ_DIR, `${taskId}.json`),
      JSON.stringify(deadLetter)
    );

    console.log(`DLQ: ${taskId} — ${err.message}`);
    res.sendStatus(200); // Still acknowledge to CaptchaAI
  }
});

function reprocessDeadLetters() {
  const files = fs.readdirSync(DLQ_DIR).filter((f) => f.endsWith(".json"));

  for (const file of files) {
    const filePath = path.join(DLQ_DIR, file);
    const item = JSON.parse(fs.readFileSync(filePath, "utf8"));

    try {
      storeResult(item.task_id, item.solution);
      fs.unlinkSync(filePath);
      console.log(`DLQ reprocessed: ${item.task_id}`);
    } catch (err) {
      // Leave in DLQ
    }
  }
}

// Retry DLQ every 5 minutes
setInterval(reprocessDeadLetters, 300000);

Patrón 3: manejador de callback idempotente

Un callback puede llegar más de una vez. Haz tu manejador idempotente para que procesar el mismo resultado dos veces no tenga efectos:

@app.route("/callback")
def idempotent_callback():
    task_id = request.args.get("id")
    solution = request.args.get("code")

    with lock:
        # Only process if not already handled
        if task_id in results:
            return "OK", 200  # Already processed — skip silently

        results[task_id] = solution
        pending_tasks.pop(task_id, None)

    return "OK", 200

Qué patrón elegir según tu escenario

  • Volumen bajo con caídas ocasionales: callback con sondeo alternativo.
  • Alto volumen con posibles cortes de la base de datos: dead letter queue.
  • Varios consumidores procesan el mismo resultado: manejador idempotente.
  • Sistema en producción con SLA: los tres combinados.

Diagnóstico de problemas frecuentes

  • El sondeo alternativo reprocesa tareas ya entregadas. Hay una carrera entre el callback y el sondeador; añade la comprobación de idempotencia sobre results.
  • La DLQ crece sin procesarse. El reprocesador no corre o falla; revisa sus logs y confirma que la BD ya esté sana.
  • El callback devuelve 200 pero el resultado se pierde. El manejador se cae después de responder; procesa antes de responder o usa la DLQ.
  • Demasiadas solicitudes de sondeo alternativo. Hay muchas tareas obsoletas; sube el umbral de timeout del callback y revisa el uptime.

Preguntas frecuentes

¿Cómo evito procesar dos veces la misma resolución?

Guarda cada resultado por su task_id y comprueba si ya existe antes de procesarlo, como en el Patrón 3. La comprobación sobre results evita cobros o inserciones repetidas cuando un callback llega dos veces o coincide con el sondeo.

¿Qué código HTTP debe devolver mi endpoint de callback?

Siempre 200. Un 4xx o 5xx no ayuda, porque CaptchaAI puede no reintentar la entrega. Acepta con un 200 OK y gestiona los fallos por dentro con la DLQ o el sondeo alternativo.

¿Dónde conviene guardar la dead letter queue en producción?

En disco basta para volúmenes bajos, pero en producción usa un almacén compartido y duradero: una tabla en tu base de datos, una lista en Redis o una cola gestionada. Así varios workers ven la misma DLQ y sobrevive a un reinicio.

¿El sondeo alternativo consume threads o saldo adicional?

No. Un thread se ocupa mientras el CAPTCHA está en resolución y se libera al terminar; consultar el resultado con res.php no abre un thread nuevo ni cobra por solicitud. CaptchaAI factura por thread concurrente, con resoluciones ilimitadas por thread.

Próximos pasos

Monta una entrega de resultados a prueba de fallos: obtén tu clave API de CaptchaAI e implementa los tres patrones. Amplía con estas guías:

Artículos relacionados

Los comentarios están deshabilitados para este artículo.