Tutoriales de API

Patrón circuit breaker para llamadas a la API CAPTCHA

Un circuit breaker es un interruptor con memoria: cuenta los fallos de una dependencia y, pasado cierto punto, deja de llamarla unos segundos en lugar de seguir insistiendo. Aplicado a la API de resolución de CAPTCHA, tus workers dejan de gastar tiempo y threads contra un servicio que ya sabes que no responde, y vuelven solos al recuperarse. Cabe en unas cincuenta líneas, sin librerías extra.

Conviene distinguirlo del reintento: este resuelve un fallo aislado, el breaker uno sostenido. Sin el segundo, cada tarea de tu cola repite los mismos reintentos contra el mismo endpoint degradado. Aquí lo montas en Python, lo replicas en Node.js, eliges umbrales y decides qué errores deben abrirlo.


Los tres estados del circuito

Toda la teoría es esta máquina de estados:

  1. Cerrado (closed): operación normal. Las solicitudes pasan y el breaker cuenta los fallos consecutivos.
  2. Abierto (open): se superó el umbral. Toda solicitud se rechaza al instante, sin salir a la red: fallas en microsegundos en vez de esperar al timeout.
  3. Semiabierto (half-open): pasado el tiempo de recuperación se deja pasar una solicitud de prueba. Si funciona, el circuito se cierra; si falla, se abre otra vez.

El estado abierto es el que justifica el patrón: las tareas se descartan o se encolan en milisegundos y liberan los threads para cuando el servicio vuelva.


Implementación en Python

Esta clase envuelve cualquier función, no solo la resolución de CAPTCHA. El threading.Lock es obligatorio si compartes el breaker entre hilos, el caso habitual con los threads de tu plan.

import time
import threading
import requests

SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"
API_KEY = "YOUR_API_KEY"


class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=60):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failure_count = 0
        self.last_failure_time = 0
        self.state = "closed"  # closed, open, half-open
        self._lock = threading.Lock()

    def call(self, func, *args, **kwargs):
        with self._lock:
            if self.state == "open":
                if time.time() - self.last_failure_time > self.recovery_timeout:
                    self.state = "half-open"
                    print("[circuit] State: half-open — testing one request")
                else:
                    remaining = self.recovery_timeout - (
                        time.time() - self.last_failure_time
                    )
                    raise CircuitOpenError(
                        f"Circuit open — retry in {remaining:.0f}s"
                    )

        try:
            result = func(*args, **kwargs)
            with self._lock:
                self.failure_count = 0
                if self.state == "half-open":
                    print("[circuit] State: closed — API recovered")
                self.state = "closed"
            return result
        except Exception as e:
            with self._lock:
                self.failure_count += 1
                self.last_failure_time = time.time()
                if self.failure_count >= self.failure_threshold:
                    self.state = "open"
                    print(
                        f"[circuit] State: open — "
                        f"{self.failure_count} failures"
                    )
            raise


class CircuitOpenError(Exception):
    pass


def solve_captcha(sitekey, page_url):
    resp = requests.post(SUBMIT_URL, data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": page_url,
        "json": "1",
    }, timeout=15)
    data = resp.json()
    if data["status"] != 1:
        raise Exception(f"Submit error: {data['request']}")

    task_id = data["request"]
    for _ in range(24):
        time.sleep(5)
        poll = requests.get(RESULT_URL, params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": "1",
        }, timeout=15).json()
        if poll["status"] == 1:
            return poll["request"]
        if poll["request"] != "CAPCHA_NOT_READY":
            raise Exception(f"Poll error: {poll['request']}")
    raise TimeoutError(f"Task {task_id} timed out")


# Usage
breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30)

for i in range(10):
    try:
        token = breaker.call(
            solve_captcha, "6Le-SITEKEY", "https://example.com"
        )
        print(f"[task-{i}] Solved: {token[:40]}...")
    except CircuitOpenError as e:
        print(f"[task-{i}] Skipped: {e}")
    except Exception as e:
        print(f"[task-{i}] Failed: {e}")

Salida esperada:

