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
- Le modèle de disjoncteur pour les appels d'API CAPTCHA
- Mettre en place une logique de nouvelle tentative pour l'API CaptchaAI
- File Redis + CaptchaAI : traitement distribué