API Tutorials

File d'attente de lettres mortes pour les tâches CAPTCHA ayant échoué

Une file d'attente de lettres mortes (DLQ) est le filet de sécurité de votre pipeline de résolution CAPTCHA : elle récupère chaque tâche qui a épuisé toutes ses tentatives au lieu de la laisser disparaître dans un log. Concrètement, dès qu'une résolution échoue définitivement, vous poussez la tâche — sitekey, URL de la page, code d'erreur, nombre de tentatives — dans une file dédiée. Rien n'est perdu en silence : vous pouvez la rejouer plus tard, l'analyser ou déclencher une alerte.

Ce guide montre comment construire cette DLQ pas à pas, d'abord en mémoire avec Python, puis avec persistance sur disque en Node.js, et enfin comment exploiter les tâches capturées pour remonter à la cause des échecs.


Ce qui envoie une tâche dans la DLQ

Une tâche de résolution reCAPTCHA v2 ne bascule dans la DLQ qu'après avoir épuisé ses nouvelles tentatives. Toutes les causes ne se valent pas : certaines méritent un rejeu immédiat, d'autres trahissent un paramètre erroné qu'aucune tentative ne corrigera.

Cause de l'échec Signal renvoyé Rejouable plus tard ?
Défi non résolu par le solveur ERROR_CAPTCHA_UNSOLVABLE Rarement — souvent un paramètre en cause
Workers tous occupés, tentatives épuisées ERROR_NO_SLOT_AVAILABLE Oui, après un court délai
Aucun résultat renvoyé dans le délai imparti Timeout Oui
Connexion coupée pendant l'interrogation du résultat Erreur réseau Oui, une fois le réseau rétabli

Pourquoi une ligne de log ne suffit pas

Sans DLQ, chacun de ces échecs se réduit à une ligne de journal que personne ne relit. La requête, elle, est perdue : il faut la reconstruire à la main. Avec une DLQ, la tâche reste vivante et rejouable — ce qui allège d'autant la charge du support, puisque plus rien ne se volatilise en silence.


Une DLQ en mémoire avec nouvelle tentative en Python

Commençons par le cas le plus simple : un script qui traite un lot d'URL et pousse dans la file les tâches définitivement en échec. Le backoff exponentiel (2 ** attempt) espace les nouvelles tentatives, et la deque bornée évite que la file ne grossisse sans limite.

import time
import json
import requests
from collections import deque
from dataclasses import dataclass, asdict
from typing import Optional

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


@dataclass
class FailedTask:
    sitekey: str
    page_url: str
    error: str
    attempts: int
    timestamp: float
    task_id: Optional[str] = None


class DeadLetterQueue:
    def __init__(self, max_size=1000, max_retries=3):
        self._queue = deque(maxlen=max_size)
        self.max_retries = max_retries

    def push(self, task: FailedTask):
        self._queue.append(task)
        print(f"[dlq] Added: {task.error} (attempts: {task.attempts})")

    def pop(self) -> Optional[FailedTask]:
        return self._queue.popleft() if self._queue else None

    def size(self) -> int:
        return len(self._queue)

    def peek_all(self) -> list:
        return [asdict(t) for t in self._queue]

    def export_json(self, path: str):
        with open(path, "w") as f:
            json.dump(self.peek_all(), f, indent=2)
        print(f"[dlq] Exported {self.size()} tasks to {path}")


dlq = DeadLetterQueue(max_retries=3)