[task-0] Solved: 03AGdBq26ZfPxL...
[task-1] Solved: 03AGdBq27AbCdE...
[task-2] Failed: Submit error: ERROR_NO_SLOT_AVAILABLE
[task-3] Failed: Submit error: ERROR_NO_SLOT_AVAILABLE
[task-4] Failed: Submit error: ERROR_NO_SLOT_AVAILABLE
[circuit] State: open — 3 failures
[task-5] Skipped: Circuit open — retry in 28s
[task-6] Skipped: Circuit open — retry in 25s
...
[circuit] State: half-open — testing one request
[task-8] Solved: 03AGdBq28FgHiJ...
[circuit] State: closed — API recovered

Fíjate en el tramo rechazado: entre task-5 y task-7 no hay una sola llamada a la red. Eso es lo que buscas durante una incidencia.


Implementación en JavaScript

La versión de Node.js sigue la misma lógica. Con varias tareas simultáneas, el breaker debe ser una única instancia compartida: crear uno por tarea anula el patrón, porque cada instancia cuenta aparte y ninguna llega al umbral.

class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.recoveryTimeout = options.recoveryTimeout || 60000;
    this.failureCount = 0;
    this.lastFailureTime = 0;
    this.state = 'closed';
  }

  async call(fn, ...args) {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
        this.state = 'half-open';
        console.log('[circuit] State: half-open');
      } else {
        const remaining = this.recoveryTimeout - (Date.now() - this.lastFailureTime);
        throw new Error(`Circuit open — retry in ${Math.ceil(remaining / 1000)}s`);
      }
    }

    try {
      const result = await fn(...args);
      this.failureCount = 0;
      if (this.state === 'half-open') {
        console.log('[circuit] State: closed — recovered');
      }
      this.state = 'closed';
      return result;
    } catch (error) {
      this.failureCount++;
      this.lastFailureTime = Date.now();
      if (this.failureCount >= this.failureThreshold) {
        this.state = 'open';
        console.log(`[circuit] State: open — ${this.failureCount} failures`);
      }
      throw error;
    }
  }
}

// Usage
const axios = require('axios');

const API_KEY = 'YOUR_API_KEY';
const breaker = new CircuitBreaker({ failureThreshold: 3, recoveryTimeout: 30000 });

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

  if (submit.data.status !== 1) throw new Error(submit.data.request);
  const taskId = submit.data.request;

  for (let i = 0; i < 24; 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: taskId, json: 1 }
    });
    if (poll.data.status === 1) return poll.data.request;
    if (poll.data.request !== 'CAPCHA_NOT_READY') throw new Error(poll.data.request);
  }
  throw new Error('Timeout');
}

(async () => {
  for (let i = 0; i < 10; i++) {
    try {
      const token = await breaker.call(solveCaptcha, '6Le-SITEKEY', 'https://example.com');
      console.log(`[task-${i}] Solved: ${token.substring(0, 40)}...`);
    } catch (err) {
      console.log(`[task-${i}] ${err.message}`);
    }
  }
})();

Reintentos dentro del breaker, no al revés

Es el error de diseño más frecuente. La regla: los reintentos van dentro del circuit breaker, para que cuente solo los fallos definitivos.

def solve_with_retry(sitekey, page_url, max_retries=2):
    for attempt in range(max_retries + 1):
        try:
            return solve_captcha(sitekey, page_url)
        except Exception:
            if attempt == max_retries:
                raise
            time.sleep(2 ** attempt)

# Circuit breaker wraps the retry function
token = breaker.call(solve_with_retry, "6Le-SITEKEY", "https://example.com")

Al revés —el breaker dentro del bucle de reintento— cada intento choca contra un circuito abierto y se dispara CircuitOpenError en bucle, sin dar al servicio el respiro que el patrón pretendía.


Elegir los umbrales según tu volumen

Los dos parámetros que ajustas son cuántos fallos toleras y cuánto esperas antes de volver a probar:

Parámetro Tráfico bajo (< 10/min) Tráfico alto (> 100/min)
failure_threshold 3 10
recovery_timeout 30 s 60 s

La lógica: con poco tráfico cada solicitud es una muestra valiosa y tres fallos seguidos son señal fiable; con mucho tráfico son ruido, y abrir el circuito por ellos cuesta throughput sin motivo. Un timeout suelto nunca debería abrirlo.

