Eine CAPTCHA-Integration in Node.js wird nicht dadurch stabil, dass sie es öfter versucht. Stabil wird sie, sobald jeder Fehlercode beim Eintreffen in „wiederholbar“ oder „fatal“ einsortiert wird: Nur die erste Gruppe geht mit exponentiellem Backoff und Jitter zurück an die API, und häufen sich Fehler in Serie, stoppt ein Circuit Breaker die Pipeline, statt weiter Threads zu belegen.
Der Rest ist Buchhaltung: Token laufen ab, HTTP-Timeouts sind etwas anderes als Lösungs-Timeouts, und ohne Metriken fällt eine steigende Fehlerquote erst auf, wenn der Nacht-Job leer zurückkommt.
Schritt 1: Fehlercodes in wiederholbar und fatal trennen
Die API antwortet auch im Fehlerfall mit HTTP 200 und transportiert den Zustand im JSON-Feld request – ein try/catch um fetch genügt nicht. Wiederholbar sind nur zwei Codes: ERROR_NO_SLOT_AVAILABLE (alle Threads belegt) und CAPCHA_NOT_READY (die Lösung läuft noch; die Schreibweise ohne „T“ stammt aus der API). Alles rund um Schlüssel, Guthaben und Parameter ist Konfiguration – hier verdeckt ein erneuter Versuch nur die Ursache.
| Fehlercode | Richtige Reaktion |
|---|---|
ERROR_NO_SLOT_AVAILABLE |
Kurz warten, erneut übermitteln |
CAPCHA_NOT_READY |
Weiter abfragen |
ERROR_ZERO_BALANCE |
Abbrechen, Alarm auslösen |
ERROR_WRONG_USER_KEY |
Abbrechen, Konfiguration prüfen |
ERROR_CAPTCHA_UNSOLVABLE |
Abbrechen, Typ und Parameter prüfen |
ERROR_BAD_PARAMETERS |
Abbrechen, sitekey und pageurl prüfen |
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);
}
Schritt 2: Exponentielles Backoff mit Jitter
Ein starrer Wiederholungsabstand ist in verteilten Workern das ungünstigste Muster: Laufen zehn Prozesse gleichzeitig in einen Fehler, kommen sie auch gleichzeitig zurück. Der Jitter multipliziert die Wartezeit mit einem Zufallsfaktor zwischen 0,5 und 1,5. Mit baseDelay von 2.000 ms wartet der Worker rund 2 s, 4 s und 8 s, gedeckelt bei 30 s – drei Wiederholungen kosten damit im Mittel rund 14 s. Entscheidend ist die erste Zeile im catch: Ein FatalError verlässt die Schleife sofort.
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;
}
Schritt 3: Der robuste Solver – übermitteln, abfragen, abbrechen
Die API arbeitet zweistufig: in.php nimmt die Aufgabe entgegen und liefert eine Task-ID, res.php gibt das Ergebnis zurück. Beim Übermitteln zählt jeder Versuch einzeln, beim Abfragen das Gesamtbudget maxPollTime von 150.000 ms. Das ist bewusst großzügig: CaptchaAI löst Cloudflare Turnstile in unter 10 Sekunden, GeeTest v3 in unter 12 Sekunden und reCAPTCHA v2 in unter 60 Sekunden.
Trennen Sie diese Zeit strikt vom HTTP-Timeout: AbortSignal.timeout(30000) begrenzt eine einzelne Verbindung, nicht die Lösungsdauer.
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`);
}
}
Schritt 4: Circuit Breaker gegen Serienfehler
Wiederholungen helfen gegen Einzelfehler, nicht gegen einen Ausfall: Dann verlängern 3 Versuche pro Task nur die Zeit bis zum Scheitern. Der Circuit Breaker beobachtet deshalb die Fehlerfolge: closed im Normalbetrieb, open nach 5 Fehlern in Folge (60 Sekunden lang sofortige Ablehnung), half-open für genau eine Testanfrage. Entscheidend ist ProtectedSolver.solve: Fatale Fehler erhöhen den Fehlerzähler nicht – ein falscher API-Schlüssel würde sonst die Ursache hinter einer Pause verstecken.
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;
}
}
Schritt 5: Token-Lebensdauer statt Token-Vorrat
Ein gelöstes Token ist kein Vorrat, sondern ein Verfallsdatum. reCAPTCHA-Token sind typischerweise rund 120 Sekunden gültig, Turnstile-Token etwas länger; die TTL im Cache liegt deshalb knapp darunter bei 110.000 ms. Die Regel: Token direkt vor der Übermittlung lösen und sofort in das Formularfeld eintragen. Der Cache lohnt sich nur, wenn derselbe Sitekey innerhalb weniger Sekunden mehrfach gebraucht wird. Lehnt die Anwendung ein Token ab, ist ein zweiter Versuch sinnvoll; ab dem dritten stimmt meist etwas mit sitekey oder pageurl nicht.
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");
}
}
Schritt 6: Metriken, die im Betrieb wirklich etwas aussagen
Vier Zähler und eine Zeitreihe reichen: übermittelt, gelöst, gescheitert, wiederholt – plus die Lösungszeit pro Task. Loggen Sie immer den Fehlercode, nicht nur die Nachricht: ERROR_ZERO_BALANCE ist ein Ticket für die Buchhaltung, ERROR_NO_SLOT_AVAILABLE eines für die Kapazitätsplanung. Steigt das Verhältnis von Wiederholungen zu Lösungen bei stabiler Lösungszeit, ist die Parallelität zu hoch.
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();
}
}
Schritt 7: Alles zusammen im Batch
Jetzt greifen die Bausteine ineinander: InstrumentedSolver zählt mit, der ProtectedSolver schützt vor Serienfehlern, withRetry fängt einzelne Aussetzer ab. Wichtig ist Promise.allSettled statt Promise.all: Letzteres bricht beim ersten Rejection ab – bei zehn CAPTCHAs kostet ein fataler Fehler dann neun fertige Lösungen.
// 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();
Threads richtig dimensionieren – das halbe Retry-Problem
Ein großer Teil aller Wiederholungen ist hausgemacht: Der Worker startet 40 gleichzeitige Anfragen, der Plan erlaubt 15 Threads, die restlichen 25 laufen in ERROR_NO_SLOT_AVAILABLE.
CaptchaAI rechnet pro gleichzeitigem Thread ab, nicht pro Lösung; jeder Plan enthält unbegrenzte Lösungen pro Thread, und ein Thread ist genau ein CAPTCHA in Bearbeitung. Ihre Obergrenze ist damit der Plan:
| Plan | Preis pro Monat | Threads |
|---|---|---|
| BASIC | 15 $ | 5 |
| STANDARD | 30 $ | 15 |
| ADVANCE | 90 $ | 50 |
Preise in US-Dollar. Setzen Sie die Parallelität Ihres Workers genau auf diesen Wert.
Praxisbeispiel: nächtlicher QA-Lauf auf einem Hetzner-Worker
Ein typisches DACH-Setup: Ein Team testet seinen Shopware-Shop in der eigenen Staging-Umgebung unter https://staging.shop.example.test und startet nachts per GitLab CI einen Node.js-Job auf einem Hetzner-Cloud-Server. Daraus folgt:
- Parallelität 15, passend zu STANDARD – die Warteschlange puffert die übrigen Testfälle.
- Circuit Breaker auf 5 Fehler / 60 s: Fällt die Verbindung aus, bricht der Job kontrolliert ab.
- Metrik-Report in die CI-Ausgabe, damit der Trend zwischen zwei Nächten sichtbar wird.
Hinweis: IP-Adressen gelten nach DSGVO als personenbezogene Daten. Schreiben Ihre Logs Request-Metadaten mit, klären Sie vorab Rechtsgrundlage und Löschfristen.
Fehlerbehebung
| Symptom | Ursache | Lösung |
|---|---|---|
| Alle Wiederholungen scheitern sofort | Fataler Fehler wird wiederholt | FATAL_ERRORS prüfen |
| Circuit Breaker bleibt offen | API nicht erreichbar, Schlüssel ungültig | API-Status und YOUR_API_KEY prüfen |
| Token beim Absenden abgelaufen | Zu viel Zeit bis zum Formularversand | Token direkt vor der Übermittlung lösen |
AbortError bei fast jedem Request |
AbortSignal.timeout zu knapp |
Wert erhöhen; HTTP-Timeout ≠ Lösungszeit |
| Retry-Quote steigt, Lösungszeit stabil | Parallelität über der Thread-Zahl | Parallelität senken oder Plan wechseln |
Häufige Fragen
Wie viele Threads brauche ich, damit ERROR_NO_SLOT_AVAILABLE seltener auftritt?
So viele, wie Sie gleichzeitig offene Anfragen haben. Der Fehler meldet keine überlastete API, sondern belegte Threads Ihres Plans: BASIC bringt 5, STANDARD 15, ADVANCE 50. Begrenzen Sie die Parallelität auf diesen Wert.
Kostet jeder erneute Versuch zusätzliches Guthaben?
Nein. Abgerechnet wird Thread-basiert mit unbegrenzten Lösungen pro Thread; eine Gebühr pro Lösung gibt es nicht. Ein erneuter Versuch belegt nur kurz einen Thread; teuer ist nicht das Geld, sondern die blockierte Kapazität.
Wie lange bleibt ein gelöstes Token verwendbar?
reCAPTCHA-Token laufen typischerweise nach rund 120 Sekunden ab, Turnstile-Token halten länger. Planen Sie so, dass zwischen Antwort und Formularversand nur wenige Sekunden liegen.
Läuft der Code unverändert auf Node.js 18?
Ja. fetch und AbortSignal.timeout stehen ab Node.js 18 global zur Verfügung, private Klassenfelder wie #apiKey deutlich früher. Unter Node.js 16 brauchen Sie einen Polyfill wie undici.
Fazit
Robuste CAPTCHA-Verarbeitung in Node.js besteht aus fünf Entscheidungen:
- Fehler klassifizieren statt pauschal wiederholen.
- Mit Backoff und Jitter zurückkommen.
- Serienfehler per Circuit Breaker stoppen.
- Token innerhalb ihrer Gültigkeit einsetzen.
- Den Betrieb über Metriken messbar machen.
Mit CaptchaAI bleibt dann eine Größe zu planen: die Zahl der Threads.