API Tutorials

Dead Letter Queue (DLQ) для нерешённых задач CAPTCHA

Задача CAPTCHA, не решившаяся после всех повторов, не обязана бесследно пропадать. Dead Letter Queue (DLQ, «очередь недоставленных сообщений») откладывает такие задачи в отдельное хранилище — для повтора, разбора причин или алерта. Без DLQ единственный след сбоя — строка в логе, которую никто не перечитывает.


Причины, по которым задача CAPTCHA попадает в DLQ

Обычно задача попадает в DLQ по одной из четырёх причин.

ERROR_CAPTCHA_UNSOLVABLE: решатель не справился с задачей

Решатель принял задачу, но не смог вернуть валидный токен — битый sitekey, нестандартная разметка страницы или защита сработала раньше, чем токен успел вернуться. Лишний повтор такую задачу обычно не спасает: разбирайтесь в причине, а не гоняйте её по кругу.

ERROR_NO_SLOT_AVAILABLE: не осталось свободных воркеров

Все потоки заняты, а лимит повторов уже исчерпан. Это чаще симптом пиковой нагрузки на вашей стороне, чем проблема самой задачи — если такие сбои идут пачками, сначала проверьте план по потокам, а не логику повтора.

Тайм-аут: решатель не уложился в отведённый срок

Опрос res.php завершился без результата за все отведённые попытки. Единичные тайм-ауты — норма для отдельных сложных задач; регулярные — повод посмотреть на нагрузку у решателя в конкретные часы.

Сетевая ошибка: соединение оборвалось во время опроса

Запрос к res.php не долетел или ответ не пришёл целиком. На нестабильном канале это происходит заметно чаще, чем на стабильном офисном интернете.

Типичный кейс — парсинг с защитой CAPTCHA из региона с нестабильным мобильным интернетом (например, удалённая команда в Казахстане): часть запросов к res.php обрывается до получения токена, и без ретраев это выглядит как случайные, необъяснимые провалы. Без DLQ такие сбои вылавливают вручную по логам, задним числом и почти всегда с опозданием.


Python: DLQ в памяти и логика повтора

Минимальная реализация: очередь на deque, копящая неудачные задачи в памяти на время работы скрипта.

import time
import json
import requests
from collections import deque
from dataclasses import dataclass, asdict
from typing import Optional

API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"


@dataclass
class FailedTask:
    sitekey: str
    page_url: str
    error: str
    attempts: int
    timestamp: float
    task_id: Optional[str] = None


class DeadLetterQueue:
    def __init__(self, max_size=1000, max_retries=3):
        self._queue = deque(maxlen=max_size)
        self.max_retries = max_retries

    def push(self, task: FailedTask):
        self._queue.append(task)
        print(f"[dlq] Added: {task.error} (attempts: {task.attempts})")

    def pop(self) -> Optional[FailedTask]:
        return self._queue.popleft() if self._queue else None

    def size(self) -> int:
        return len(self._queue)

    def peek_all(self) -> list:
        return [asdict(t) for t in self._queue]

    def export_json(self, path: str):
        with open(path, "w") as f:
            json.dump(self.peek_all(), f, indent=2)
        print(f"[dlq] Exported {self.size()} tasks to {path}")


dlq = DeadLetterQueue(max_retries=3)


def solve_captcha(sitekey, page_url, max_retries=3):
    for attempt in range(max_retries + 1):
        try:
            resp = requests.post(SUBMIT_URL, data={
                "key": API_KEY,
                "method": "userrecaptcha",
                "googlekey": sitekey,
                "pageurl": page_url,
                "json": "1",
            }, timeout=15)
            data = resp.json()
            if data["status"] != 1:
                raise Exception(data["request"])

            task_id = data["request"]
            for _ in range(24):
                time.sleep(5)
                poll = requests.get(RESULT_URL, params={
                    "key": API_KEY, "action": "get",
                    "id": task_id, "json": "1",
                }, timeout=15).json()

                if poll["status"] == 1:
                    return poll["request"]
                if poll["request"] != "CAPCHA_NOT_READY":
                    raise Exception(poll["request"])

            raise TimeoutError(f"Task {task_id} timed out")

        except Exception as e:
            if attempt == max_retries:
                dlq.push(FailedTask(
                    sitekey=sitekey,
                    page_url=page_url,
                    error=str(e),
                    attempts=attempt + 1,
                    timestamp=time.time(),
                ))
                return None
            time.sleep(2 ** attempt)

    return None


