إعادة محاولة طلبات API الفاشلة في بايثون باستخدام requests وRetry | بايثون بعد الأساسيات 13

إعادة محاولة طلبات API الفاشلة في بايثون باستخدام requests وRetry مع معالجة أخطاء 429 و503

قد يعمل برنامجك الذي يتصل بواجهة API عشرات المرات بلا مشكلة، ثم يفشل فجأة بسبب انقطاع مؤقت في الاتصال أو ضغط على الخادم أو حدّ لعدد الطلبات. إعادة تشغيل البرنامج يدويًا ليست حلًا جيدًا، كما أن وضع requests.get() داخل حلقة لا نهائية قد يحوّل عطلًا مؤقتًا إلى ضغط إضافي على الخدمة.

في هذا الدرس من سلسلة بايثون بعد الأساسيات 13 سنبني سياسة إعادة محاولة Retry منظمة باستخدام requests.Session وHTTPAdapter وurllib3.util.Retry. سنتعامل مع حالات مثل 429 و503، ونشرح Backoff وRetry-After، ثم نختبر كل ذلك على خادم محلي حتى لا نعتمد على تعطل API خارجية أثناء التعلم.

{alertInfo}الفكرة الأساسية: Retry ليس «كرر أي طلب عند أي خطأ». السياسة الجيدة تحدد متى نعيد المحاولة، وكم مرة، وكم ننتظر، وأي طرق HTTP يسمح بتكرارها.

تحميل مشروع Retry لطلبات API

خادم HTTP محلي، دالة لإنشاء Session مع Retry، أمثلة 503 و429، واختبارات unittest بدون الاعتماد على API خارجية.

تحميل مشروع بايثون ZIP

{getToc} $title={محتوى المقال}


لماذا تفشل طلبات API بصورة مؤقتة؟

ليس كل فشل يعني أن الكود خاطئ. قد تكون الخدمة تحت ضغط، أو تجاوزت حد الطلبات، أو انقطع الاتصال لحظيًا، أو توجد بوابة وسيطة ترجع خطأ مؤقتًا. من الحالات التي قد تكون مؤقتة: 429 Too Many Requests و502 Bad Gateway و503 Service Unavailable و504 Gateway Timeout. أما أخطاء مثل 401 أو 403 فغالبًا تحتاج إصلاح المصادقة أو الصلاحيات بدل تكرار الطلب نفسه.

توثيق Requests يوضح أن المكتبة لا تعيد الاتصالات الفاشلة تلقائيًا افتراضيًا، وأن التحكم المتقدم في Retry يتم بتمرير كائن urllib3.util.Retry إلى HTTPAdapter. سنستخدم هذه الآلية بدل بناء حلقة Retry يدوية من الصفر.

لماذا لا نستخدم while True لإعادة الطلب؟

import requests

while True:
    response = requests.get("https://example.com/api")
    if response.status_code == 200:
        break
{alertWarning}هذا المثال غير مناسب للإنتاج: لا يوجد حد للمحاولات، ولا Timeout، ولا انتظار بين المحاولات، ولا تمييز بين خطأ دائم وخطأ مؤقت. وإذا كانت الخدمة متعثرة فقد يرسل البرنامج طلبات متتابعة بسرعة تزيد المشكلة.

نحتاج إلى سياسة واضحة: عدد محدود للمحاولات، وقائمة حالات HTTP محددة، وBackoff بين المحاولات، واحترام تعليمات الخادم عند وجود Retry-After.

الفرق بين Timeout وRetry

المفهوموظيفتهمثال
timeoutيحدد حدود الانتظار أثناء محاولة الاتصال/القراءةإذا لم تستجب الخدمة خلال المدة المحددة، تفشل المحاولة
Retryيقرر هل تبدأ محاولة جديدة بعد فشل مناسببعد 503، انتظر ثم جرّب GET مرة أخرى
Backoffيزيد زمن الانتظار بين المحاولات تدريجيًا0 ثم 1 ثم 2 ثانية بحسب الإعداد
Retry-Afterتعليمات من الخادم بمدة الانتظارالخادم يرجع 429 ويطلب الانتظار ثانية أو أكثر

