Tutorials

Endpoints de santé pour vos workers de résolution CAPTCHA

Un worker de résolution CAPTCHA dont le processus tourne n'est pas forcément un worker qui travaille : il peut avoir épuisé son solde CaptchaAI, rester coincé sur une file saturée ou boucler sur un timeout sans plus rien résoudre. Trois endpoints de santé — liveness, readiness et vérification des dépendances — donnent à Kubernetes et à votre équilibreur de charge le signal pour redémarrer, retirer du routage ou dégrader le service. Sans eux, l'orchestrateur continue d'alimenter une instance morte.

Liveness, readiness et dépendances : trois rôles à séparer

« Le processus répond » et « le worker peut résoudre un CAPTCHA » sont deux questions distinctes, avec deux réactions différentes.

Contrôle Question posée Réponse en cas d'échec
Liveness Le processus répond-il encore ? Redémarrez le conteneur
Readiness Peut-il accepter du travail ? Retirez-le du routage
Dépendances Les services en amont sont-ils sains ? Dégradez proprement

Quelle sonde pour quel type de panne ?

  • Processus totalement muet → liveness : redémarrez le conteneur.
  • Worker vivant mais à écarter du trafic → readiness : sortez-le du routage sans le tuer.
  • Dépendance en amont dégradée → dépendances : dégradez ou alertez avant la cascade.
  • Déploiement Kubernetes ou derrière un LB → les trois sondes combinées.

Sur une flotte hébergée en région eu-west-3 (Paris) chez OVHcloud ou Scaleway, gardez la liveness locale et instantanée : un pic de latence de l'API ne doit pas déclencher de redémarrage. Côté logs, ne consignez ni clé API ni donnée personnelle (obligations RGPD).

Implémenter les endpoints en Python avec Flask

Le worker maintient un objet d'état (résolutions, échecs consécutifs, dernière résolution, solde en cache) que les trois routes Flask consultent. La liveness ne touche à rien d'externe ; la readiness interroge le solde CaptchaAI, avec un cache de 60 secondes pour ne pas transformer chaque sonde en appel réseau.

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


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()

Les mêmes endpoints en Node.js avec Express

Même logique en JavaScript : un objet health et trois routes qui renvoient 200 ou 503. successRate vaut 1 tant qu'aucune tâche n'a été traitée, pour ne pas pénaliser un worker au démarrage.

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"));

Brancher les sondes sur Kubernetes

Câblez ensuite les routes dans le manifeste du déploiement, chaque sonde avec son propre rythme et son seuil. Laissez initialDelaySeconds couvrir le démarrage du worker pour ne pas le déclarer défaillant trop tôt.

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

Codes de réponse : 200 ou 503, jamais 500

  • /health/live — 200 si le processus répond, 503 s'il est gelé (à redémarrer).
  • /health/ready — 200 s'il peut accepter du travail, 503 sinon (arrêtez les tâches).
  • /health/dependencies — 200 si l'amont est sain, 503 s'il est dégradé.

Ne renvoyez jamais 500 : un 500 signale un bug de l'endpoint, pas l'état du worker.

Dépannage

Problème Cause Correctif
Worker redémarré en boucle Seuil de liveness trop strict Augmentez failureThreshold ou periodSeconds
Worker non prêt au démarrage Aucune résolution comptée, jugée « trop ancienne » Testez seconds_since_last_solve après la première résolution
Sonde ralentie par le solde Appel API à chaque requête Mettez le solde en cache (TTL 60 s)
L'endpoint de santé plante Exception non gérée dans un contrôle try/except par contrôle ; renvoyez « dégradé », pas un 500
Faux négatifs sur les dépendances Micro-coupure réseau pendant la vérification Servez la valeur en cache (stale-while-revalidate)

FAQ

Quelle différence entre une sonde liveness et une sonde readiness ?

La liveness demande « le processus est-il vivant ? » : en cas d'échec, Kubernetes redémarre le conteneur. La readiness demande « peut-il traiter une tâche ? » : en cas d'échec, il est retiré du routage sans être tué. Un worker au solde épuisé est vivant mais non prêt.

Un worker sans résolution récente est-il forcément en panne ?

Non. Au démarrage ou pendant une accalmie, un worker peut rester silencieux sans être défaillant. Le seuil seconds_since_last_solve ne doit donc être évalué qu'après la première résolution réussie.

Comment éviter que la vérification du solde ne ralentisse la sonde ?

Mettez le solde en cache (TTL 60 secondes) et servez la valeur en cache si l'appel échoue. La liveness ne doit jamais appeler l'API : réservez les appels réseau à la readiness et aux dépendances.

Peut-on exposer ces endpoints derrière un load balancer managé ?

Oui. Un LB managé (OVHcloud, Scaleway ou équivalent) interroge /health/ready pour choisir les workers qui reçoivent du trafic, comme la readinessProbe de Kubernetes. Exposez aussi une route /metrics Prometheus pour suivre la flotte dans Grafana.

Articles connexes

Prochaines étapes

Passez vos workers en production : récupérez votre clé API CaptchaAI et ajoutez vos trois sondes.

Guides associés :

Les commentaires sont désactivés pour cet article.