# Process a batch
urls = [f"https://example.com/page/{i}" for i in range(5)]
for url in urls:
    token = solve_captcha("6Le-SITEKEY", url)
    if token:
        print(f"Solved: {token[:40]}...")

print(f"\nDLQ size: {dlq.size()}")

Ожидаемый результат:

Solved: 03AGdBq26ZfPxL...
Solved: 03AGdBq27AbCdE...
[dlq] Added: ERROR_CAPTCHA_UNSOLVABLE (attempts: 4)
Solved: 03AGdBq28FgHiJ...
[dlq] Added: Task 71823460 timed out (attempts: 4)

DLQ size: 2

Как повторно обработать задачи из DLQ

Когда основной пакет обработан, запустите retry_dlq() отдельным проходом — он забирает задачи из очереди и пробует решить их ещё раз, с собственным лимитом попыток:

def retry_dlq(dlq: DeadLetterQueue, max_retries=2):
    retried = 0
    recovered = 0

    while dlq.size() > 0:
        task = dlq.pop()
        if task.attempts >= dlq.max_retries + max_retries:
            print(f"[dlq] Permanently failed: {task.sitekey} — {task.error}")
            continue

        retried += 1
        token = solve_captcha(
            task.sitekey, task.page_url, max_retries=max_retries
        )
        if token:
            recovered += 1
            print(f"[dlq-retry] Recovered: {token[:40]}...")

    print(f"[dlq] Retried: {retried}, Recovered: {recovered}")

# Run DLQ retry after main batch
retry_dlq(dlq)

JavaScript: DLQ с сохранением в файл

Очередь в памяти не переживает рестарт процесса — деплой, падение контейнера, перезапуск по расписанию. Версия на JavaScript пишет очередь на диск после каждого изменения:

const fs = require('fs');
const axios = require('axios');

const API_KEY = 'YOUR_API_KEY';
const DLQ_FILE = './captcha-dlq.json';

class DeadLetterQueue {
  constructor(maxRetries = 3) {
    this.maxRetries = maxRetries;
    this.queue = this._load();
  }

  push(task) {
    this.queue.push({
      ...task,
      timestamp: Date.now(),
    });
    this._save();
    console.log(`[dlq] Added: ${task.error} (attempts: ${task.attempts})`);
  }

  pop() {
    const task = this.queue.shift();
    if (task) this._save();
    return task || null;
  }

  size() {
    return this.queue.length;
  }

  _load() {
    try {
      return JSON.parse(fs.readFileSync(DLQ_FILE, 'utf8'));
    } catch {
      return [];
    }
  }

  _save() {
    fs.writeFileSync(DLQ_FILE, JSON.stringify(this.queue, null, 2));
  }
}

const dlq = new DeadLetterQueue(3);

async function solveCaptcha(sitekey, pageurl, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const submit = await axios.post('https://ocr.captchaai.com/in.php', null, {
        params: { key: API_KEY, method: 'userrecaptcha', googlekey: sitekey, pageurl, json: 1 }
      });
      if (submit.data.status !== 1) throw new Error(submit.data.request);

      const taskId = submit.data.request;
      for (let i = 0; i < 24; 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: taskId, json: 1 }
        });
        if (poll.data.status === 1) return poll.data.request;
        if (poll.data.request !== 'CAPCHA_NOT_READY') throw new Error(poll.data.request);
      }
      throw new Error(`Task ${taskId} timed out`);
    } catch (err) {
      if (attempt === maxRetries) {
        dlq.push({ sitekey, pageurl, error: err.message, attempts: attempt + 1 });
        return null;
      }
      await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
    }
  }
}

