Sur un worker Node.js, la vraie question n'est pas de savoir si les appels de résolution vont échouer, mais lesquels méritent une nouvelle tentative et lesquels doivent arrêter le pipeline. CAPCHA_NOT_READY se retente en boucle ; ERROR_ZERO_BALANCE ne se retente jamais.
Toute la robustesse tient dans cette séparation. Les couches se présentent ici dans l'ordre où elles s'ajoutent en production : classification des codes, backoff exponentiel, circuit breaker, durée de vie des tokens, métriques.
Classer les erreurs de l'API : retriable ou fatale
Un code retriable décrit un état temporaire du service ; un code fatal décrit un problème qui vous appartient — clé API invalide, solde vide, paramètres mal formés. Retenter un code fatal immobilise un thread sans rien réparer.
const RETRIABLE_ERRORS = new Set([
"ERROR_NO_SLOT_AVAILABLE",
"CAPCHA_NOT_READY",
]);
const FATAL_ERRORS = new Set([
"ERROR_WRONG_USER_KEY",
"ERROR_KEY_DOES_NOT_EXIST",
"ERROR_ZERO_BALANCE",
"ERROR_CAPTCHA_UNSOLVABLE",
"ERROR_BAD_DUPLICATES",
"ERROR_BAD_PARAMETERS",
"ERROR_WRONG_CAPTCHA_ID",
]);
class CaptchaError extends Error {
constructor(code, message) {
super(message || code);
this.name = "CaptchaError";
this.code = code;
}
}
class RetriableError extends CaptchaError {
constructor(code) {
super(code, `Retriable: ${code}`);
this.name = "RetriableError";
}
}
class FatalError extends CaptchaError {
constructor(code) {
super(code, `Fatal: ${code}`);
this.name = "FatalError";
}
}
function classifyError(code) {
if (FATAL_ERRORS.has(code)) throw new FatalError(code);
throw new RetriableError(code);
}
classifyError traite tout code inconnu comme retriable : si l'API en ajoute un demain, le worker ralentit au lieu de s'arrêter net. Et CAPCHA_NOT_READY s'écrit sans le « T » : c'est l'orthographe de res.php, pas une coquille.
| Code | Traitement |
|---|---|
CAPCHA_NOT_READY |
Continuer d'interroger res.php |
ERROR_NO_SLOT_AVAILABLE |
Attendre, puis renvoyer la tâche |
ERROR_ZERO_BALANCE |
Arrêter et alerter |
ERROR_WRONG_USER_KEY |
Vérifier la configuration |
Backoff exponentiel et jitter
Un retry immédiat sur un service saturé aggrave la saturation. Le backoff espace les tentatives — 2 s, 4 s, 8 s — et maxDelay les plafonne à 30 s. Le jitter, lui, évite que dix processus qui échouent au même instant retentent au même instant.
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function withRetry(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 2000,
maxDelay = 30000,
jitter = true,
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error instanceof FatalError) throw error;
lastError = error;
if (attempt < maxRetries) {
let delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
if (jitter) delay *= 0.5 + Math.random();
console.log(
`Retry ${attempt + 1}/${maxRetries} in ${(delay / 1000).toFixed(1)}s: ${error.message}`
);
await sleep(delay);
}
}
}
throw lastError;
}
La ligne décisive est if (error instanceof FatalError) throw error, en tête du catch : sans elle, une clé invalide traverserait quatre tentatives avant de remonter.
Un solveur Node.js qui sépare soumission et polling
La soumission vers in.php est un appel court : elle réussit ou renvoie un code exploitable tout de suite. L'interrogation de res.php est une attente longue, où l'erreur la plus fréquente n'en est pas une. Chaque requête porte son AbortSignal.timeout(30000) : sans ce garde-fou, un fetch bloqué immobilise le worker, et maxPollTime n'est évalué qu'entre deux itérations.
const API_KEY = "YOUR_API_KEY";
class RobustSolver {
#apiKey;
#maxRetries;
#pollInterval;
#maxPollTime;
constructor(apiKey, options = {}) {
this.#apiKey = apiKey;
this.#maxRetries = options.maxRetries ?? 3;
this.#pollInterval = options.pollInterval ?? 5000;
this.#maxPollTime = options.maxPollTime ?? 150000;
}
async solve(method, params) {
return withRetry(
() => this.#doSolve(method, params),
{ maxRetries: this.#maxRetries }
);
}
async #doSolve(method, params) {
const taskId = await this.#submit(method, params);
return await this.#poll(taskId);
}
async #submit(method, params) {
for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
try {
const resp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams({
key: this.#apiKey,
method,
json: "1",
...params,
}),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
throw new RetriableError(`HTTP_${resp.status}`);
}
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_NO_SLOT_AVAILABLE") {
if (attempt < this.#maxRetries) {
await sleep(3000 * (attempt + 1));
continue;
}
}
classifyError(data.request);
} catch (error) {
if (error instanceof FatalError) throw error;
if (error.name === "TimeoutError" || error.name === "AbortError") {
if (attempt < this.#maxRetries) {
await sleep(2000 * (attempt + 1));
continue;
}
}
throw error;
}
}
throw new RetriableError("MAX_SUBMIT_RETRIES");
}
async #poll(taskId) {
const start = Date.now();
while (Date.now() - start < this.#maxPollTime) {
await sleep(this.#pollInterval);
try {
const resp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: this.#apiKey,
action: "get",
id: taskId,
json: "1",
})}`,
{ signal: AbortSignal.timeout(30000) }
);
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "CAPCHA_NOT_READY") continue;
if (FATAL_ERRORS.has(data.request)) throw new FatalError(data.request);
} catch (error) {
if (error instanceof FatalError) throw error;
// Network errors during poll — keep trying
continue;
}
}
throw new CaptchaError("TIMEOUT", `Timed out after ${this.#maxPollTime}ms`);
}
}
Le pollInterval par défaut vaut 5 s ; descendre à 1 s multiplie les appels sans faire avancer la file.
Circuit breaker : couper quand l'API décroche
Le retry protège une tâche, le circuit breaker protège le pipeline. Quand l'endpoint ne répond plus, chaque tâche qui insiste occupe un thread : sur un plan ADVANCE ($90/mois, 50 threads), cinquante threads restent immobilisés une minute pour zéro token.
class CircuitBreaker {
#state = "closed"; // closed | open | half-open
#failures = 0;
#lastFailure = 0;
#threshold;
#resetTimeout;
constructor(threshold = 5, resetTimeout = 60000) {
this.#threshold = threshold;
this.#resetTimeout = resetTimeout;
}
get state() {
return this.#state;
}
canExecute() {
if (this.#state === "closed") return true;
if (this.#state === "open") {
if (Date.now() - this.#lastFailure > this.#resetTimeout) {
this.#state = "half-open";
return true;
}
return false;
}
return true; // half-open: allow test request
}
recordSuccess() {
this.#failures = 0;
this.#state = "closed";
}
recordFailure() {
this.#failures++;
this.#lastFailure = Date.now();
if (this.#failures >= this.#threshold) {
this.#state = "open";
console.log(`Circuit OPEN — pausing for ${this.#resetTimeout / 1000}s`);
}
}
}
class ProtectedSolver {
#solver;
#breaker;
constructor(apiKey) {
this.#solver = new RobustSolver(apiKey);
this.#breaker = new CircuitBreaker(5, 60000);
}
async solve(method, params) {
if (!this.#breaker.canExecute()) {
throw new CaptchaError(
"CIRCUIT_OPEN",
"API appears down — circuit breaker is open"
);
}
try {
const result = await this.#solver.solve(method, params);
this.#breaker.recordSuccess();
return result;
} catch (error) {
if (error instanceof FatalError) throw error;
this.#breaker.recordFailure();
throw error;
}
}
get circuitState() {
return this.#breaker.state;
}
}
Le compteur bascule sur open après cinq échecs consécutifs, rejette pendant 60 s, puis laisse une requête tester le terrain en half-open. Les FatalError ne l'incrémentent pas : une clé invalide n'est pas une panne du service.
Durée de vie des tokens : un cache, pas un stock
Le TokenCache expire par défaut à 110 000 ms, sous la fenêtre de validité côté reCAPTCHA ; Turnstile est plus généreux. Il ne s'agit pas de constituer une réserve à l'avance : le cache évite de repayer une résolution quand la même page repasse dans la fenêtre.
class TokenCache {
#cache = new Map();
#defaultTTL;
constructor(defaultTTL = 110000) {
// reCAPTCHA: ~2 min, Turnstile: ~5 min
this.#defaultTTL = defaultTTL;
}
get(key) {
const entry = this.#cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.#defaultTTL) {
this.#cache.delete(key);
return null;
}
return entry.token;
}
set(key, token) {
this.#cache.set(key, { token, timestamp: Date.now() });
}
invalidate(key) {
this.#cache.delete(key);
}
}
class CachedSolver {
#solver;
#cache;
constructor(apiKey) {
this.#solver = new ProtectedSolver(apiKey);
this.#cache = new TokenCache(110000);
}
async getToken(cacheKey, method, params) {
const cached = this.#cache.get(cacheKey);
if (cached) return cached;
const token = await this.#solver.solve(method, params);
this.#cache.set(cacheKey, token);
return token;
}
async solveWithRetryOnReject(method, params, submitFn, maxAttempts = 2) {
for (let i = 0; i < maxAttempts; i++) {
const token = await this.#solver.solve(method, params);
const accepted = await submitFn(token);
if (accepted) return token;
console.log(`Token rejected (attempt ${i + 1}), re-solving...`);
}
throw new CaptchaError("TOKEN_REJECTED", "Token rejected after max attempts");
}
}
solveWithRetryOnReject couvre le cas que les logs de l'API ne montrent jamais : le token est arrivé et le site l'a refusé. Deux tentatives suffisent ; au-delà, cherchez le défaut dans le formulaire.
Métriques : voir la dégradation avant la panne
Trois chiffres pilotent un pipeline de résolution : le taux de réussite, le temps de résolution moyen et le débit. Le quatrième, le nombre de retries, est celui qui prévient : sa hausse à taux de réussite constant annonce une dégradation avant tout échec visible.
class SolverMetrics {
#startTime = Date.now();
#solveTimes = [];
#counts = { submitted: 0, solved: 0, failed: 0, retries: 0 };
recordSubmit() { this.#counts.submitted++; }
recordSolved(duration) { this.#counts.solved++; this.#solveTimes.push(duration); }
recordFailed() { this.#counts.failed++; }
recordRetry() { this.#counts.retries++; }
report() {
const elapsed = (Date.now() - this.#startTime) / 1000;
const total = this.#counts.solved + this.#counts.failed;
const avgTime = this.#solveTimes.length > 0
? this.#solveTimes.reduce((a, b) => a + b, 0) / this.#solveTimes.length / 1000
: 0;
return {
elapsed: `${elapsed.toFixed(0)}s`,
submitted: this.#counts.submitted,
solved: this.#counts.solved,
failed: this.#counts.failed,
retries: this.#counts.retries,
avgSolveTime: `${avgTime.toFixed(1)}s`,
successRate: total > 0 ? `${((this.#counts.solved / total) * 100).toFixed(1)}%` : "N/A",
throughput: `${(this.#counts.solved / (elapsed / 60)).toFixed(1)}/min`,
};
}
}
class InstrumentedSolver {
#solver;
#metrics;
constructor(apiKey) {
this.#solver = new ProtectedSolver(apiKey);
this.#metrics = new SolverMetrics();
}
async solve(method, params) {
this.#metrics.recordSubmit();
const start = Date.now();
try {
const token = await this.#solver.solve(method, params);
this.#metrics.recordSolved(Date.now() - start);
return token;
} catch (error) {
this.#metrics.recordFailed();
throw error;
}
}
report() {
return this.#metrics.report();
}
}
Exposez ces compteurs plutôt que de les imprimer ; un endpoint /metrics suffit. Côté RGPD, ce sont des agrégats sans donnée personnelle : si vous ajoutez l'URL cible ou le token aux logs, tronquez-les et fixez une conservation courte.
Le modèle de production complet en Node.js
Les couches s'empilent dans un seul ordre : le cache interroge le solveur protégé, qui interroge le solveur robuste, qui gère retry et polling. Promise.allSettled referme l'ensemble sans qu'un rejet fasse tomber le lot.
// Combine everything
const solver = new InstrumentedSolver("YOUR_API_KEY");
async function main() {
const tasks = Array.from({ length: 10 }, (_, i) => ({
method: "userrecaptcha",
params: { googlekey: `KEY_${i}`, pageurl: `https://example.com/${i}` },
}));
const results = await Promise.allSettled(
tasks.map((task) => solver.solve(task.method, task.params))
);
const solved = results.filter((r) => r.status === "fulfilled");
const failed = results.filter((r) => r.status === "rejected");
console.log(`Solved: ${solved.length}, Failed: ${failed.length}`);
console.log("Metrics:", solver.report());
for (const fail of failed) {
console.log(` Error: ${fail.reason.message}`);
}
}
main();
Alignez la taille du lot sur vos threads : dix tâches simultanées tiennent sur un plan STANDARD ($30/mois, 15 threads), tandis que deux cents tâches sur un plan BASIC ($15/mois, 5 threads) produisent surtout des ERROR_NO_SLOT_AVAILABLE. Le thread plafonne la concurrence, pas le volume.
En pratique : un lot nocturne chez OVHcloud
Un traitement de nuit qui rejoue les formulaires de votre propre application, sur une instance OVHcloud ou Scaleway, change deux réglages. Personne ne surveille un job nocturne : le circuit breaker et une alerte sur ERROR_ZERO_BALANCE cessent d'être optionnels. Et la fenêtre est bornée — si le lot doit finir avant 6 h, dimensionnez-le ainsi : temps de résolution médian × tâches ÷ threads, plus 20 % pour les nouvelles tentatives.
Dépannage
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Les tentatives échouent en quelques millisecondes | Erreur fatale dans la boucle | Vérifiez le contenu de FATAL_ERRORS |
| Le circuit reste ouvert | Endpoint injoignable ou clé invalide | Testez un appel manuel à in.php |
| Token refusé à la soumission | Résolution et latence dépassent la validité | Résolvez juste avant l'envoi |
ERROR_NO_SLOT_AVAILABLE en rafale |
Concurrence supérieure aux threads | Bornez le lot au nombre de threads |
Questions fréquentes
Que faire si le site refuse un token valide ?
Relancez une résolution, une seule fois. Si le second token est refusé lui aussi, la cause est en amont : sitekey erroné, page différente de celle déclarée, ou action reCAPTCHA v3 incohérente.
Quand faut-il abandonner un polling ?
Le budget par défaut est de 150 000 ms, soit 2 min 30. C'est confortable pour les grilles d'images ; pour Cloudflare Turnstile ou reCAPTCHA v2, dépasser 60 s signale déjà une anomalie.
Le circuit breaker doit-il être partagé entre workers ?
Idéalement oui. Un breaker en mémoire ne protège qu'un processus : avec dix conteneurs, l'API reçoit encore neuf flux d'appels pendant la panne. Un compteur dans Redis règle le problème.
Les codes changent-ils selon le type de CAPTCHA ?
Non. Ils sont identiques pour reCAPTCHA v2 et v3, Cloudflare Turnstile et Challenge, GeeTest v3 et les CAPTCHA image/OCR ; seul method change. hCaptcha et FunCaptcha (Arkose Labs) ne sont pas pris en charge, GeeTest v4 est annoncé à venir. CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta) suivent la même mécanique.
En résumé
Un pipeline Node.js robuste avec CaptchaAI tient en cinq décisions : trier les codes, espacer les tentatives avec backoff et jitter, ouvrir un circuit breaker quand le service décroche, respecter la durée de vie des tokens et surveiller le nombre de retries.