الرقم الذي يحكم استقرار أي خط أتمتة ليس عدد الطلبات التي يستطيع كودك إطلاقها، بل عدد الطلبات التي يُفترض أن يطلقها في الثانية الواحدة. خوارزمية Token Bucket تعطيك هذا الرقم بمعاملين اثنين فقط: سعة تحدد أكبر اندفاع مسموح به، ومعدل إعادة تعبئة يحدد الوتيرة المستمرة بعد استهلاك ذلك الاندفاع. النتيجة أن الإرسال يخرج بإيقاع منتظم بدل موجة واحدة تُثقل نقطة النهاية، فيختفي ERROR_TOO_MUCH_REQUESTS وتبقى الـ threads المتاحة في خطتك مشغولة بانتظام بدل أن تتزاحم عليها الطلبات.
الترتيب هنا مقصود: نضبط الأرقام أولاً، ثم ننفّذها في Python وJavaScript، ثم نربطها بعدد الـ threads في خطتك.
متى يتحول تحديد معدل الطلبات إلى ضرورة
في سكربت بخيط واحد لن تلاحظ فرقاً. المشكلة تبدأ حين يتضاعف عدد المرسِلين والمفتاح واحد:
- أكثر من عامل أو حاوية Docker تتشارك مفتاح الـ API نفسه.
- دفعات غير منتظمة: قائمة الانتظار تفرغ ببطء ثم تمتلئ بمئات الروابط دفعة واحدة.
- بيئة الاختبار وبيئة الإنتاج تعملان بالمفتاح ذاته في الوقت نفسه.
- ظهور
ERROR_TOO_MUCH_REQUESTSفي السجلات رغم أن متوسط الحِمل اليومي منخفض.
السبب واحد في الحالات الأربع: الذروة اللحظية لا الكمية الإجمالية، وهذا ما يعالجه Token Bucket.
الفكرة كاملة: سعة زائد معدل إعادة تعبئة
تخيّل حاوية تمتلئ بالرموز بوتيرة ثابتة: كل إرسال يستهلك رمزاً، وإذا فرغت ينتظر الطلب الرمز التالي.
[Bucket] capacity=20, refill=10/sec
Time 0: ████████████████████ 20 tokens available
→ 15 requests consume 15 tokens
Time 0: █████ 5 tokens remain
Time 1s: ███████████████ 15 tokens (5 + 10 refilled)
→ 15 requests consume 15 tokens
Time 1s: (empty) 0 tokens
Time 2s: ██████████ 10 tokens (0 + 10 refilled)
→ Request waits if bucket is empty
اقرأ المخطط من ثلاث زوايا:
- السعة — أكبر عدد طلبات يمكن إطلاقها دفعة واحدة بعد فترة هدوء.
- معدل إعادة التعبئة — عدد الطلبات المستمر في الثانية على المدى الطويل.
- الانتظار بدل الرفض — الحاوية الفارغة تؤخّر الطلب ولا تُسقطه، فلا تفقد مهمة بسبب التقييد.
اختر الأرقام قبل كتابة سطر واحد
ابدأ من طبيعة الحِمل لا من طاقة الخادم:
| نوع الحِمل | السعة — أقصى اندفاع | معدل إعادة التعبئة — الوتيرة المستمرة |
|---|---|---|
| جمع بيانات خفيف | 5 | 2/sec |
| أتمتة قياسية | 20 | 10/sec |
| خط إنتاج كبير الحجم | 50 | 30/sec |
| أقصى إنتاجية | 100 | 50/sec |
ثلاث قواعد عملية تختصر عليك جولات التجريب:
- اجعل السعة ضعف معدل إعادة التعبئة تقريباً، فتسمح باندفاع مدته ثانيتان.
- ابدأ برقم متحفظ وارفعه تدريجياً مع مراقبة رموز الخطأ، لا العكس.
- حدّد معدل الإرسال فقط؛ الاستطلاع الدوري خفيف ويحدّ نفسه بنفسه عبر فترة الانتظار بين المحاولات.
التنفيذ في Python
حاوية رموز آمنة مع تعدد الخيوط
الفئة التالية تحمي عدّاد الرموز بقفل، وتعيد حساب الرصيد من فارق الوقت بدل تشغيل خيط تعبئة مستقل — أبسط وأدق:
import time
import threading
class TokenBucket:
def __init__(self, capacity, refill_rate):
"""
Args:
capacity: Maximum tokens (burst size)
refill_rate: Tokens added per second
"""
self.capacity = capacity
self.refill_rate = refill_rate
self.tokens = capacity
self.last_refill = time.monotonic()
self.lock = threading.Lock()
def acquire(self, timeout=None):
"""Block until a token is available."""
deadline = time.monotonic() + timeout if timeout else float("inf")
while True:
with self.lock:
self._refill()
if self.tokens >= 1:
self.tokens -= 1
return True
# Check timeout
if time.monotonic() >= deadline:
return False
# Wait before retrying (avoid busy loop)
time.sleep(min(1.0 / self.refill_rate, 0.1))
def _refill(self):
now = time.monotonic()
elapsed = now - self.last_refill
new_tokens = elapsed * self.refill_rate
self.tokens = min(self.capacity, self.tokens + new_tokens)
self.last_refill = now
الاعتماد على time.monotonic مقصود: الساعة الأحادية لا تتأثر بتعديل وقت النظام، فلا يقفز الرصيد عند مزامنة الخادم.
تمرير دالة الحل عبر الحاوية
نقطة الربط سطر واحد قبل الإرسال، وبقية الدالة تبقى كما هي:
import os
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
# Allow 10 submissions/sec with burst of 20
rate_limiter = TokenBucket(capacity=20, refill_rate=10)
def solve_captcha_rate_limited(sitekey, pageurl):
"""Solve with rate limiting on submission."""
# Wait for token before submitting
rate_limiter.acquire()
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1
})
data = resp.json()
if data.get("status") != 1:
raise RuntimeError(data.get("request"))
captcha_id = data["request"]
# Polling doesn't need rate limiting (separate concern)
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get", "id": captcha_id, "json": 1
}).json()
if result.get("status") == 1:
return result["request"]
if result.get("request") != "CAPCHA_NOT_READY":
raise RuntimeError(result.get("request"))
raise TimeoutError("Solve timeout")
# Run 100 tasks through rate limiter
tasks = [
{"sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
"pageurl": f"https://example.com/p/{i}"}
for i in range(100)
]
with ThreadPoolExecutor(max_workers=30) as executor:
futures = {
executor.submit(
solve_captcha_rate_limited, t["sitekey"], t["pageurl"]
): t for t in tasks
}
for future in as_completed(futures):
task = futures[future]
try:
solution = future.result()
print(f"[OK] {task['pageurl']}")
except Exception as e:
print(f"[ERR] {task['pageurl']}: {e}")
الحلقة التي تستطلع النتيجة من res.php تعمل خارج الحدّ عمداً: تقييدها يضيف تأخيراً دون فائدة، فكل استطلاع يفصله خمس ثوانٍ أصلاً.
التنفيذ في JavaScript
نسخة غير متزامنة تعمل مع Promise
في Node.js لا توجد أقفال ولا خيوط، لذا يكفي حساب زمن الانتظار المتبقي وتسليم التحكم عبر await:
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.refillRate = refillRate; // tokens per second
this.tokens = capacity;
this.lastRefill = Date.now();
this.waitQueue = [];
}
_refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
async acquire() {
this._refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return;
}
// Wait until a token is available
const waitTime = ((1 - this.tokens) / this.refillRate) * 1000;
await new Promise((resolve) => setTimeout(resolve, waitTime));
this._refill();
this.tokens -= 1;
}
}
تشغيل دفعة كاملة تحت الحدّ نفسه
Promise.allSettled يطلق المهام كلها فوراً، لكن حاوية الرموز هي التي تفرض الإيقاع الفعلي عند نقطة الإرسال:
const axios = require("axios");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
const rateLimiter = new TokenBucket(20, 10); // 20 burst, 10/sec sustained
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function solveCaptchaLimited(sitekey, pageurl) {
// Wait for rate limit token
await rateLimiter.acquire();
const submitResp = await axios.post(
"https://ocr.captchaai.com/in.php",
null,
{
params: {
key: API_KEY,
method: "userrecaptcha",
googlekey: sitekey,
pageurl: pageurl,
json: 1,
},
}
);
if (submitResp.data.status !== 1) {
throw new Error(submitResp.data.request);
}
const captchaId = submitResp.data.request;
for (let i = 0; i < 60; i++) {
await sleep(5000);
const result = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
});
if (result.data.status === 1) return result.data.request;
if (result.data.request !== "CAPCHA_NOT_READY") {
throw new Error(result.data.request);
}
}
throw new Error("TIMEOUT");
}
// Solve 100 tasks — rate limiter ensures max 10 submissions/sec
async function batchSolve(tasks) {
const results = await Promise.allSettled(
tasks.map((t) => solveCaptchaLimited(t.sitekey, t.pageurl))
);
const solved = results.filter((r) => r.status === "fulfilled").length;
const failed = results.filter((r) => r.status === "rejected").length;
console.log(`Solved: ${solved}, Failed: ${failed}`);
}
اختيار allSettled بدل all مقصود أيضاً: مهمة واحدة فاشلة لا يجب أن تُسقط الدفعة بأكملها.
مثال تشغيلي: مراقبة الأسعار قبل موسم الجمعة البيضاء
فريق من ثلاثة مطورين في الرياض يراقب أسعار المنافسين على متاجر خليجية قبل موسم التخفيضات. الحِمل غير منتظم: وتيرة هادئة طوال اليوم، ثم ارتفاع حاد بين السابعة والحادية عشرة مساءً بتوقيت الخليج حين تتغير الأسعار. وصفحات المنتج محمية بـ reCAPTCHA v2.
الفريق على خطة ADVANCE بسعر $90 شهرياً مع 50 thread وعمليات حل غير محدودة لكل thread. ويُحل reCAPTCHA v2 عادة في أقل من 60 ثانية، أي أن عدد الـ threads هو السقف الحقيقي للحل المتزامن — لا سرعة الشبكة.
ما فعله الفريق عملياً:
- ضبط السعة على 20 لاستيعاب موجة المساء الأولى دون تأخير محسوس.
- ضبط معدل إعادة التعبئة على 10 في الثانية، أي دون طاقة الخطة بهامش أمان واضح.
- تشغيل حاوية رموز واحدة لكل مفتاح، مشتركة بين عمّال الفحص الثلاثة، لا واحدة لكل عملية.
- تسجيل زمن الانتظار عند
acquireكمقياس مستقل: ارتفاعه المستمر يعني أن الخطة صارت أضيق من الحِمل، لا أن الأرقام خاطئة.
النتيجة: موجة المساء تُعالَج بإيقاع ثابت خلال دقائق، بدل أن ترتد أخطاء تقييد وإعادة محاولة تضاعف الاستهلاك.
اربط الحدّ بعدد الـ threads في خطتك
الفوترة في CaptchaAI قائمة على عدد الـ threads المتزامنة لا على عدد عمليات الحل، وكل خطة تشمل عمليات حل غير محدودة لكل thread خلال الشهر. والأسعار أدناه بالدولار الأمريكي:
| الخطة | السعر الشهري | عدد الـ threads |
|---|---|---|
| BASIC | $15 | 5 |
| ADVANCE | $90 | 50 |
| ENTERPRISE | $300 | 200 |
القاعدة البسيطة: اجعل معدل إعادة التعبئة أقل من طاقة الخطة لا مساوياً لها. الـ thread يبقى مشغولاً حتى تكتمل العملية، فإذا أرسلت أسرع من وتيرة تحرره تتراكم الطلبات بدل أن تُنجز.
Token Bucket مقابل خوارزميات التحديد الأخرى
| الخوارزمية | السلوك | الأنسب لـ |
|---|---|---|
| Token Bucket | وتيرة منتظمة مع سماح بالاندفاع | استدعاءات CAPTCHA API |
| Leaky Bucket | معدل خرج ثابت بلا اندفاع | حدود صارمة لا تحتمل أي زيادة لحظية |
| Fixed Window | عدّ داخل نافذة زمنية ثابتة مع اندفاع عند الحواف | عدّادات بسيطة |
| Sliding Window | عدّ ضمن نافذة متحركة | تطبيق دقيق للمعدل |
يبقى Token Bucket الخيار الافتراضي الأنسب هنا لأن حِمل CAPTCHA متقطع بطبعه: أداة جمع البيانات تعثر على عشرين اختباراً في لحظة واحدة، ثم لا شيء لدقيقة كاملة.
أخطاء شائعة وكيف تعالجها
| العَرَض | السبب الأرجح | الإجراء |
|---|---|---|
استمرار ERROR_TOO_MUCH_REQUESTS رغم وجود حدّ |
معدل التعبئة أعلى مما تسمح به الخطة، أو أكثر من عملية تستخدم المفتاح نفسه | اخفض المعدل واجمع كل المرسِلين خلف حدّ واحد مشترك |
| بطء ملحوظ في كل طلب | الرموز نفدت والطلبات تنتظر التعبئة | ارفع السعة لاستيعاب الاندفاع بدل رفع الوتيرة المستمرة |
| نمو استهلاك الذاكرة | قائمة انتظار مفتوحة بلا سقف | حدّد أقصى طول لقائمة الانتظار وارفض الفائض مبكراً |
| الحدّ لا يُطبَّق بين العمليات | الحاوية تعيش في ذاكرة عملية واحدة فقط | انقل العدّاد إلى Redis ليصبح الحدّ موزعاً |
أسئلة شائعة
هل يغني ضبط Token Bucket عن ترقية الخطة؟
لا. الحدّ ينظّم الإيقاع ولا يزيد الطاقة. إذا ارتفع زمن الانتظار عند طلب الرمز وتأخرت قائمة العمل، فالمطلوب threads أكثر لا معدل أبطأ.
ما الفرق العملي بين Token Bucket و Leaky Bucket؟
Leaky Bucket يخرج الطلبات بمعدل ثابت مهما كان الوارد، فيسوّي الذروة تماماً ويؤخّر أول دفعة. Token Bucket يسمح بإنفاق الرصيد المتراكم دفعة واحدة، وهو ما يناسب الحِمل المتقطع لصفحات محمية بـ CAPTCHA.
كيف أطبّق الحدّ على أكثر من خادم؟
الحاوية أعلاه تعيش في ذاكرة العملية، فتحصل كل عملية على حدّها الخاص. لجعل الحدّ موزعاً، انقل العدّاد إلى Redis مع سكربت Lua ذري للخصم والتعبئة، ثم اقسم المعدل الكلي على عدد الخوادم.
هل أحدّ من الاستطلاع الدوري أيضاً؟
في الغالب لا. طلبات res.php خفيفة ويفصلها خمس ثوانٍ، فهي محدودة ذاتياً؛ وتقييدها يزيد وقت الحل الظاهر دون أن يخفّض الضغط الفعلي.