def solve_captcha(sitekey, page_url, max_retries=3):
    for attempt in range(max_retries + 1):
        try:
            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(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(poll["request"])

            raise TimeoutError(f"Task {task_id} timed out")

        except Exception as e:
            if attempt == max_retries:
                dlq.push(FailedTask(
                    sitekey=sitekey,
                    page_url=page_url,
                    error=str(e),
                    attempts=attempt + 1,
                    timestamp=time.time(),
                ))
                return None
            time.sleep(2 ** attempt)

    return None


# Process a batch
urls = [f"https://example.com/page/{i}" for i in range(5)]
for url in urls:
    token = solve_captcha("6Le-SITEKEY", url)
    if token:
        print(f"Solved: {token[:40]}...")

print(f"\nDLQ size: {dlq.size()}")

Résultat attendu :

Solved: 03AGdBq26ZfPxL...
Solved: 03AGdBq27AbCdE...
[dlq] Added: ERROR_CAPTCHA_UNSOLVABLE (attempts: 4)
Solved: 03AGdBq28FgHiJ...
[dlq] Added: Task 71823460 timed out (attempts: 4)

DLQ size: 2

Sur cinq URL, deux tâches n'ont pas abouti après quatre tentatives : elles atterrissent dans la file au lieu d'être perdues.

Le rôle du backoff exponentiel

Entre deux tentatives, time.sleep(2 ** attempt) double le délai d'attente (1 s, 2 s, 4 s…). Ce backoff exponentiel évite de marteler l'API quand elle est déjà sous tension et laisse à un worker le temps de se libérer avant le prochain essai.


Rejouer les tâches depuis la DLQ

La capture ne sert à rien sans rejeu. La fonction ci-dessous vide la file, tente une résolution avec un budget de tentatives réduit, et abandonne définitivement les tâches qui dépassent le plafond cumulé (dlq.max_retries + max_retries). Ce plafond est ce qui vous protège d'une même tâche rejouée à l'infini.

def retry_dlq(dlq: DeadLetterQueue, max_retries=2):
    retried = 0
    recovered = 0

    while dlq.size() > 0:
        task = dlq.pop()
        if task.attempts >= dlq.max_retries + max_retries:
            print(f"[dlq] Permanently failed: {task.sitekey} — {task.error}")
            continue

        retried += 1
        token = solve_captcha(
            task.sitekey, task.page_url, max_retries=max_retries
        )
        if token:
            recovered += 1
            print(f"[dlq-retry] Recovered: {token[:40]}...")

    print(f"[dlq] Retried: {retried}, Recovered: {recovered}")

# Run DLQ retry after main batch
retry_dlq(dlq)

Lancez ce rejeu quelques minutes après le lot principal : les échecs dus à une saturation temporaire des workers (ERROR_NO_SLOT_AVAILABLE) ou à un pic réseau passent souvent au second essai.


Persister la DLQ sur disque avec Node.js

Une DLQ en mémoire disparaît au redémarrage du processus — inacceptable pour un worker de longue durée. Prenons un cas concret : un pipeline de scraping tournant en continu sur des serveurs OVHcloud ou une instance Scaleway. Si le processus redémarre après un déploiement, les tâches en attente de rejeu ne doivent pas s'évaporer. La version Node.js écrit donc la file dans un fichier JSON à chaque push et à chaque pop.

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

const API_KEY = 'YOUR_API_KEY';
const DLQ_FILE = './captcha-dlq.json';

class DeadLetterQueue {
  constructor(maxRetries = 3) {
    this.maxRetries = maxRetries;
    this.queue = this._load();
  }

  push(task) {
    this.queue.push({
      ...task,
      timestamp: Date.now(),
    });
    this._save();
    console.log(`[dlq] Added: ${task.error} (attempts: ${task.attempts})`);
  }

  pop() {
    const task = this.queue.shift();
    if (task) this._save();
    return task || null;
  }

  size() {
    return this.queue.length;
  }

  _load() {
    try {
      return JSON.parse(fs.readFileSync(DLQ_FILE, 'utf8'));
    } catch {
      return [];
    }
  }

  _save() {
    fs.writeFileSync(DLQ_FILE, JSON.stringify(this.queue, null, 2));
  }
}

const dlq = new DeadLetterQueue(3);

async function solveCaptcha(sitekey, pageurl, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      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(`Task ${taskId} timed out`);
    } catch (err) {
      if (attempt === maxRetries) {
        dlq.push({ sitekey, pageurl, error: err.message, attempts: attempt + 1 });
        return null;
      }
      await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
    }
  }
}

