Ein Callback ist keine Zustellgarantie. Sobald CaptchaAI das Ergebnis per Pingback an Ihre Callback-URL schickt, hängt die Zustellung davon ab, dass Ihr Server erreichbar ist, schnell genug antwortet und den Datensatz auch tatsächlich speichert. Fällt nur eine dieser Bedingungen aus, ist die Lösung weg – der Thread hat sie aber trotzdem berechnet. Die zuverlässige Antwort lautet: Behandeln Sie den Callback als schnellen Normalfall und legen Sie darunter ein zweites Sicherungsnetz.
Dieses Tutorial zeigt drei Muster, die genau das leisten, ohne dass eine einzige Lösung verloren geht:
- Fallback-Polling fragt jede Aufgabe aktiv ab, für die innerhalb eines Timeouts kein Callback eingetroffen ist.
- Eine Dead-Letter-Queue fängt Lösungen auf, deren Verarbeitung im Handler scheitert.
- Ein idempotenter Handler verarbeitet doppelte Zustellungen genau einmal.
Welche Fehler bei der Callback-Zustellung auftreten
| Fehlermodus | Symptom | Folge |
|---|---|---|
| Server nicht erreichbar | CaptchaAI erhält „connection refused" | Lösung wird nicht zugestellt |
| Server antwortet mit 5xx | CaptchaAI bekommt eine Fehlerantwort | Kein erneuter Versuch garantiert (implementierungsabhängig) |
| Netzwerk-Timeout | Die Verbindung von CaptchaAI hängt | Lösung geht möglicherweise verloren |
| Handler stürzt ab | Anfrage angenommen, Ergebnis aber nicht gespeichert | Lösung wird stillschweigend verworfen |
Die Konsequenz ist in allen vier Fällen dieselbe: Verlassen Sie sich nie allein auf den Callback. Sehen Sie immer einen Fallback vor.
Das ist kein rein akademisches Risiko. CaptchaAI rechnet pro Thread ab, nicht pro Lösung – ein belegter Thread hat die Abfrage bereits gelöst, selbst wenn das Ergebnis nie bei Ihnen ankommt. Schon der Einstiegstarif BASIC (15 $/Monat, 5 Threads) verarbeitet dauerhaft parallele Aufgaben; jede verlorene Zustellung bedeutet, dass ein Workflow ohne Not erneut anstößt. Robuste Fehlerbehandlung schützt also nicht nur Ihre Daten, sondern auch den Durchsatz, den Sie bezahlen.
Muster 1: Callback mit Fallback-Polling
Der stabilste Ansatz kombiniert beide Zustellwege: Sie nehmen Callbacks an, sobald sie eintreffen, und fragen zusätzlich alle Aufgaben aktiv ab, für die innerhalb eines Timeouts kein Callback kam. Der Callback bleibt der schnelle Normalweg, das Polling fängt jede verpasste Zustellung auf.
So greifen beide Wege ineinander:
- Sie übermitteln die Aufgabe mit
pingback-Parameter und vermerken sie lokal als offen. - Trifft der Callback ein, speichern Sie das Ergebnis und streichen die Aufgabe aus der Pending-Liste.
- Bleibt der Callback über das Timeout hinaus aus, holt der Poller das Ergebnis über
res.phpnach.
Python
import os
import time
import threading
import requests
from flask import Flask, request
app = Flask(__name__)
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
# Track task state
pending_tasks = {} # task_id -> {"submitted_at": timestamp, "status": "pending"}
results = {}
lock = threading.Lock()
def submit_captcha(sitekey, pageurl, callback_url):
"""Submit with callback, but track for fallback polling."""
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
"pingback": callback_url,
"json": 1
})
data = resp.json()
if data.get("status") == 1:
task_id = data["request"]
with lock:
pending_tasks[task_id] = {
"submitted_at": time.time(),
"status": "pending"
}
return task_id
return None
@app.route("/callback")
def captcha_callback():
"""Primary result delivery — CaptchaAI sends results here."""
task_id = request.args.get("id")
solution = request.args.get("code")
with lock:
results[task_id] = solution
pending_tasks.pop(task_id, None)
return "OK", 200
def fallback_poller():
"""Poll for any tasks that missed their callback."""
while True:
time.sleep(30) # Check every 30 seconds
with lock:
stale_tasks = [
tid for tid, info in pending_tasks.items()
if time.time() - info["submitted_at"] > 120 # 2 min callback timeout
and info["status"] == "pending"
]
for task_id in stale_tasks:
resp = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": 1
})
data = resp.json()
if data.get("status") == 1:
with lock:
results[task_id] = data["request"]
pending_tasks.pop(task_id, None)
print(f"Fallback poll recovered: {task_id}")
elif data.get("request") != "CAPCHA_NOT_READY":
# Permanent error — remove from pending
with lock:
pending_tasks.pop(task_id, None)
print(f"Task failed: {task_id} — {data.get('request')}")
# Start fallback poller in background
poller_thread = threading.Thread(target=fallback_poller, daemon=True)
poller_thread.start()
JavaScript
const express = require("express");
const axios = require("axios");
const app = express();
const API_KEY = process.env.CAPTCHAAI_API_KEY;
const pendingTasks = new Map(); // taskId -> { submittedAt, status }
const results = new Map();
async function submitCaptcha(sitekey, pageurl, callbackUrl) {
const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: {
key: API_KEY,
method: "userrecaptcha",
googlekey: sitekey,
pageurl: pageurl,
pingback: callbackUrl,
json: 1,
},
});
if (resp.data.status === 1) {
const taskId = resp.data.request;
pendingTasks.set(taskId, {
submittedAt: Date.now(),
status: "pending",
});
return taskId;
}
return null;
}
// Primary callback endpoint
app.get("/callback", (req, res) => {
const taskId = req.query.id;
const solution = req.query.code;
results.set(taskId, solution);
pendingTasks.delete(taskId);
res.sendStatus(200);
});
// Fallback poller
setInterval(async () => {
const now = Date.now();
const staleTasks = [];
for (const [taskId, info] of pendingTasks) {
if (now - info.submittedAt > 120000 && info.status === "pending") {
staleTasks.push(taskId);
}
}
for (const taskId of staleTasks) {
try {
const resp = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: taskId, json: 1 },
});
if (resp.data.status === 1) {
results.set(taskId, resp.data.request);
pendingTasks.delete(taskId);
console.log(`Fallback recovered: ${taskId}`);
} else if (resp.data.request !== "CAPCHA_NOT_READY") {
pendingTasks.delete(taskId);
console.log(`Task failed: ${taskId} — ${resp.data.request}`);
}
} catch (err) {
console.error(`Poll error for ${taskId}: ${err.message}`);
}
}
}, 30000);
app.listen(3000);
Wählen Sie das Timeout großzügig: Zwei Minuten (120 Sekunden) decken selbst langsame Lösungen plus die Latenz der Callback-Zustellung ab. Erst danach lohnt sich eine aktive Abfrage über res.php.
Muster 2: Dead-Letter-Queue für fehlgeschlagene Verarbeitung
Manchmal kommt der Callback an, aber die Verarbeitung scheitert: Die Datenbank ist gerade nicht erreichbar, eine Validierung schlägt fehl, ein nachgelagerter Dienst antwortet nicht. Statt die Lösung in diesem Moment zu verlieren, schreiben Sie sie in eine Dead-Letter-Queue (DLQ) und arbeiten sie später erneut ab. Wichtig: Bestätigen Sie CaptchaAI den Empfang trotzdem mit 200 – der Fehler liegt auf Ihrer Seite, nicht bei der Zustellung.
Python
import json
import os
import time
from pathlib import Path
DEAD_LETTER_DIR = Path("dead_letter")
DEAD_LETTER_DIR.mkdir(exist_ok=True)
@app.route("/callback")
def captcha_callback_with_dlq():
task_id = request.args.get("id")
solution = request.args.get("code")
try:
# Attempt normal processing
store_result(task_id, solution)
return "OK", 200
except Exception as e:
# Processing failed — save to dead-letter queue
dead_letter = {
"task_id": task_id,
"solution": solution,
"error": str(e),
"received_at": time.time()
}
dlq_path = DEAD_LETTER_DIR / f"{task_id}.json"
dlq_path.write_text(json.dumps(dead_letter))
print(f"DLQ: {task_id} — {e}")
return "OK", 200 # Still return 200 to CaptchaAI
def reprocess_dead_letters():
"""Retry processing dead-letter items."""
for dlq_file in DEAD_LETTER_DIR.glob("*.json"):
item = json.loads(dlq_file.read_text())
try:
store_result(item["task_id"], item["solution"])
dlq_file.unlink() # Remove after successful processing
print(f"DLQ reprocessed: {item['task_id']}")
except Exception:
pass # Leave in DLQ for next retry
JavaScript
const fs = require("fs");
const path = require("path");
const DLQ_DIR = path.join(__dirname, "dead_letter");
if (!fs.existsSync(DLQ_DIR)) fs.mkdirSync(DLQ_DIR);
app.get("/callback-dlq", (req, res) => {
const taskId = req.query.id;
const solution = req.query.code;
try {
storeResult(taskId, solution);
res.sendStatus(200);
} catch (err) {
// Save to dead-letter queue
const deadLetter = {
task_id: taskId,
solution: solution,
error: err.message,
received_at: Date.now(),
};
fs.writeFileSync(
path.join(DLQ_DIR, `${taskId}.json`),
JSON.stringify(deadLetter)
);
console.log(`DLQ: ${taskId} — ${err.message}`);
res.sendStatus(200); // Still acknowledge to CaptchaAI
}
});
function reprocessDeadLetters() {
const files = fs.readdirSync(DLQ_DIR).filter((f) => f.endsWith(".json"));
for (const file of files) {
const filePath = path.join(DLQ_DIR, file);
const item = JSON.parse(fs.readFileSync(filePath, "utf8"));
try {
storeResult(item.task_id, item.solution);
fs.unlinkSync(filePath);
console.log(`DLQ reprocessed: ${item.task_id}`);
} catch (err) {
// Leave in DLQ
}
}
}
// Retry DLQ every 5 minutes
setInterval(reprocessDeadLetters, 300000);
Der Reprozessor läuft dabei getrennt vom Callback-Handler – etwa als eigener Worker auf einem Hetzner- oder netcup-Server oder als geplanter Job in GitLab CI – und leert die Queue, sobald der zugrunde liegende Dienst wieder verfügbar ist.
DSGVO-Hinweis: Enthält der in der DLQ gespeicherte Datensatz personenbezogene Daten aus Ihrem Workflow, gelten dafür die üblichen DSGVO-Pflichten. Legen Sie Aufbewahrungsdauer und Löschkonzept für Ihre DLQ-Dateien fest, bevor der erste Datensatz auf der Platte landet.
Muster 3: Idempotenter Callback-Handler
Callbacks können mehrfach zugestellt werden – etwa wenn Ihr Fallback-Polling eine Aufgabe abholt, die parallel doch noch per Callback eintrifft. Ein idempotenter Handler verarbeitet jede Task-ID nur einmal und ignoriert Wiederholungen still:
@app.route("/callback")
def idempotent_callback():
task_id = request.args.get("id")
solution = request.args.get("code")
with lock:
# Only process if not already handled
if task_id in results:
return "OK", 200 # Already processed — skip silently
results[task_id] = solution
pending_tasks.pop(task_id, None)
return "OK", 200
Zwei Punkte entscheiden hier über die Korrektheit:
- Die Prüfung läuft innerhalb desselben Locks wie das Schreiben – sonst öffnet sich genau die Race Condition wieder, die der Handler eigentlich schließen soll.
- Auch bei einer erkannten Wiederholung antwortet der Handler mit 200; für CaptchaAI ist die Zustellung damit sauber quittiert und wird nicht erneut versucht.
Welches Muster passt zu welchem Setup
| Szenario | Empfohlenes Muster |
|---|---|
| Geringes Volumen, seltene Ausfälle | Callback + Fallback-Polling |
| Hohes Volumen, mögliche Datenbankausfälle | Dead-Letter-Queue |
| Mehrere Konsumenten verarbeiten dasselbe Ergebnis | Idempotenter Handler |
| Produktivsystem mit SLAs | Alle drei kombiniert |
Typische Probleme beim Debugging
| Problem | Ursache | Lösung |
|---|---|---|
| Das Fallback-Polling findet bereits zugestellte Aufgaben | Race Condition zwischen Callback und Poller | Idempotenzprüfung ergänzen – überspringen, wenn die Task-ID schon in results liegt |
| Die DLQ wächst, wird aber nicht geleert | Reprozessor läuft nicht oder scheitert wiederholt | Logs des Reprozessors prüfen und sicherstellen, dass die Ursache (z. B. die Datenbank) behoben ist |
| Callback antwortet mit 200, das Ergebnis fehlt trotzdem | Der Handler stürzt nach dem Senden der Antwort ab | Erst verarbeiten, dann antworten – oder das DLQ-Muster einsetzen |
| Zu viele Fallback-Abfragen | Zu viele Aufgaben laufen ins Timeout | Timeout-Schwelle erhöhen und die Serververfügbarkeit prüfen |
Häufige Fragen
Wie unterscheide ich einen abgelaufenen Token von einem echten Fehlercode?
Ein abgelaufener Token wird erst von der Zielanwendung abgelehnt, während res.php weiterhin status: 1 mit einem gültigen Ergebnis meldet. Ein echter Fehler kommt dagegen als eigener Code zurück – jeder Wert außer CAPCHA_NOT_READY. Trennen Sie im Poller beide Fälle: status: 1 ist ein Ergebnis, alles andere jenseits von CAPCHA_NOT_READY ist ein permanenter Fehler und gehört aus der Pending-Liste entfernt.
Wie lange bleibt ein CAPTCHA-Token gültig?
In der Regel etwa 120 Sekunden. reCAPTCHA-Token laufen nach rund zwei Minuten ab, deshalb sollten Sie sie erst unmittelbar vor der Übermittlung an die Zielseite einsetzen. An dieser Frist orientiert sich auch das Timeout im Fallback-Polling.
Muss ich meinen Callback-Endpunkt absichern?
Ja. Ihre Callback-URL ist öffentlich erreichbar. Validieren Sie eingehende Anfragen über einen schwer zu erratenden Pfad, ein Shared Secret oder eine Signaturprüfung, damit niemand gefälschte Ergebnisse einschleust oder Ihren Handler mit leeren Aufrufen belastet.
Was passiert mit laufenden Aufgaben, wenn mein Server neu startet?
Ohne Persistenz gehen sie verloren, weil pending_tasks und results im Arbeitsspeicher liegen. Für Produktivsysteme legen Sie den Zustand in einen externen Speicher wie Redis oder eine Datenbank. Nach einem Deploy oder Neustart übernimmt dann das Fallback-Polling die offenen Aufgaben und arbeitet sie sauber ab.
Verwandte Leitfäden
- Retry- und Fehlerbehandlung in Node.js
- Dead-Letter-Queue für fehlgeschlagene CAPTCHA-Aufgaben
- Webhook-Sicherheit und Callback-Validierung