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:
- Cerrado (
closed): operación normal. Las solicitudes pasan y el breaker cuenta los fallos consecutivos. - 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. - 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 |
Sí | Capacidad saturada; insistir empeora la cola |
| Timeout de red o HTTP 5xx | Sí | 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.