لا تستبدل Retry بـTimeout ولا العكس. حتى مع Retry يجب أن تمرر timeout لكل طلب حتى لا تبقى محاولة واحدة معلقة إلى أجل غير مناسب.

تثبيت requests ونسخة urllib3 المناسبة

python -m pip install "requests>=2.32,<3" "urllib3>=2.5,<3"

المشروع المرفق اختُبر على Python 3.13.5 مع Requests 2.32.5 وurllib3 2.7.0. استخدمنا backoff_jitter الموجودة في urllib3 الحديثة، لذلك لا تعتمد على مثال قديم جدًا قبل التحقق من نسخة المكتبة لديك.

إذا ظهرت لك مشكلة أن requests مثبتة لكن بايثون لا يجدها، راجع حل ModuleNotFoundError بعد pip install.

إنشاء Session مع HTTPAdapter وRetry

الخطوة الأساسية هي إنشاء requests.Session ثم تركيب HTTPAdapter عليه. الـSession تسمح بتوحيد الإعدادات وإعادة استخدام الاتصالات، بينما يحمل الـAdapter سياسة Retry.

import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

retry = Retry(
    total=3,
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods={"GET", "HEAD", "OPTIONS"},
    backoff_factor=0.5,
    backoff_jitter=0.2,
    respect_retry_after_header=True,
)

session = requests.Session()
adapter = HTTPAdapter(max_retries=retry)

session.mount("https://", adapter)
session.mount("http://", adapter)

لاحظ أننا تعمدنا استخدام GET وHEAD وOPTIONS فقط في السياسة الافتراضية للمشروع. urllib3 تسمح افتراضيًا بطرق idempotent أخرى مثل PUT وDELETE أيضًا، لكننا نستخدم قائمة أكثر تحفظًا في الدرس لأن هدفنا هو بناء أساس آمن وواضح.

شرح آلية Retry في requests باستخدام Session وHTTPAdapter وBackoff لإعادة طلبات API الفاشلة في بايثون

ما معنى total=3؟

في Retry تمثل total عدد مرات إعادة المحاولة بعد المحاولة الأصلية. لذلك إذا كانت total=3 فقد يصل مجموع المحاولات إلى أربع: المحاولة الأولى ثم ثلاث إعادات.

retry = Retry(total=3)

في مشروعنا، المسار /always-503 يفشل دائمًا، وعندما استخدمنا total=3 سجل الخادم أربع محاولات فعلية قبل إعلان استنفاد Retry.

اختيار status_forcelist

لا يعني وجود أي كود HTTP غير 200 أننا يجب أن نعيد الطلب. نحدد فقط الحالات التي نعتبرها قابلة للتعافي مؤقتًا بحسب الخدمة التي نتعامل معها:

status_forcelist = [
    429,
    500,
    502,
    503,
    504,
]
  • 429: تجاوز حد الطلبات؛ غالبًا يجب الانتظار بدل الإرسال فورًا.
  • 500: خطأ داخلي في الخادم؛ قد يكون مؤقتًا أو دائمًا حسب الخدمة.
  • 502: وسيط أو بوابة حصلت على استجابة غير صالحة من الخادم الخلفي.
  • 503: الخدمة غير متاحة مؤقتًا في كثير من الحالات.
  • 504: انتهت مهلة البوابة أثناء انتظار الخادم الخلفي.

لا تضع كل أكواد 4xx داخل القائمة. مثلًا تكرار طلب 401 باستخدام بيانات الاعتماد الخاطئة لن يصلح كلمة المرور.

كيف يعمل allowed_methods؟

لا تكفي مطابقة status_forcelist وحدها. Retry تتحقق أيضًا من طريقة HTTP. إذا كانت الطريقة غير موجودة في allowed_methods فلن يعاد الطلب بسبب Status Code حتى لو كان 503.

allowed_methods={
    "GET",
    "HEAD",
    "OPTIONS",
}

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

Exponential Backoff: لماذا ننتظر بين المحاولات؟

