Tutoriales

Endpoints de health check para workers de resolución de CAPTCHA

Un worker de resolución de CAPTCHA puede tener el proceso en marcha y aun así estar inservible: la clave API se quedó sin saldo, la API upstream no responde o el bucle de resolución se atascó sin devolver ningún token. Un health check es justo lo que le permite a tu orquestador distinguir "el proceso vive" de "el proceso sirve". En esta guía expones tres endpoints —liveness, readiness y dependencias— para que Kubernetes o tu balanceador de carga saquen de rotación a un worker degradado antes de que siga recibiendo tareas que no puede resolver.

Piensa en una agencia de datos en Ciudad de México que corre una flota de 40 workers detrás de un balanceador, cada uno consumiendo tareas de una cola y llamando a la API de CaptchaAI. Sin health checks, cuando el saldo de una clave se agota a media tarde, el balanceador sigue enviando trabajo a ese worker durante minutos: las tareas fallan en silencio y la cola se acumula. Con un readiness que consulta el saldo, ese worker se marca not_ready en segundos y el tráfico se redirige solo a los que sí pueden resolver.

Liveness, readiness y dependencias: los tres checks

No todos los health checks responden la misma pregunta. Conviene separarlos porque cada uno dispara una acción distinta en el orquestador:

Check Qué pregunta Acción ante el fallo
Liveness ¿El proceso responde? Reiniciar el contenedor
Readiness ¿Puede aceptar trabajo ahora? Dejar de enrutar tráfico
Dependencias ¿La API upstream está bien? Degradarse con gracia

La regla práctica: liveness debe ser trivial y no llamar a nada externo, porque un reinicio es una acción cara. Readiness sí puede mirar el estado real (saldo, fallos consecutivos, tiempo sin resolver) y devolver 503 cuando el worker no está en condiciones, sin matar el proceso.

Python: endpoints de salud con Flask

El worker mantiene un objeto WorkerHealth protegido con un lock y actualiza sus contadores en cada intento de resolución. Los tres endpoints leen ese estado: /health/live responde al instante, /health/ready evalúa umbrales de fallos, tiempo sin resolver y saldo mínimo, y /health/dependencies mide la latencia hacia la API. El saldo se cachea 60 segundos para no golpear la API en cada sondeo.

import requests
import time
import threading
from flask import Flask, jsonify
from dataclasses import dataclass, field

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

app = Flask(__name__)


@dataclass
class WorkerHealth:
    """Tracks worker health metrics."""
    started_at: float = field(default_factory=time.monotonic)
    last_solve_at: float = 0.0
    total_solved: int = 0
    total_failed: int = 0
    consecutive_failures: int = 0
    balance: float | None = None
    balance_checked_at: float = 0.0
    _lock: threading.Lock = field(default_factory=threading.Lock)

    def record_success(self):
        with self._lock:
            self.total_solved += 1
            self.last_solve_at = time.monotonic()
            self.consecutive_failures = 0

    def record_failure(self):
        with self._lock:
            self.total_failed += 1
            self.consecutive_failures += 1

    @property
    def success_rate(self) -> float:
        total = self.total_solved + self.total_failed
        return self.total_solved / total if total > 0 else 1.0

    @property
    def seconds_since_last_solve(self) -> float:
        if self.last_solve_at == 0:
            return time.monotonic() - self.started_at
        return time.monotonic() - self.last_solve_at


health = WorkerHealth()

# Thresholds
MAX_CONSECUTIVE_FAILURES = 10
MAX_SECONDS_WITHOUT_SOLVE = 600  # 10 minutes
MIN_BALANCE = 1.0

# Practical operating rules
# - Mark the worker not ready if it cannot solve reliably right now.
# - Keep liveness simple: the process can be alive even when it should stop taking work.
# - Alert before balance reaches zero so queues can drain cleanly.


def check_balance() -> float | None:
    """Check CaptchaAI balance."""
    now = time.monotonic()
    # Cache balance for 60 seconds
    if health.balance is not None and now - health.balance_checked_at < 60:
        return health.balance

    try:
        resp = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "getbalance", "json": 1,
        }, timeout=10).json()
        health.balance = float(resp.get("request", 0))
        health.balance_checked_at = now
        return health.balance
    except Exception:
        return health.balance  # Return cached value on error


@app.route("/health/live")
def liveness():
    """Liveness probe — is the process responsive?"""
    return jsonify({"status": "ok", "uptime_s": int(time.monotonic() - health.started_at)}), 200


