الدروس التطبيقية

معالجة أخطاء حل CAPTCHA وإعادة المحاولة في Node.js

الفارق بين سكربت يحلّ CAPTCHA على جهازك وآخر يصمد في الإنتاج ليس في الشيفرة التي تنجح، بل في الشيفرة التي تتعامل مع الفشل. فعند تشغيل CaptchaAI مع Node.js على آلاف الطلبات يومياً، ستواجه حتماً مهلات شبكة تنتهي، ورموزاً تنقضي صلاحيتها قبل استخدامها، ورصيداً ينفد في منتصف دُفعة. يبني هذا الدليل طبقة صمود كاملة فوق الـ API عبر ست لبنات متتابعة، جميعها بأمثلة Node.js جاهزة للنسخ والتشغيل مباشرةً:

  • تصنيف الأخطاء إلى قابلة لإعادة المحاولة وأخرى فادحة
  • التراجع الأسي مع عشوائية زمنية (jitter)
  • حلّال مُحصّن يدمج الإرسال واستطلاع النتيجة
  • قاطع دائرة يوقف الطلبات عند تعطّل الخدمة
  • تخزين مؤقت للرموز والتعامل مع انتهاء صلاحيتها
  • طبقة قياس وتسجيل تمنحك رؤية إنتاجية واضحة

صنّف الأخطاء: قابلة لإعادة المحاولة مقابل فادحة

القاعدة الأولى في أي تكامل موثوق هي أنّ ليس كل فشل يستحق إعادة المحاولة. بعض الأخطاء عابرة — مثل ERROR_NO_SLOT_AVAILABLE عند امتلاء الخيوط (threads)، أو CAPCHA_NOT_READY أثناء انتظار النتيجة — وتُحلّ من تلقاء نفسها بعد لحظات. وأخرى فادحة لا معنى لإعادتها: مفتاح API خاطئ، أو رصيد صفري، أو كابتشا يتعذّر حلّه، أو معطى ناقص في الطلب. إعادة محاولة خطأ فادح تهدر الوقت والرصيد وتشغل خيطاً كان يمكن أن يخدم طلباً حقيقياً. لذلك نبدأ بمجموعتين صريحتين للأخطاء وفئات استثناء مخصّصة تفصل المسارَين منذ اللحظة الأولى:

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);
}

التراجع الأسي مع عشوائية زمنية (jitter)

بعد أن نعرف أنّ الخطأ قابل لإعادة المحاولة، يبقى السؤال: متى نعيد؟ الإجابة ليست «فوراً». فإغراق الـ API بطلبات متتالية بلا مهلة يُفاقم الضغط ويؤخّر التعافي. التراجع الأسي يضاعف زمن الانتظار مع كل محاولة — ثانيتان ثم أربع ثم ثمان — حتى سقف أقصى، بينما تضيف العشوائية الزمنية (jitter) قدراً من التشتيت يمنع عشرات العمّال من إعادة المحاولة في اللحظة ذاتها، وهو ما يُعرف بمشكلة «القطيع المندفع». الدالة التالية تغلّف أي عملية بمنطق إعادة محاولة قابل لإعادة الاستخدام، وتتوقف فوراً عند أي خطأ فادح:

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;
}

بناء حلّال CAPTCHA مُحصّن في Node.js

الآن ندمج الخطوتين — الإرسال إلى in.php ثم استطلاع النتيجة من res.php — داخل صنف واحد يحمي كل طلب بمهلة عبر AbortSignal.timeout، ويمنح ERROR_NO_SLOT_AVAILABLE عدداً محدوداً من المحاولات قبل الاستسلام.