إذا أعدنا الطلب فورًا عشرات المرات، قد نزيد الضغط على خدمة تحاول التعافي. backoff_factor يضيف انتظارًا متزايدًا بين إعادات المحاولة. توثيق urllib3 الحالي يشرح أن التأخير ينمو أسيًا، وأن أول Retry قد تحدث بلا انتظار ثم تبدأ الأزمنة بالارتفاع بحسب عدد المحاولات السابقة.

retry = Retry(
    total=4,
    backoff_factor=0.5,
)

مع معامل 0.5 قد تكون فترات Backoff التقريبية على النمط 0، 1، 2، 4... ثانية قبل سقف backoff_max. لا تعتمد على جدول ثابت في منطق البرنامج؛ دع المكتبة تدير التوقيت وفق الإعدادات والحالة.

ما فائدة backoff_jitter؟

تخيل آلاف العملاء حصلوا على 503 في اللحظة نفسها. إذا انتظروا جميعًا الزمن نفسه ثم عادوا في اللحظة نفسها، قد ينشأ ضغط جديد يسمى أحيانًا Thundering Herd. backoff_jitter يضيف فرقًا عشوائيًا صغيرًا إلى Backoff حتى لا تتحرك كل العملاء بتزامن كامل.

retry = Retry(
    total=4,
    backoff_factor=0.5,
    backoff_jitter=0.2,
)

وفق توثيق urllib3، يضاف مقدار عشوائي من صفر حتى قيمة backoff_jitter إلى زمن Backoff المحسوب.

التعامل مع HTTP 429 وRetry-After

429 Too Many Requests يعني أن العميل تجاوز حد الطلبات الذي تفرضه الخدمة. بعض الخوادم ترسل رأس Retry-After لتخبرك بمدة الانتظار قبل المحاولة التالية.

retry = Retry(
    total=3,
    status_forcelist=[429, 503],
    allowed_methods={"GET"},
    respect_retry_after_header=True,
)

urllib3 تعرف حالات 413 و429 و503 ضمن حالات Retry-After الافتراضية. وعند تفعيل respect_retry_after_header تستخدم المكتبة المدة التي يطلبها الخادم عندما تنطبق شروط Retry.

{alertInfo}احترام Retry-After أفضل من تجاهله ثم الاعتماد على Backoff من طرف العميل، لأن الخادم يعرف سياسة Rate Limit الخاصة به. ومع ذلك، ضع حدودًا مناسبة في تطبيقك حتى لا يصبح طلب واحد مسؤولًا عن انتظار طويل لا يناسب تجربة المستخدم.

اختبار 429 بخادم محلي بدل API خارجية

أنشأنا في المشروع خادم HTTP محليًا. في أول طلب إلى /rate-429 يرجع:

HTTP 429 Too Many Requests
Retry-After: 1

ثم يسمح الخادم بالمحاولة التالية ويرجع 200. أثناء الاختبار قسنا الزمن للتأكد من أن العميل انتظر فعلًا قبل إعادة الطلب. النتيجة الفعلية:

429 demo status: 200
429 demo attempts: 2
429 respected Retry-After: True

كما أضفنا اختبارًا يعطّل respect_retry_after_header ليثبت أن الانتظار لا يحدث في هذه التجربة عندما يكون Backoff صفرًا. الهدف تعليمي؛ في تطبيق حقيقي لا تعطل تعليمات Rate Limit بلا سبب واضح.

اختبار 503: يفشل مرتين ثم ينجح

المسار /flaky-503 في الخادم المحلي يرجع 503 في أول محاولتين، ثم 200 في الثالثة:

Request #1 -> 503 Service Unavailable
Request #2 -> 503 Service Unavailable
Request #3 -> 200 OK

المخرجات الحقيقية من demo.py:

503 demo status: 200
503 demo attempts: 3
503 demo message: service recovered

هذه التجربة مهمة لأنها تثبت أن نجاح الطلب النهائي لم يحدث من أول محاولة، وأن Retry هي التي أعادت GET حتى تعافت الخدمة.

