Tutorials

Node.js CAPTCHA-Lösung mit Wiederholungsversuch und Fehlerbehandlung

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:

  1. Fehler klassifizieren statt pauschal wiederholen.
  2. Mit Backoff und Jitter zurückkommen.
  3. Serienfehler per Circuit Breaker stoppen.
  4. Token innerhalb ihrer Gültigkeit einsetzen.
  5. Den Betrieb über Metriken messbar machen.

Mit CaptchaAI bleibt dann eine Größe zu planen: die Zahl der Threads.

Weiterführende Artikel

Kommentare sind für diesen Artikel deaktiviert.