DevOps & Scaling

Planification de reprise après sinistre pour les pipelines de résolution de CAPTCHA

En production, un worker finit toujours par tomber, une file par se corrompre ou une clé API par expirer : la seule question est de savoir combien de tâches vous perdez quand cela arrive. Un plan de reprise après sinistre (DR) répond à l'avance — il fixe le volume de tâches que vous acceptez de perdre, le délai de remise en service et la procédure exacte pour y parvenir.

Pour un pipeline de résolution de CAPTCHA, cela tient en trois leviers : des objectifs chiffrés, une file qui survit aux crashs et un runbook prêt à dérouler.

Fixez d'abord vos objectifs : RPO, RTO et MTTR

Chiffrez d'abord ce que « récupérer » signifie pour vous. Trois métriques suffisent, et elles guident toutes les décisions techniques qui suivent.

Métrique Définition Cible du pipeline CAPTCHA
RPO (objectif de point de récupération) Perte de données maximale tolérable < 5 minutes de tâches en file d'attente
RTO (objectif de temps de récupération) Temps maximum pour restaurer le service < 15 minutes
MTTR (temps moyen de récupération) Temps de récupération moyen < 10 minutes

Chaque cible se traduit directement en décision d'ingénierie, à caler sur la valeur réelle de vos tâches :

  • un RPO serré impose des points de contrôle fréquents ;
  • un RTO serré impose un redémarrage automatisé, pas une intervention manuelle ;
  • un MTTR bas suppose des alertes et un runbook déjà répétés en amont.

Quand la reprise après sinistre devient une priorité

Tous les pipelines n'ont pas besoin du même niveau de robustesse. Ces signaux indiquent qu'il est temps de traiter la reprise après sinistre comme un chantier à part entière.

Signal Pourquoi la DR devient importante
Les tâches sont nombreuses et coûteuses à rejouer Une panne se traduit directement en temps, budget et données perdus
Plusieurs équipes dépendent du même pipeline Une panne locale devient vite un incident transverse
Les workers tournent en continu en production Le redémarrage manuel ne suffit plus comme réponse
Les files, caches ou bases évoluent souvent Le risque de dérive de configuration et de corruption augmente

Les scénarios de panne à anticiper

Un plan se construit à partir de pannes concrètes, chacune avec sa réponse. Ces cinq scénarios couvrent la quasi-totalité des incidents d'un pipeline de résolution de CAPTCHA.

Scenario 1: Worker crash         → Restart workers, replay queue
Scenario 2: Queue data loss      → Restore from persistent backup
Scenario 3: Network partition    → Failover to secondary region
Scenario 4: API key compromised  → Rotate key, update workers
Scenario 5: Config corruption    → Rollback to last known good

Pour le scénario 3, gardez une région secondaire réellement prête (OVHcloud, Scaleway ou AWS eu-west-3 Paris) plutôt que d'improviser un basculement pendant l'incident. « Réellement prête » suppose trois conditions, vérifiées avant l'incident :

  • les données de la file répliquées en continu vers la région secondaire ;
  • les secrets et la clé API déjà provisionnés des deux côtés ;
  • le routage DNS ou load balancer validé lors d'un exercice, pas le jour J.

Persistez les tâches pour survivre aux crashs

Ne résolvez jamais un CAPTCHA depuis une file uniquement en mémoire. Persistez chaque tâche sur disque : c'est la seule façon de rejouer ce qui tournait au moment du crash et de tenir votre RPO. Concrètement, cette persistance vous apporte trois garanties :

  • vous rejouez exactement les tâches en cours au moment du crash ;
  • vous bornez la perte de données à la valeur de RPO que vous visez ;
  • vous récupérez au redémarrage les tâches restées bloquées en processing.

Si vos payloads contiennent des URL ou des identifiants liés à des utilisateurs, minimisez les données personnelles stockées et vérifiez vos obligations RGPD.

Python : file d'attente persistante sur SQLite