اختبار إعادة المحاولة Retry محليًا في بايثون عند أخطاء HTTP 503 و429 واستخدام Retry-After

ماذا يحدث عندما لا تتعافى الخدمة؟

يجب ألا يستمر Retry إلى ما لا نهاية. في المسار /always-503 يرجع الخادم 503 في كل مرة. مع total=3 جرت المحاولة الأصلية وثلاث إعادات، ثم رفعت Requests استثناء RetryError.

always-503 exhausted retries: YES
always-503 attempts: 4

هنا يمكن للتطبيق عرض رسالة للمستخدم، أو تسجيل الحادثة، أو نقل المهمة إلى Queue لاحقة، بدل الاستمرار في إرسال الطلبات بلا حد.

import requests

try:
    response = session.get(url, timeout=10)
    response.raise_for_status()
except requests.exceptions.RetryError:
    print("استُنفدت محاولات إعادة الطلب.")
except requests.exceptions.Timeout:
    print("انتهت مهلة إحدى المحاولات.")
except requests.exceptions.RequestException as error:
    print("فشل الطلب:", error)

إذا كنت تحتاج مراجعة أساسيات التقاط الاستثناءات، راجع شرح try وexcept في بايثون.

معنى Max retries exceeded

قد ترى عبارة Max retries exceeded ضمن سلسلة الاستثناءات عند استنفاد المحاولات، لكنها لا تعني سببًا واحدًا دائمًا. قد تأتي من فشل اتصال متكرر، وقد تظهر ضمن استثناءات مرتبطة بسياسة Retry. اقرأ الاستثناء الداخلي والسبب الأصلي بدل البحث عن العبارة وحدها.

في تجربتنا الخاصة بفشل 503 استلمنا من Requests استثناء requests.exceptions.RetryError بعد أن استنفدت urllib3 المحاولات المسموحة بسبب Status Code.

لماذا لا نضيف POST إلى Retry افتراضيًا؟

نفذنا اختبارًا مباشرًا على المسار /post-503. الخادم يرجع 503، لكن سياسة المشروع لا تحتوي POST في allowed_methods. النتيجة:

POST status: 503
POST attempts with default policy: 1

أي أن POST أُرسلت مرة واحدة فقط. هذا سلوك مقصود، لأن POST قد تُحدث أثرًا في الخادم.

{alertWarning}لا تضف POST إلى allowed_methods فقط لأنك تريد «مزيدًا من الاعتمادية». يجب أولًا أن تفهم هل العملية Idempotent، وهل الـAPI نفسها توفر طريقة تمنع تكرار الأثر.

متى يمكن إعادة POST؟ وما هي Idempotency Key؟

بعض APIs توفر مفهوم Idempotency Key: يرسل العميل معرفًا فريدًا مع العملية، ويستخدمه الخادم للتعرف على إعادة المحاولة بدل تنفيذ العملية مرتين. Stripe مثال موثق على خدمة تسمح باستخدام Idempotency Key مع طلبات إنشاء أو تحديث، بحيث يمكن إعادة الطلب بصورة أكثر أمانًا حسب قواعدها.

هذا ليس سلوكًا عامًا في بروتوكول HTTP تستطيع فرضه من طرفك. إذا لم توثق الـAPI دعم Idempotency Keys، فإن إضافة Header من عندك لا تجعل POST آمنة تلقائيًا.

headers = {
    "Idempotency-Key": "unique-operation-id"
}

# استخدم هذا فقط إذا كانت الـAPI توثق دعم المفتاح.
response = session.post(
    api_url,
    json=payload,
    headers=headers,
    timeout=10,
)

الدالة الكاملة create_retry_session

هذا هو قلب المشروع المرفق. كتبنا السياسة في دالة واحدة حتى لا نكرر الإعدادات في كل ملف:

from collections.abc import Collection

import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

DEFAULT_STATUS_FORCELIST = (
    429, 500, 502, 503, 504
)
DEFAULT_ALLOWED_METHODS = frozenset({
    "GET", "HEAD", "OPTIONS"
})