// Process tasks
(async () => {
  for (let i = 0; i < 5; i++) {
    const token = await solveCaptcha('6Le-SITEKEY', `https://example.com/page/${i}`);
    if (token) console.log(`Solved: ${token.substring(0, 40)}...`);
  }
  console.log(`DLQ size: ${dlq.size()}`);
})();

Типичные проблемы DLQ и их решения

Проблема Причина Решение
DLQ растёт без остановки Никто не разбирает очередь Запускайте retry_dlq() по расписанию (cron)
Задача повторяется бесконечно Нет проверки на максимум попыток Сверяйте task.attempts перед повторной постановкой
Файл DLQ повреждён Параллельная запись из процессов Файловая блокировка или переход на Redis/БД
Задачи теряются при падении DLQ живёт только в памяти Файловая или Redis-based очередь вместо in-memory

Анализ отказов в DLQ

Экспортируйте задачи и посмотрите на распределение ошибок — закономерности видны сразу:

# Export DLQ for analysis
dlq.export_json("failed-tasks.json")

# Analyze error distribution
from collections import Counter
errors = Counter(t["error"] for t in dlq.peek_all())
for error, count in errors.most_common():
    print(f"  {error}: {count}")

Что искать в этих данных:

  1. Один и тот же sitekey падает раз за разом — проверьте, не устарел ли параметр и не поменялась ли вёрстка страницы.
  2. Тайм-ауты кучкуются в одни и те же часы — сопоставьте всплеск с нагрузкой на API или с вашим собственным пиковым трафиком.
  3. Сетевые ошибки идут пачками — сначала проверьте прокси и канал связи, и только потом подозревайте решатель.

Держите в FailedTask только то, что нужно: sitekey, page_url, код ошибки, число попыток. Персональные данные из query-параметров обрежьте перед записью — гигиена логирования что по 152-ФЗ, что по GDPR.


Часто задаваемые вопросы

DLQ в памяти или постоянный — что выбрать?

Для короткоживущих скриптов хватает памяти: очередь исчезнет вместе с процессом, и это нормально. Для сервисов, которые перезапускаются, нужен файл или Redis — иначе накопленные задачи теряются при каждом рестарте.

Сколько потоков закладывать на повтор задач из DLQ?

Ориентируйтесь на долю задач, оседающих в DLQ, а не на общий трафик: если из 1000 падает 20–30, хватит нескольких потоков из плана STANDARD ($30/мес, 15 потоков). Очередь, растущая быстрее, чем разбирается, — сигнал поднять план, а не просто добавлять потоки.

Что делать, если DLQ переполняется быстрее, чем разбирается?

Сначала посмотрите на errors.most_common() — обычно за ростом очереди стоит одна причина: устаревший sitekey, нестабильный прокси или тайм-ауты в конкретные часы. Наращивать частоту retry_dlq() имеет смысл уже после этого.

Можно ли хранить в DLQ данные пользователей вместе с задачей?

Не стоит. Хватает sitekey, page_url, кода ошибки и числа попыток — этого достаточно и для повтора, и для разбора причин. Персональные данные в очереди — лишний риск без практической пользы.

Как DLQ сочетается с паттерном автоматического выключателя?

Да, это разные уровни защиты. Выключатель останавливает поток запросов при массовой недоступности решателя, а DLQ ловит отдельные задачи, упавшие до его срабатывания. См. схему автоматического выключателя для CAPTCHA API.


Не теряйте ни одной задачи CAPTCHA — постройте DLQ с CaptchaAI

Получите API-ключ на captchaai.com и подключите очередь к своему пайплайну.


Связанные руководства

Комментарии для этой статьи отключены.