@app.route("/health/ready")
def readiness():
    """Readiness probe — can the worker accept tasks?"""
    issues = []

    # Check consecutive failures
    if health.consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
        issues.append(f"consecutive_failures={health.consecutive_failures}")

    # Check time since last solve
    if health.total_solved > 0 and health.seconds_since_last_solve > MAX_SECONDS_WITHOUT_SOLVE:
        issues.append(f"no_solve_for={int(health.seconds_since_last_solve)}s")

    # Check balance
    balance = check_balance()
    if balance is not None and balance < MIN_BALANCE:
        issues.append(f"low_balance=${balance:.2f}")

    if issues:
        return jsonify({
            "status": "not_ready",
            "issues": issues,
            "stats": {
                "solved": health.total_solved,
                "failed": health.total_failed,
                "success_rate": round(health.success_rate, 3),
            },
        }), 503

    return jsonify({
        "status": "ready",
        "stats": {
            "solved": health.total_solved,
            "failed": health.total_failed,
            "success_rate": round(health.success_rate, 3),
            "balance": balance,
        },
    }), 200


@app.route("/health/dependencies")
def dependencies():
    """Check upstream dependencies."""
    checks = {}

    # CaptchaAI API reachability
    try:
        resp = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "getbalance", "json": 1,
        }, timeout=10)
        checks["captchaai_api"] = {
            "status": "ok" if resp.status_code == 200 else "degraded",
            "response_ms": int(resp.elapsed.total_seconds() * 1000),
        }
    except Exception as e:
        checks["captchaai_api"] = {"status": "down", "error": str(e)}

    all_ok = all(c["status"] == "ok" for c in checks.values())
    return jsonify({
        "status": "ok" if all_ok else "degraded",
        "checks": checks,
    }), 200 if all_ok else 503


# --- Worker loop (runs in background) ---

def worker_loop():
    """Simulated CAPTCHA solving worker."""
    while True:
        try:
            # ... solve CAPTCHA logic ...
            health.record_success()
        except Exception:
            health.record_failure()
        time.sleep(1)


threading.Thread(target=worker_loop, daemon=True).start()

JavaScript: endpoints de salud con Express

Si tu worker corre sobre Node.js, la lógica es idéntica: un objeto health con contadores y tres rutas Express. Fíjate en que checkBalance reutiliza el valor cacheado durante 60 segundos y en que readiness devuelve 503 en cuanto encuentra un problema, para que el balanceador reaccione de inmediato.

const express = require("express");

const API_KEY = "YOUR_API_KEY";
const RESULT_URL = "https://ocr.captchaai.com/res.php";

const app = express();

const health = {
  startedAt: Date.now(),
  lastSolveAt: 0,
  totalSolved: 0,
  totalFailed: 0,
  consecutiveFailures: 0,
  balance: null,
  balanceCheckedAt: 0,

  recordSuccess() {
    this.totalSolved++;
    this.lastSolveAt = Date.now();
    this.consecutiveFailures = 0;
  },

  recordFailure() {
    this.totalFailed++;
    this.consecutiveFailures++;
  },

  get successRate() {
    const total = this.totalSolved + this.totalFailed;
    return total > 0 ? this.totalSolved / total : 1;
  },
};

async function checkBalance() {
  if (health.balance !== null && Date.now() - health.balanceCheckedAt < 60000) {
    return health.balance;
  }
  try {
    const url = `${RESULT_URL}?key=${API_KEY}&action=getbalance&json=1`;
    const resp = await (await fetch(url)).json();
    health.balance = parseFloat(resp.request);
    health.balanceCheckedAt = Date.now();
    return health.balance;
  } catch {
    return health.balance;
  }
}

app.get("/health/live", (req, res) => {
  res.json({ status: "ok", uptimeMs: Date.now() - health.startedAt });
});

app.get("/health/ready", async (req, res) => {
  const issues = [];

  if (health.consecutiveFailures >= 10) {
    issues.push(`consecutive_failures=${health.consecutiveFailures}`);
  }

  if (health.totalSolved > 0) {
    const silentMs = Date.now() - health.lastSolveAt;
    if (silentMs > 600_000) {
      issues.push(`no_solve_for=${Math.round(silentMs / 1000)}s`);
    }
  }

  const balance = await checkBalance();
  if (balance !== null && balance < 1.0) {
    issues.push(`low_balance=$${balance.toFixed(2)}`);
  }

  const stats = {
    solved: health.totalSolved,
    failed: health.totalFailed,
    successRate: Math.round(health.successRate * 1000) / 1000,
    balance,
  };

  if (issues.length > 0) {
    return res.status(503).json({ status: "not_ready", issues, stats });
  }
  res.json({ status: "ready", stats });
});