def create_retry_session(
    *,
    total=3,
    backoff_factor=0.25,
    backoff_jitter=0.1,
    status_forcelist=DEFAULT_STATUS_FORCELIST,
    allowed_methods=DEFAULT_ALLOWED_METHODS,
    respect_retry_after_header=True,
):
    retry = Retry(
        total=total,
        connect=total,
        read=total,
        status=total,
        other=0,
        status_forcelist=frozenset(status_forcelist),
        allowed_methods=frozenset(
            method.upper()
            for method in allowed_methods
        ),
        backoff_factor=backoff_factor,
        backoff_jitter=backoff_jitter,
        respect_retry_after_header=(
            respect_retry_after_header
        ),
        raise_on_status=True,
    )

    adapter = HTTPAdapter(max_retries=retry)
    session = requests.Session()
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session

أضفنا في النسخة الكاملة تحققًا من القيم السالبة أيضًا. وحددنا other=0 لتكون السياسة أكثر وضوحًا تجاه الأخطاء غير المصنفة، بينما يظل total الحد الأعلى العام.

استخدام Session في برنامج حقيقي

session = create_retry_session(
    total=4,
    backoff_factor=0.5,
    backoff_jitter=0.2,
)

response = session.get(
    "https://api.example.com/items",
    timeout=(3.05, 10),
)

response.raise_for_status()
data = response.json()
print(data)

استخدم رابط API الحقيقي وTimeout مناسبًا لخدمتك. ولا تنس إغلاق Session إذا كان عمرها محدودًا، أو استخدمها ضمن Context Manager إذا كان ذلك أنسب لتصميم البرنامج.

هل نعيد 500 دائمًا؟

لا توجد قائمة عالمية تناسب جميع APIs. أضفنا 500 إلى المثال لأنه قد يمثل عطلًا مؤقتًا في بعض الخدمات، لكن بعض الخوادم قد ترجع 500 بسبب خطأ دائم في بيانات الطلب أو Bug لا يفيد معه Retry. راجع توثيق الخدمة وسلوكها الفعلي.

القاعدة الأفضل: ابدأ بسياسة محافظة ثم وسّعها حسب توثيق الـAPI والمراقبة الفعلية.

ConnectionError وTimeout: هل يعيد Retry المحاولة؟

Retry يمكنها التعامل مع أنواع من أخطاء الاتصال والقراءة وفق إعداداتها، ولهذا مررنا connect=total وread=total. لكن أخطاء القراءة تحدث بعد إرسال الطلب في بعض الحالات، ولذلك تصبح مسألة الآثار الجانبية مهمة مرة أخرى، خصوصًا مع الطرق غير الآمنة لإعادة التنفيذ.

لا تحاول حل كل فشل شبكي بإعادة الطلب بلا تمييز. مع العمليات الحساسة استخدم سياسة مصممة وفق طبيعة Endpoint نفسها.

اختبارات المشروع: 20 اختبارًا دون إنترنت

شغّل الاختبارات من مجلد المشروع:

python -m unittest discover -s tests -v

شغلنا المجموعة كاملة على Python 3.13.5 مع Requests 2.32.5 وurllib3 2.7.0، وكانت النتيجة:

Ran 20 tests in 6.073s

OK

مدة الاختبار تختلف بين الأجهزة، خصوصًا لأن أحد الاختبارات ينتظر Retry-After: 1 عمدًا. الاختبارات تتحقق من:

  • إعادة GET بعد 503 حتى النجاح.
  • أن total=2 يسمح بمحاولتين إضافيتين بعد الأولى.
  • رفع RetryError عند استمرار 503.
  • احترام Retry-After في اختبار 429.
  • عدم إعادة POST افتراضيًا.
  • إعادة POST فقط إذا أضفناها صراحة إلى allowed_methods.
  • عدم إعادة 404 لأنها ليست في status_forcelist.
  • تركيب HTTPAdapter على HTTP وHTTPS وقيم Backoff وJitter.
اختبار سياسة Retry في requests باستخدام unittest والتحقق من إعادة GET واحترام 429 وعدم إعادة POST

طريقة تشغيل المشروع المرفق