import os
import json
import time
import sqlite3
import threading
import requests
from datetime import datetime

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class PersistentTaskQueue:
    """SQLite-backed task queue that survives crashes."""

    def __init__(self, db_path="captcha_tasks.db"):
        self.db_path = db_path
        self.conn = sqlite3.connect(db_path, check_same_thread=False)
        self.lock = threading.Lock()
        self._init_db()

    def _init_db(self):
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS tasks (
                id TEXT PRIMARY KEY,
                payload TEXT NOT NULL,
                status TEXT DEFAULT 'pending',
                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                started_at TEXT,
                completed_at TEXT,
                result TEXT,
                attempts INTEGER DEFAULT 0
            )
        """)
        self.conn.commit()

    def enqueue(self, task_id, payload):
        with self.lock:
            self.conn.execute(
                "INSERT INTO tasks (id, payload) VALUES (?, ?)",
                (task_id, json.dumps(payload))
            )
            self.conn.commit()

    def dequeue(self):
        with self.lock:
            cursor = self.conn.execute(
                "SELECT id, payload FROM tasks "
                "WHERE status = 'pending' ORDER BY created_at LIMIT 1"
            )
            row = cursor.fetchone()
            if not row:
                return None

            task_id, payload = row
            self.conn.execute(
                "UPDATE tasks SET status = 'processing', "
                "started_at = ?, attempts = attempts + 1 WHERE id = ?",
                (datetime.utcnow().isoformat(), task_id)
            )
            self.conn.commit()
            return {"id": task_id, "payload": json.loads(payload)}

    def complete(self, task_id, result):
        with self.lock:
            self.conn.execute(
                "UPDATE tasks SET status = 'completed', "
                "completed_at = ?, result = ? WHERE id = ?",
                (datetime.utcnow().isoformat(), json.dumps(result), task_id)
            )
            self.conn.commit()

    def fail(self, task_id, error):
        with self.lock:
            # Requeue if under retry limit
            cursor = self.conn.execute(
                "SELECT attempts FROM tasks WHERE id = ?", (task_id,)
            )
            row = cursor.fetchone()
            if row and row[0] < 3:
                self.conn.execute(
                    "UPDATE tasks SET status = 'pending' WHERE id = ?",
                    (task_id,)
                )
            else:
                self.conn.execute(
                    "UPDATE tasks SET status = 'failed', "
                    "result = ? WHERE id = ?",
                    (json.dumps({"error": error}), task_id)
                )
            self.conn.commit()

    def recover_stale(self, timeout_seconds=600):
        """Reset tasks stuck in 'processing' after a crash."""
        with self.lock:
            cutoff = datetime.utcnow().timestamp() - timeout_seconds
            self.conn.execute(
                "UPDATE tasks SET status = 'pending' "
                "WHERE status = 'processing' "
                "AND started_at < datetime(?, 'unixepoch')",
                (cutoff,)
            )
            count = self.conn.total_changes
            self.conn.commit()
            return count

    @property
    def stats(self):
        cursor = self.conn.execute(
            "SELECT status, COUNT(*) FROM tasks GROUP BY status"
        )
        return dict(cursor.fetchall())


# On startup: recover tasks that were processing during a crash
queue = PersistentTaskQueue()
recovered = queue.recover_stale(timeout_seconds=600)
print(f"Recovered {recovered} stale tasks after restart")

Au démarrage, recover_stale remet en file les tâches bloquées en processing : ce qui aurait été perdu sans persistance.

Node.js : gestionnaire de reprise et points de contrôle

Sur un traitement par lots, complétez la file persistante par des points de contrôle réguliers : chaque checkpoint enregistre l'avancement et permet de reprendre un lot interrompu.

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

const API_KEY = process.env.CAPTCHAAI_API_KEY;

class DisasterRecoveryManager {
  constructor(checkpointDir = "./dr-checkpoints") {
    this.checkpointDir = checkpointDir;
    if (!fs.existsSync(checkpointDir)) {
      fs.mkdirSync(checkpointDir, { recursive: true });
    }
  }

  checkpoint(label, data) {
    const filename = `${this.checkpointDir}/${label}-${Date.now()}.json`;
    fs.writeFileSync(filename, JSON.stringify(data, null, 2));
    this.pruneOldCheckpoints(label, 10); // Keep last 10
    return filename;
  }

  restore(label) {
    const files = fs.readdirSync(this.checkpointDir)
      .filter((f) => f.startsWith(label) && f.endsWith(".json"))
      .sort()
      .reverse();

    if (files.length === 0) return null;
    const latest = fs.readFileSync(
      `${this.checkpointDir}/${files[0]}`, "utf8"
    );
    return JSON.parse(latest);
  }

  pruneOldCheckpoints(label, keep) {
    const files = fs.readdirSync(this.checkpointDir)
      .filter((f) => f.startsWith(label) && f.endsWith(".json"))
      .sort();

    while (files.length > keep) {
      const old = files.shift();
      fs.unlinkSync(`${this.checkpointDir}/${old}`);
    }
  }

  async healthCheck() {
    try {
      const resp = await axios.get("https://ocr.captchaai.com/res.php", {
        params: { key: API_KEY, action: "getbalance", json: 1 },
        timeout: 10000,
      });
      return {
        healthy: resp.data.status === 1,
        balance: parseFloat(resp.data.request || 0),
      };
    } catch (err) {
      return { healthy: false, error: err.message };
    }
  }
}

class ResilientSolver {
  constructor() {
    this.dr = new DisasterRecoveryManager();
    this.pendingTasks = [];
  }

  async solveBatch(tasks) {
    // Checkpoint before starting
    this.dr.checkpoint("batch-pending", {
      tasks,
      startedAt: new Date().toISOString(),
    });

    const results = [];
    for (const task of tasks) {
      try {
        const result = await this.solveSingle(task);
        results.push({ taskId: task.id, ...result });
      } catch (err) {
        results.push({ taskId: task.id, error: err.message });
      }

      // Checkpoint progress periodically
      if (results.length % 10 === 0) {
        this.dr.checkpoint("batch-progress", { results, remaining: tasks.length - results.length });
      }
    }

    // Final checkpoint
    this.dr.checkpoint("batch-complete", { results });
    return results;
  }

  async recover() {
    // Check for incomplete batch
    const progress = this.dr.restore("batch-progress");
    const pending = this.dr.restore("batch-pending");

    if (progress) {
      const completedIds = new Set(progress.results.map((r) => r.taskId));
      const remaining = pending?.tasks.filter((t) => !completedIds.has(t.id));
      console.log(
        `Recovering: ${progress.results.length} done, ${remaining?.length || 0} remaining`
      );
      return remaining || [];
    }

    if (pending) {
      console.log(`Recovering full batch: ${pending.tasks.length} tasks`);
      return pending.tasks;
    }

    return [];
  }

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

    if (resp.data.status !== 1) throw new Error(resp.data.request);

    const captchaId = resp.data.request;
    for (let i = 0; i < 60; 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: captchaId, json: 1 },
      });
      if (poll.data.status === 1) return { solution: poll.data.request };
      if (poll.data.request !== "CAPCHA_NOT_READY")
        throw new Error(poll.data.request);
    }
    throw new Error("TIMEOUT");
  }
}

// Start with recovery check
const solver = new ResilientSolver();
solver.recover().then((remaining) => {
  if (remaining.length > 0) {
    console.log(`Resuming ${remaining.length} tasks from checkpoint`);
    solver.solveBatch(remaining);
  }
});

Au lancement, le solveur ne reprend que les tâches manquantes du dernier lot : la reprise devient le comportement par défaut, pas une exception.

Un runbook de reprise après sinistre prêt à dérouler

Un code irréprochable ne sert à rien si personne ne sait quoi faire à 3 h du matin. Formalisez la procédure dans un runbook court, testé et versionné.

RUNBOOK: CAPTCHA Pipeline Recovery
====================================

1. DETECT
   - Alert fires: [PagerDuty / Slack / Email]
   - Symptom: [Queue growing / Workers offline / Error spike]

2. ASSESS
   - Check worker health: curl http://workers/health
   - Check API status: GET /res.php?action=getbalance
   - Check queue depth: SELECT COUNT(*) FROM tasks WHERE status='pending'

3. RECOVER
   If: Workers crashed
     → Restart worker containers: docker-compose up -d workers
     → Run stale task recovery: recovery.py --recover-stale

   If: Network partition
     → Failover to secondary region
     → Update DNS or load balancer routing

   If: API key compromised
     → Generate new key at captchaai.com
     → Update secret store
     → Rolling restart workers

4. VERIFY
   - Confirm solve rate > 90%
   - Confirm queue draining
   - Confirm no duplicate solves

5. POST-MORTEM
   - Document root cause
   - Update runbook if needed

Dépannage

Problème Cause Correctif
Tâches perdues après un crash File d'attente uniquement en mémoire Passez à une file persistante (SQLite, ou Redis avec AOF)
Résolutions en double après reprise Tâches obsolètes retraitées sans déduplication Ajoutez des clés d'idempotence et vérifiez si la tâche est déjà résolue
La reprise dépasse le RTO Sauvegarde ou point de contrôle trop ancien Augmentez la fréquence des points de contrôle
Basculement vers la mauvaise région TTL DNS trop élevé Abaissez le TTL à 60 s avant tout basculement planifié

Questions fréquentes

Comment tester mon plan de reprise sans provoquer une vraie panne ?

Organisez un « game day » en préproduction, sans jamais toucher à la production :

  1. coupez un worker ou renommez la base pour simuler la panne ;
  2. chronométrez la reprise complète du pipeline ;
  3. comparez le résultat à votre RTO ;
  4. resserrez les points de contrôle si la cible n'est pas tenue.

Quelle est la différence entre RPO et RTO ?

Le RPO mesure ce que vous acceptez de perdre — ici, les tâches encore en file. Le RTO mesure le temps de remise en service. Le premier pilote vos points de contrôle, le second l'automatisation du redémarrage.

Comment empêcher les résolutions en double après une reprise ?

Attribuez une clé d'idempotence à chaque tâche et vérifiez son statut avant de la renvoyer. Une tâche déjà marquée « completed » ne repart jamais vers l'API, même si elle réapparaît dans la file.

Que se passe-t-il si l'API CaptchaAI est momentanément indisponible ?

Conservez les tâches localement et réessayez une fois l'API rétablie. Encadrez les appels avec un disjoncteur (circuit breaker) et un backoff exponentiel : votre pipeline absorbe une indisponibilité passagère sans perdre de tâches.

Pour aller plus loin

Anticipez le pire : récupérez votre clé API CaptchaAI et intégrez la reprise après sinistre à votre pipeline dès la première tâche.

Guides associés :

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