app.get("/health/dependencies", async (req, res) => {
  const checks = {};
  try {
    const start = Date.now();
    const url = `${RESULT_URL}?key=${API_KEY}&action=getbalance&json=1`;
    const resp = await fetch(url);
    checks.captchaaiApi = {
      status: resp.ok ? "ok" : "degraded",
      responseMs: Date.now() - start,
    };
  } catch (e) {
    checks.captchaaiApi = { status: "down", error: e.message };
  }

  const allOk = Object.values(checks).every((c) => c.status === "ok");
  res.status(allOk ? 200 : 503).json({
    status: allOk ? "ok" : "degraded",
    checks,
  });
});

app.listen(8080, () => console.log("Health server on :8080"));

Cómo conectar los probes en Kubernetes

Kubernetes usa el liveness probe para decidir cuándo reiniciar el contenedor y el readiness probe para decidir si lo incluye en el Service. Dale a liveness un initialDelaySeconds holgado para que el worker arranque sin reinicios prematuros, y a readiness un periodSeconds más corto para que reaccione rápido cuando el saldo baja o los fallos se acumulan.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: captcha-worker
spec:
  replicas: 3
  template:
    spec:
      containers:

        - name: worker
          image: captcha-worker:latest
          ports:

            - containerPort: 8080
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 2

Qué significa cada código de respuesta

Cada endpoint devuelve solo dos estados —200 cuando todo va bien y 503 cuando el orquestador debe actuar— para que las probes sean fáciles de interpretar:

Endpoint 200 503
/health/live Proceso responsivo Proceso congelado: reiniciar
/health/ready Puede aceptar trabajo Deja de enviar tareas
/health/dependencies Dependencias OK Upstream degradado

Problemas comunes y cómo resolverlos

La mayoría de los fallos de health checks no vienen de la lógica de resolución, sino de umbrales mal calibrados o de llamadas externas dentro del probe. Esta tabla reúne los casos que más soporte generan:

Problema Causa Solución
El worker se reinicia sin parar Umbral de liveness demasiado estricto Sube failureThreshold o periodSeconds
Se marca not_ready al arrancar Aún no hubo ninguna resolución y se cuenta como "demasiado tiempo" Evalúa seconds_since_last_solve solo después de la primera resolución
El saldo ralentiza el endpoint Llamada a la API en cada solicitud Cachea el saldo con un TTL (60 s recomendado)
El propio endpoint de salud falla Excepción sin controlar en un check Envuelve cada check en try/except y devuelve degraded en vez de 500
Falsos negativos en dependencias Corte de red puntual al consultar el saldo Usa valores cacheados con un enfoque stale-while-revalidate

Preguntas frecuentes

¿Cuál es la diferencia entre liveness y readiness?

Liveness responde "¿el proceso está vivo?" y su fallo reinicia el contenedor. Readiness responde "¿puede aceptar trabajo ahora mismo?" y su fallo solo deja de enrutar tráfico, sin matar el proceso. Un worker sin saldo está vivo pero no listo: liveness debe seguir en 200 y readiness pasar a 503.

¿Qué debe devolver el health check cuando el saldo está bajo?

El readiness debe devolver 503 con el detalle en issues (por ejemplo low_balance), no el liveness. Así el worker sale de rotación y la cola drena hacia otros workers mientras recargas saldo, sin provocar un ciclo de reinicios. Recuerda que CaptchaAI factura por thread concurrente, así que el saldo se agota por consumo, no por número de solicitudes.

¿Puedo usar estos endpoints sin Kubernetes, solo con Docker Compose?

Sí. Cualquier balanceador o supervisor que sepa hacer una petición HTTP sirve: Docker Compose con healthcheck, un nginx con upstream checks, o incluso un script de systemd. Los mismos /health/live y /health/ready funcionan igual; solo cambia quién los consulta.

¿Es seguro exponer /health a Internet?

Mejor no. Los endpoints revelan saldo, tasa de éxito y estado interno, así que exponlos solo en la red interna o detrás de autenticación. Kubernetes y el balanceador los consultan desde dentro del clúster, por lo que no necesitan salir a la red pública.

Artículos relacionados

Siguientes pasos

Deja tus workers de CAPTCHA listos para producción: obtén tu clave API de CaptchaAI y añade estos endpoints de health check a cada instancia.

Guías relacionadas:

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