python -m pip install -r requirements.txt
python demo.py
python -m unittest discover -s tests -v

لا يحتاج المشروع إلى مفتاح API، ولا يرسل البيانات إلى خدمة خارجية. كل تجارب 429 و503 تعمل على 127.0.0.1 باستخدام خادم Python محلي مؤقت يغلق بعد انتهاء المثال.

أخطاء شائعة عند بناء Retry لطلبات API

1. إعادة كل Status Code

لا تعامل 400 و401 و403 بالطريقة نفسها التي تعامل بها 429 و503. أصلح سبب الخطأ الدائم بدل إخفائه خلف Retry.

2. عدم تحديد timeout

Retry لا يعني أن كل محاولة يجب أن تنتظر بلا حدود مناسبة. مرر Timeout يلائم API.

3. جعل total كبيرًا جدًا

عشر إعادات مع Backoff قد تجعل طلب المستخدم يستغرق وقتًا طويلًا. اختر عددًا صغيرًا ثم راقب الخدمة.

4. إضافة POST إلى allowed_methods دون فهم

قد تكرر عملية شراء أو إنشاء سجل. استخدم Retry مع POST فقط وفق ضمانات واضحة من API، مثل دعم Idempotency موثق.

5. تجاهل Retry-After

في Rate Limit، الخادم قد يخبرك مباشرة بمدة الانتظار. تجاهلها قد يزيد عدد 429 بدل حلها.

6. استخدام مثال قديم بـ method_whitelist

قد تجد شروحات قديمة تستخدم method_whitelist. في واجهة urllib3 الحالية يستخدم الخيار allowed_methods. تحقق دائمًا من نسخة التوثيق المطابقة لإصدار مكتبتك.

جدول سريع لاختيار التصرف المناسب

الحالةهل Retry مناسب؟الملاحظة
429غالبًا نعماحترم Retry-After وسياسة Rate Limit
502/503/504غالبًا نعماستخدم Backoff وعددًا محدودًا
500يعتمدقد يكون مؤقتًا أو Bug دائمًا
401عادة لاصحح Token أو بيانات المصادقة
403عادة لاراجع الصلاحيات والسياسة
404غالبًا لاالمورد قد لا يكون موجودًا أصلًا
GET مع ConnectionError مؤقتقد يكون مناسبًاحدد Timeout وRetry محدودة
POST حساسلا افتراضيًاأعده فقط وفق دعم Idempotency أو ضمان مكافئ

مصادر رسمية ودروس مرتبطة

الأسئلة الشائعة حول Retry في requests

هل requests تعيد الطلب تلقائيًا عند فشل API؟

ليس بصورة شاملة افتراضيًا. للتحكم الدقيق في الحالات والأزمنة استخدم HTTPAdapter مع urllib3.util.Retry داخل Session.

كم محاولة ينفذ total=3؟

تعني ثلاث إعادات بعد المحاولة الأولى، أي يمكن أن يصل الإجمالي إلى أربع محاولات إذا استمر الفشل.

هل أضع 429 داخل status_forcelist؟

نعم عندما تريد أن تكون 429 من حالات Retry التي تديرها السياسة، مع تفعيل احترام Retry-After ومراعاة توثيق الـAPI.

ما الفرق بين backoff_factor وRetry-After؟

backoff_factor يحدد انتظارًا يحسبه العميل عند غياب تعليمات أنسب، بينما Retry-After يأتي من الخادم ويحدد متى يفضل إعادة الطلب.

هل أضيف POST إلى allowed_methods؟

ليس افتراضيًا. افعل ذلك فقط إذا كانت العملية قابلة لإعادة التنفيذ بأمان، أو إذا كانت الـAPI توفر Idempotency موثقة وتستخدمها بالطريقة الصحيحة.

هل Retry تحل كل مشاكل الاتصال؟

لا. Retry مفيدة للأعطال المؤقتة، لكنها لا تصلح رابطًا خاطئًا أو Token منتهيًا أو بيانات طلب غير صحيحة أو Bug دائمًا في الخادم.

إرسال تعليق

أحدث أقدم