Fällt ein Worker aus, während er gerade ein reCAPTCHA v2 oder ein Cloudflare Turnstile löst, entscheidet ein einziger Punkt darüber, ob Sie den Vorfall in Minuten oder in Stunden überstehen: ob die laufende Aufgabe die Störung übersteht. Genau das leistet ein Disaster-Recovery-Plan (DR). Er hält in Bearbeitung befindliche Aufgaben persistent, begrenzt den Datenverlust auf einen klar definierten Wert und bringt den Dienst nach Infrastrukturausfällen, API-Störungen oder Fehlkonfigurationen kontrolliert wieder ans Netz.
DR beginnt dabei nicht erst beim kompletten Blackout. Schon ein abgestürzter Worker, eine beschädigte Warteschlange oder ein fehlerhafter Secret-Rollout genügen, damit Aufgaben verloren gehen, doppelt gelöst werden oder Backlogs über Stunden anwachsen. Drei Bausteine halten das auf:
- eine persistente Aufgabenspeicherung, die Abstürze übersteht,
- ein einsatzbereites Failover in eine zweite Region,
- ein erprobtes Runbook statt einer Folie in der Schublade.
Wiederherstellungsziele definieren: RPO, RTO und MTTR
Bevor Sie Technik bauen, legen Sie messbare Ziele fest. Ohne Zahlen bleibt „ausfallsicher“ ein Gefühl statt einer Vorgabe, an der Sie Architektur und Checkpoint-Frequenz ausrichten können. Drei Kennzahlen genügen für eine CAPTCHA-Pipeline:
| Kennzahl | Definition | Ziel für die CAPTCHA-Pipeline |
|---|---|---|
| RPO (Recovery Point Objective) | Maximal tolerierbarer Datenverlust | < 5 Minuten an Aufgaben in der Warteschlange |
| RTO (Recovery Time Objective) | Maximale Zeit bis zur Wiederherstellung des Dienstes | < 15 Minuten |
| MTTR (mittlere Wiederherstellungszeit) | Durchschnittliche Erholungszeit über alle Vorfälle | < 10 Minuten |
Ein RPO von unter 5 Minuten bedeutet konkret: Sie dürfen im schlimmsten Fall fünf Minuten an eingereihten Aufgaben verlieren – nicht mehr. Diese Zahl bestimmt später, wie oft Sie Checkpoints schreiben und wie eng Sie Ihre Warteschlange sichern. Das RTO hält fest, wie schnell wieder gelöst werden muss; die MTTR misst über viele Vorfälle hinweg, ob Ihr Recovery-Prozess in der Praxis hält, was das Runbook verspricht.
Woran Sie erkennen, dass DR operativ zählt
Nicht jede Pipeline braucht dieselbe Tiefe. Die folgenden Signale zeigen, dass Disaster Recovery bei Ihnen kein Papierkonzept bleiben darf, sondern echten Betriebswert hat:
| Signal | Was es bedeutet |
|---|---|
| Aufgaben bleiben nach Worker-Neustarts in unklarem Zustand | Der Persistenz- oder Recovery-Pfad ist zu schwach |
| Nach Zwischenfällen tauchen doppelte Lösungen auf | Idempotenz und Wiederaufnahme sind nicht sauber geklärt |
| Ein kleiner regionaler oder Netzwerkfehler legt den gesamten Durchsatz lahm | Failover existiert theoretisch, ist aber nicht betriebsbereit |
| RTO und RPO stehen nur auf einer Folie | Das Runbook ist noch nicht in reale Abläufe übersetzt |
Wer seine Worker etwa bei Hetzner in Nürnberg und Falkenstein oder bei netcup betreibt, kann eine zweite Region als Failover-Ziel vorhalten und per GitLab CI identisch bereitstellen. Sobald persistierte Aufgaben-Payloads IP-Adressen oder andere personenbezogene Daten enthalten, gehören DSGVO-Grundlage und Aufbewahrungsdauer von Anfang an in dieselbe Planung – nicht als nachträglicher Zusatz.
Die häufigsten Ausfallszenarien
Fünf Störungsbilder decken den Großteil realer Vorfälle ab. Für jedes gehört eine eindeutige Reaktion ins Runbook – vom einfachen Worker-Neustart bis zum Regions-Failover. Der Wert liegt weniger in der Liste als darin, jede Zeile vorab durchgespielt zu haben:
Scenario 1: Worker crash → Restart workers, replay queue
Scenario 2: Queue data loss → Restore from persistent backup
Scenario 3: Network partition → Failover to secondary region
Scenario 4: API key compromised → Rotate key, update workers
Scenario 5: Config corruption → Rollback to last known good
Aufgaben crashsicher persistieren
Lösen Sie CAPTCHAs niemals aus einer reinen In-Memory-Warteschlange. Sobald der Prozess abstürzt, ist jede noch nicht abgeschlossene Aufgabe verloren – und mit ihr das RPO. Schreiben Sie Aufgaben stattdessen auf eine dauerhafte Ablage:
- SQLite auf einem einzelnen Knoten – einfach und ohne zusätzliche Infrastruktur.
- Redis mit AOF-Persistenz für verteilte Setups mit mehreren Workern.
Entscheidend ist der Zustandsübergang: Eine Aufgabe wird beim Herausnehmen als processing markiert und erst nach bestätigter Lösung auf completed gesetzt. Bleibt sie nach einem Absturz in processing hängen, holt der Recovery-Lauf sie zurück in die Warteschlange.
Python: persistente Aufgabenwarteschlange
import os
import json
import time
import sqlite3
import threading
import requests
from datetime import datetime
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
class PersistentTaskQueue:
"""SQLite-backed task queue that survives crashes."""
def __init__(self, db_path="captcha_tasks.db"):
self.db_path = db_path
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.lock = threading.Lock()
self._init_db()
def _init_db(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
completed_at TEXT,
result TEXT,
attempts INTEGER DEFAULT 0
)
""")
self.conn.commit()
def enqueue(self, task_id, payload):
with self.lock:
self.conn.execute(
"INSERT INTO tasks (id, payload) VALUES (?, ?)",
(task_id, json.dumps(payload))
)
self.conn.commit()
def dequeue(self):
with self.lock:
cursor = self.conn.execute(
"SELECT id, payload FROM tasks "
"WHERE status = 'pending' ORDER BY created_at LIMIT 1"
)
row = cursor.fetchone()
if not row:
return None
task_id, payload = row
self.conn.execute(
"UPDATE tasks SET status = 'processing', "
"started_at = ?, attempts = attempts + 1 WHERE id = ?",
(datetime.utcnow().isoformat(), task_id)
)
self.conn.commit()
return {"id": task_id, "payload": json.loads(payload)}
def complete(self, task_id, result):
with self.lock:
self.conn.execute(
"UPDATE tasks SET status = 'completed', "
"completed_at = ?, result = ? WHERE id = ?",
(datetime.utcnow().isoformat(), json.dumps(result), task_id)
)
self.conn.commit()
def fail(self, task_id, error):
with self.lock:
# Requeue if under retry limit
cursor = self.conn.execute(
"SELECT attempts FROM tasks WHERE id = ?", (task_id,)
)
row = cursor.fetchone()
if row and row[0] < 3:
self.conn.execute(
"UPDATE tasks SET status = 'pending' WHERE id = ?",
(task_id,)
)
else:
self.conn.execute(
"UPDATE tasks SET status = 'failed', "
"result = ? WHERE id = ?",
(json.dumps({"error": error}), task_id)
)
self.conn.commit()
def recover_stale(self, timeout_seconds=600):
"""Reset tasks stuck in 'processing' after a crash."""
with self.lock:
cutoff = datetime.utcnow().timestamp() - timeout_seconds
self.conn.execute(
"UPDATE tasks SET status = 'pending' "
"WHERE status = 'processing' "
"AND started_at < datetime(?, 'unixepoch')",
(cutoff,)
)
count = self.conn.total_changes
self.conn.commit()
return count
@property
def stats(self):
cursor = self.conn.execute(
"SELECT status, COUNT(*) FROM tasks GROUP BY status"
)
return dict(cursor.fetchall())
# On startup: recover tasks that were processing during a crash
queue = PersistentTaskQueue()
recovered = queue.recover_stale(timeout_seconds=600)
print(f"Recovered {recovered} stale tasks after restart")
JavaScript: Checkpoints setzen und Batches wiederaufnehmen
Der folgende Recovery-Manager schreibt in regelmäßigen Abständen Checkpoints und liest beim Start den letzten Stand zurück. So setzt ein unterbrochener Batch genau dort auf, wo er stehen geblieben ist, statt bereits gelöste Aufgaben erneut an die API zu übermitteln – das schont Threads und hält die Wiederherstellungszeit niedrig. Der integrierte Health-Check gegen res.php prüft zusätzlich das Guthaben, bevor überhaupt neue Aufgaben eingereiht werden.
const axios = require("axios");
const fs = require("fs");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
class DisasterRecoveryManager {
constructor(checkpointDir = "./dr-checkpoints") {
this.checkpointDir = checkpointDir;
if (!fs.existsSync(checkpointDir)) {
fs.mkdirSync(checkpointDir, { recursive: true });
}
}
checkpoint(label, data) {
const filename = `${this.checkpointDir}/${label}-${Date.now()}.json`;
fs.writeFileSync(filename, JSON.stringify(data, null, 2));
this.pruneOldCheckpoints(label, 10); // Keep last 10
return filename;
}
restore(label) {
const files = fs.readdirSync(this.checkpointDir)
.filter((f) => f.startsWith(label) && f.endsWith(".json"))
.sort()
.reverse();
if (files.length === 0) return null;
const latest = fs.readFileSync(
`${this.checkpointDir}/${files[0]}`, "utf8"
);
return JSON.parse(latest);
}
pruneOldCheckpoints(label, keep) {
const files = fs.readdirSync(this.checkpointDir)
.filter((f) => f.startsWith(label) && f.endsWith(".json"))
.sort();
while (files.length > keep) {
const old = files.shift();
fs.unlinkSync(`${this.checkpointDir}/${old}`);
}
}
async healthCheck() {
try {
const resp = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "getbalance", json: 1 },
timeout: 10000,
});
return {
healthy: resp.data.status === 1,
balance: parseFloat(resp.data.request || 0),
};
} catch (err) {
return { healthy: false, error: err.message };
}
}
}
class ResilientSolver {
constructor() {
this.dr = new DisasterRecoveryManager();
this.pendingTasks = [];
}
async solveBatch(tasks) {
// Checkpoint before starting
this.dr.checkpoint("batch-pending", {
tasks,
startedAt: new Date().toISOString(),
});
const results = [];
for (const task of tasks) {
try {
const result = await this.solveSingle(task);
results.push({ taskId: task.id, ...result });
} catch (err) {
results.push({ taskId: task.id, error: err.message });
}
// Checkpoint progress periodically
if (results.length % 10 === 0) {
this.dr.checkpoint("batch-progress", { results, remaining: tasks.length - results.length });
}
}
// Final checkpoint
this.dr.checkpoint("batch-complete", { results });
return results;
}
async recover() {
// Check for incomplete batch
const progress = this.dr.restore("batch-progress");
const pending = this.dr.restore("batch-pending");
if (progress) {
const completedIds = new Set(progress.results.map((r) => r.taskId));
const remaining = pending?.tasks.filter((t) => !completedIds.has(t.id));
console.log(
`Recovering: ${progress.results.length} done, ${remaining?.length || 0} remaining`
);
return remaining || [];
}
if (pending) {
console.log(`Recovering full batch: ${pending.tasks.length} tasks`);
return pending.tasks;
}
return [];
}
async solveSingle(task) {
const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: {
key: API_KEY,
method: "userrecaptcha",
googlekey: task.sitekey,
pageurl: task.pageurl,
json: 1,
},
});
if (resp.data.status !== 1) throw new Error(resp.data.request);
const captchaId = resp.data.request;
for (let i = 0; i < 60; 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: captchaId, json: 1 },
});
if (poll.data.status === 1) return { solution: poll.data.request };
if (poll.data.request !== "CAPCHA_NOT_READY")
throw new Error(poll.data.request);
}
throw new Error("TIMEOUT");
}
}
// Start with recovery check
const solver = new ResilientSolver();
solver.recover().then((remaining) => {
if (remaining.length > 0) {
console.log(`Resuming ${remaining.length} tasks from checkpoint`);
solver.solveBatch(remaining);
}
});
Runbook für den Ernstfall
Ein Runbook ist erst dann etwas wert, wenn es unter Stress ohne langes Nachdenken funktioniert. Die folgende Vorlage führt von der Erkennung über die Bewertung bis zur Wiederherstellung und zum Post-Mortem. Kopieren Sie sie in Ihr Wiki, füllen Sie die Platzhalter mit Ihren konkreten Alarmkanälen und Befehlen und spielen Sie den Ablauf mindestens einmal pro Quartal als Übung durch – ein Runbook, das nie geprobt wurde, versagt genau im Ernstfall:
RUNBOOK: CAPTCHA Pipeline Recovery
====================================
1. DETECT
- Alert fires: [PagerDuty / Slack / Email]
- Symptom: [Queue growing / Workers offline / Error spike]
2. ASSESS
- Check worker health: curl http://workers/health
- Check API status: GET /res.php?action=getbalance
- Check queue depth: SELECT COUNT(*) FROM tasks WHERE status='pending'
3. RECOVER
If: Workers crashed
→ Restart worker containers: docker-compose up -d workers
→ Run stale task recovery: recovery.py --recover-stale
If: Network partition
→ Failover to secondary region
→ Update DNS or load balancer routing
If: API key compromised
→ Generate new key at captchaai.com
→ Update secret store
→ Rolling restart workers
4. VERIFY
- Confirm solve rate > 90%
- Confirm queue draining
- Confirm no duplicate solves
5. POST-MORTEM
- Document root cause
- Update runbook if needed
Fehlerbehebung im Recovery-Betrieb
| Problem | Ursache | Lösung |
|---|---|---|
| Worker ist erreichbar, verarbeitet aber keine Aufgaben | Warteschlange, Credentials oder Eingabestrom passen nicht zusammen | Prüfen Sie Queue-Tiefe, API-Schlüssel, Health-Checks und Fehlerraten pro Worker gemeinsam |
| Doppelte Lösungen nach der Wiederherstellung | Stehengebliebene Aufgaben werden ohne Deduplizierung erneut verarbeitet | Vergeben Sie Idempotenzschlüssel und prüfen Sie vor der Übermittlung, ob bereits ein Ergebnis vorliegt |
| Fehlerrate steigt nach einem Rollout | Die neue Version verändert Session-, Proxy- oder Retry-Verhalten | Vergleichen Sie erfolgreiche und fehlschlagende Runs zwischen alter und neuer Version und rollen Sie bei Bedarf zurück |
| Wiederherstellung dauert länger als das RTO | Das Backup der Warteschlange ist zu alt | Erhöhen Sie die Checkpoint-Frequenz, bis Sie das RPO-Ziel wieder einhalten |
| Failover landet in der falschen Region | DNS-TTL ist zu hoch | Senken Sie die TTL vor geplanten Failovern auf 60 Sekunden |
Häufige Fragen
Worin unterscheiden sich RPO und RTO konkret?
RPO begrenzt den Datenverlust, RTO die Ausfallzeit. Für eine CAPTCHA-Pipeline heißt das: Ein RPO unter 5 Minuten erlaubt höchstens fünf Minuten an verlorenen Warteschlangen-Aufgaben; ein RTO unter 15 Minuten legt fest, dass der Dienst spätestens nach einer Viertelstunde wieder Aufgaben verarbeitet. Das RPO treibt Ihre Checkpoint-Frequenz, das RTO Ihre Failover- und Neustart-Automatisierung.
Wie verhindere ich doppelte Lösungen nach einem Neustart?
Über Idempotenz. Vergeben Sie pro Aufgabe einen stabilen Schlüssel und prüfen Sie vor jeder Übermittlung, ob bereits ein Ergebnis vorliegt. Eine Aufgabe, die vor dem Absturz schon gelöst wurde, geht so nach der Wiederaufnahme nicht erneut an die API – das spart Threads und hält den Ergebnis-Store sauber. Der Recovery-Lauf setzt hängengebliebene processing-Aufgaben gezielt zurück, statt blind alles neu einzureihen.
Was kostet eine ausfallsichere CAPTCHA-Pipeline bei CaptchaAI?
CaptchaAI rechnet pro gleichzeitigem Thread ab, nicht pro Lösung; jeder Tarif enthält unbegrenzte Lösungen pro Thread. Für Redundanz zählt vor allem die Thread-Anzahl: BASIC beginnt bei 15 $/Monat mit 5 Threads, ADVANCE bietet 50 Threads für 90 $/Monat. Eine zweite Region erhöht die CAPTCHA-Kosten nicht zwangsläufig, da sich die Abrechnung an der gleichzeitigen Thread-Auslastung orientiert und nicht an der Anzahl der Worker. Preise werden in US-Dollar ausgewiesen.
Was passiert, wenn CaptchaAI selbst nicht erreichbar ist?
Halten Sie die Aufgaben in der persistenten Warteschlange und übermitteln Sie sie erneut, sobald die API wieder antwortet. Ein Circuit Breaker unterbricht die Aufrufe dann kontrolliert, statt jeden Worker in Timeouts laufen zu lassen; die Wiederholungslogik nimmt den Betrieb automatisch wieder auf, sobald der Health-Check erneut grün meldet.
Verwandte Leitfäden
- High-Availability und Failover für die CAPTCHA-Lösung
- Circuit-Breaker-Muster für CAPTCHA-API-Aufrufe
- Architektur für Multi-Region-CAPTCHA-Lösung