خذ سيناريو واقعياً: فريق في الرياض يشغّل مراقبة معتمدة لتوافر المواعيد على بوابة خدمات إقليمية، وترتفع طلباته بشدّة في ذروة الصباح، فيبدأ ERROR_NO_SLOT_AVAILABLE بالتكرار. السبب غالباً ليس عطلاً في الخدمة، بل نفاد الخيوط المتاحة في الخطة. هنا لا تكفي إعادة المحاولة وحدها؛ الحل توسيع التزامن بالانتقال من خطة BASIC ($15 شهرياً، 5 threads) إلى ADVANCE ($90 شهرياً، 50 threads)، إذ يعمل كل thread على كابتشا واحد في اللحظة ثم يتحرّر للطلب التالي:

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`);
  }
}

قاطع الدائرة: أوقف الطلبات عندما يتعطّل الـ API

إعادة المحاولة علاج ممتاز للأعطال العابرة، لكنها كارثة حين يكون الـ API متوقّفاً فعلاً: عندئذٍ تُضاعف كل مهمة الضرر وتُغرق الخدمة بطلبات محكوم عليها بالفشل. قاطع الدائرة يحل هذا بأن «يفتح» بعد عدد محدّد من الإخفاقات المتتالية فيمنع الطلبات الجديدة طوال فترة تهدئة، ثم ينتقل إلى حالة «نصف مفتوح» ليختبر طلباً واحداً: إن نجح عاد إلى العمل الطبيعي، وإن فشل أُعيد فتحه. لاحظ أنّ الأخطاء الفادحة لا تُحتسب ضمن عدّاد القاطع، لأنها تشير إلى خطأ في الإعداد لا إلى عطل في الخدمة:

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;
  }
}

التعامل مع انتهاء صلاحية الرموز وتخزينها مؤقتاً

الرمز المحلول ليس أبدياً، ولكل نوع نافذة صلاحية قصيرة:

  • رمز reCAPTCHA: نحو دقيقتين
  • رمز Cloudflare Turnstile: نحو خمس دقائق

إن حللت الكابتشا مبكّراً ثم تأخّرت في إرساله، فقد ترفضه الوجهة لانتهاء صلاحيته. الحل ذو شقّين: خزّن الرمز مؤقتاً لإعادة استخدامه ضمن نافذة صلاحيته بدل دفع تكلفة حلّ جديد، وأعد الحلّ تلقائياً إذا رفضت الوجهة الرمز. القاعدة العملية: احسِب زمن الحل المتوقّع واحصل على الرمز قبيل لحظة إرساله مباشرةً، لا قبلها بدقائق:

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");
  }
}

القياس والتسجيل لرؤية إنتاجية

لا يمكنك تحسين ما لا تقيسه. في الإنتاج تحتاج إلى أرقام واضحة: كم طلباً أُرسل، وكم حُلّ، وكم فشل، وكم مرة أُعيدت المحاولة، ومتوسط زمن الحل، ومقدار الإنتاجية في الدقيقة. تجمّع الفئة التالية هذه المؤشرات في مكان واحد لتصدّرها إلى سجلّاتك أو لوحة التحكم، فتكشف تدهور معدل النجاح أو ارتفاع زمن الحل قبل أن يتحوّل إلى شكوى من المستخدمين:

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();
  }
}

النمط الإنتاجي الكامل مجمّعاً

تتراكم الطبقات فوق بعضها: الحلّال المزوّد بالقياس يغلّف الحلّال المحميّ بقاطع الدائرة، الذي يغلّف بدوره الحلّال المُحصّن بإعادة المحاولة. المثال التالي يشغّل دُفعة من عشر مهام عبر Promise.allSettled بحيث لا يُسقط فشلُ مهمة واحدة بقيةَ الدُّفعة، ثم يطبع ملخّص المقاييس في النهاية:

// 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();

جدول استكشاف الأعطال وإصلاحها

عند مواجهة سلوك غير متوقّع، ابدأ من الأعراض الشائعة التالية وأسبابها الأرجح قبل الغوص في السجلّات:

العَرَض السبب المرجّح الإصلاح
تفشل كل المحاولات فوراً دون انتظار خطأ فادح يُعاد بلا داعٍ راجِع تصنيف الأخطاء وتأكّد أنّ الفادح لا يُعاد
يبقى قاطع الدائرة مفتوحاً الـ API متوقّف أو المفتاح خاطئ تحقّق من حالة الخدمة وصحّة مفتاح الـ API
رفض الرمز لانتهاء صلاحيته عند الإرسال زمن الحل + التأخير تجاوز عمر الرمز احصل على الرمز قبيل الإرسال مباشرةً
ظهور AbortError أثناء fetch المهلة المحدّدة قصيرة جداً ارفع قيمة AbortSignal.timeout
UnhandledPromiseRejection غياب catch على دالة غير متزامنة عالِج كل حالات الرفض دائماً

الأسئلة المتداولة

متى أفتح قاطع الدائرة بدل مواصلة إعادة المحاولة؟

إعادة المحاولة مناسبة للأخطاء العابرة والمعزولة. لكن حين تتوالى الإخفاقات — خمسة متتالية في الإعداد الافتراضي — فهذا مؤشر على عطل عام في الخدمة لا على طلب سيّئ الحظ. عندها يفتح القاطع ويمنح الـ API فترة تعافٍ بدل إغراقه بطلبات فاشلة.

لماذا أضيف عشوائية زمنية (jitter) إلى التراجع الأسي؟

لأنّ عشرات العمّال قد يفشلون في اللحظة نفسها ثم يعيدون المحاولة في اللحظة نفسها، فيتكرّر الازدحام دورياً. تضيف العشوائية فارقاً بسيطاً وعشوائياً إلى كل مهلة، فتوزّع المحاولات على الزمن وتخفّف الضغط اللحظي على الخدمة.

كيف أتعامل مع تكرار ERROR_NO_SLOT_AVAILABLE؟

هذا الخطأ عابر ويعني امتلاء خيوطك المتاحة مؤقتاً، لذا أعِد المحاولة بمهلة قصيرة. لكن إن تكرّر باستمرار في أوقات الذروة فالسبب بنيوي: عدد الـ threads في خطتك لا يكفي حجمك، والحل رفع سعة التزامن بترقية الخطة لا زيادة عدد المحاولات.

هل أعيد حلّ الكابتشا إذا رفضت الوجهة الرمز؟

نعم، لكن ضمن حدّ أقصى للمحاولات. قد يُرفض الرمز لانتهاء صلاحيته أو لتغيّر سياق الصفحة. أعد الحلّ مرّة أو مرّتين فقط؛ فإن استمر الرفض فالمشكلة على الأرجح في طريقة الإرسال أو في sitekey، لا في الرمز نفسه.

ما الفرق بين مهلة الإرسال ومهلة الاستطلاع؟

مهلة الإرسال تحمي طلب in.php الواحد من التعليق — نستخدم 30 ثانية. أما مهلة الاستطلاع فهي السقف الكلي لانتظار النتيجة عبر res.php — نحو 150 ثانية — قبل إعلان انتهاء المهلة. الأولى لكل طلب، والثانية لدورة الحل كاملة.

ملخص

بناء حلّ CAPTCHA مقاوم للأعطال في Node.js مع CaptchaAI يقوم على طبقات متكاملة: صنّف الأخطاء إلى قابلة لإعادة المحاولة وفادحة، وطبّق التراجع الأسي مع عشوائية زمنية، واحمِ التكامل بقاطع دائرة، وخزّن الرموز مؤقتاً لإعادة استخدامها، وأضِف طبقة قياس تمنحك رؤية إنتاجية واضحة. اجمعها معاً لتحصل على تكامل يصمد تحت الحِمل بدل أن ينهار عند أول عطل.

مقالات ذات صلة

الخطوات التالية

أدلة ذات صلة

التعليقات غير مفعّلة لهذا المقال.