El recovery_timeout marca el otro extremo: demasiado corto y el circuito se reabre sin dejar respirar al servicio; demasiado largo y rechazas trabajo ya recuperado. De 30 a 60 segundos cubre casi todos los casos.


Qué errores deben abrir el circuito y cuáles no

Un breaker que cuenta todos los errores por igual se abre cuando no debe. Separa los fallos de la dependencia de los tuyos:

Situación ¿Cuenta como fallo? Motivo
ERROR_NO_SLOT_AVAILABLE Capacidad saturada; insistir empeora la cola
Timeout de red o HTTP 5xx La dependencia no responde
ERROR_ZERO_BALANCE No: para y avisa Saldo agotado; esperar no lo arregla
ERROR_WRONG_USER_KEY No: para y avisa Configuración tuya, no una incidencia
ERROR_CAPTCHA_UNSOLVABLE No Tarea concreta; reencólala aparte

De aquí sale otra recomendación: usa breakers separados para envío y sondeo. in.php puede estar saturado mientras res.php sigue devolviendo resultados en curso, y un breaker global bloquearía consultas que funcionan.


Escenario: un portal de cita previa con ventana horaria

Piensa en un equipo que hace QA automatizado sobre un portal público de trámites —el tipo de sitio de cita previa o de gestiones fiscales habitual en España o México—, protegido con reCAPTCHA v2 y con un pico de tráfico a las 9:00. Ese perfil concentra casi toda la carga del día en veinte minutos.

Si el servicio se degrada en ese tramo, la aplicación sin breaker deja sus threads bloqueados en timeouts y no completa ni una verificación útil. Con failure_threshold=5 y recovery_timeout=30, detecta la degradación, deja de quemar hilos medio minuto, prueba una solicitud y reanuda en cuanto la API vuelve.

Un detalle de coste para quien factura en moneda local: CaptchaAI cobra por thread simultáneo, no por resolución, así que tu gasto es el mismo con el circuito abierto o cerrado. Lo escaso son los threads: con el plan STANDARD ($30/mes, 15 threads), quince hilos atrapados en timeouts no procesan nada.


Resolución de problemas

Un circuito que se abre en silencio es un servicio caído sin alarma: registra cada transición de estado donde tus alertas la vean.

Síntoma Causa probable Solución
Se abre demasiado pronto Umbral bajo para tu volumen Sube failure_threshold a 10
No se recupera nunca recovery_timeout excesivo Bájalo a 30–60 segundos
Estado inconsistente entre hilos Falta bloqueo sobre el contador Usa threading.Lock u operaciones atómicas
Todo se bloquea en una caída parcial Un breaker para todos los endpoints Separa in.php de res.php
Se abre por saldo agotado Cuenta errores de configuración Filtra ERROR_ZERO_BALANCE y ERROR_WRONG_USER_KEY

Preguntas frecuentes

¿El circuit breaker sustituye a la lógica de reintentos?

No, se complementan: el reintento cubre el fallo puntual y el breaker el sostenido, con los reintentos dentro. Tienes el detalle en la guía de reintentos para la API de CaptchaAI.

¿Qué hago con las tareas que el circuito rechaza?

Encólalas para cuando el circuito se cierre, muestra una alternativa o descarta la operación si no es crítica. Las tres estrategias están en degradación elegante cuando falla la resolución.

¿Debo abrir el circuito cuando la API devuelve ERROR_ZERO_BALANCE?

No: es saldo agotado y no se arregla esperando. Trátalo como parada controlada con alerta y usa la referencia de códigos de error para clasificar el resto.

¿Sirve el mismo breaker para reCAPTCHA v2, Turnstile y GeeTest v3?

Sí: el breaker envuelve la llamada HTTP, no el tipo de desafío. Solo cambian los tiempos, así que comprueba que tu timeout por solicitud supere el tiempo de resolución esperado antes de tocar el umbral.


Protege tu pipeline antes del próximo pico

Copia la clase, envuelve tus llamadas de envío y sondeo, y ajusta los umbrales a tu volumen real. Obtén tu clave API en captchaai.com y pruébalo en tu propio flujo.


Guías relacionadas

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