// Process tasks
(async () => {
  for (let i = 0; i < 5; i++) {
    const token = await solveCaptcha('6Le-SITEKEY', `https://example.com/page/${i}`);
    if (token) console.log(`Solved: ${token.substring(0, 40)}...`);
  }
  console.log(`DLQ size: ${dlq.size()}`);
})();

Quand passer du fichier à Redis

Le fichier JSON reste la version la plus simple à mettre en place et convient parfaitement à un worker unique. Dès que plusieurs processus écrivent dans la même file — un pipeline de scraping réparti sur plusieurs instances OVHcloud ou Scaleway, par exemple — passez à une file Redis, qui gère l'accès concurrent sans risque de corruption du fichier.


Analyser les échecs pour remonter à la cause

La DLQ n'est pas seulement un tampon de rejeu : c'est un journal de vos échecs, donc une mine de diagnostics. Exportez-la et comptez la distribution des erreurs.

# Export DLQ for analysis
dlq.export_json("failed-tasks.json")

# Analyze error distribution
from collections import Counter
errors = Counter(t["error"] for t in dlq.peek_all())
for error, count in errors.most_common():
    print(f"  {error}: {count}")

Ce que révèlent ces chiffres :

Signal dans la DLQ Ce qu'il indique Première action
Un sitekey qui échoue en boucle googlekey ou URL de page erronée Vérifiez les paramètres du formulaire cible
Timeouts groupés sur une plage horaire Saturation de l'API à ce moment-là Corrélez avec votre allocation de threads
Majorité d'erreurs réseau Proxys instables Contrôlez la santé de vos proxys

Côté RGPD : la DLQ n'a besoin que de métadonnées techniques (sitekey, URL, code d'erreur, horodatage). N'y stockez jamais de données personnelles collectées sur la page — c'est inutile au rejeu et cela alourdit vos obligations de conformité.


Dépannage

Problème Cause Correctif
La DLQ grossit indéfiniment Les tentatives ne sont jamais traitées Planifiez une vidange périodique avec retry_dlq()
La même tâche est rejouée sans fin Aucun plafond de tentatives Vérifiez task.attempts avant de la remettre en file
Fichier DLQ corrompu Écritures concurrentes Utilisez un verrouillage de fichier ou passez à Redis / base de données
Tâches perdues après un crash DLQ en mémoire uniquement Basculez sur une DLQ fichier ou Redis

FAQ

Comment éviter qu'une même tâche tourne en boucle dans la DLQ ?

Fixez un plafond cumulé de tentatives et testez-le avant chaque remise en file. Dans les exemples ci-dessus, task.attempts >= dlq.max_retries + max_retries marque la tâche comme définitivement en échec au lieu de la rejouer indéfiniment.

Quelles informations faut-il conserver pour chaque tâche, côté RGPD ?

Uniquement des métadonnées techniques : sitekey, URL de la page, code d'erreur, nombre de tentatives et horodatage. C'est suffisant pour rejouer la résolution. Évitez d'y placer la moindre donnée personnelle issue de la page ciblée et minimisez ce que vous journalisez.

À quelle fréquence faut-il vider la DLQ ?

Déclenchez le rejeu peu après chaque lot, puis planifiez un passage régulier (toutes les quelques minutes ou en tâche cron) pour un service de longue durée. Au-delà de 6 échecs cumulés, les paramètres sont probablement en cause : enregistrez la tâche et passez à la suivante.

Peut-on combiner la DLQ avec un disjoncteur (circuit breaker) ?

Oui, et les deux sont complémentaires. Le disjoncteur coupe l'envoi de requêtes pendant une panne, tandis que la DLQ récupère les tâches qui échouent avant le déclenchement du circuit. Voir le modèle de disjoncteur pour l'API CAPTCHA.


Ne perdez plus aucune tâche CAPTCHA avec CaptchaAI

Récupérez votre clé API sur captchaai.com et branchez une DLQ dès votre premier lot de résolutions.


Guides associés

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