اكتب الحارس الذي يكتبه الجميع:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
وجّهه إلى خادم يرسل سطر الحالة والرؤوس فورًا ثم يتوقف قبل إرسال الجسم. هنا تنتهي مهلة القراءة. لكن الحارس لا يعمل. الاستثناء الناتج هو ConnectionError، وConnectionError ليس فرعًا من Timeout.
أما نفس التوقف مع httpx فيُنتج ReadTimeout، وهو من TimeoutException، لذلك يلتقطه الحارس المكافئ.
ذهبت أبحث عن الفروق بين httpx وrequests التي تسبب مشكلات لعمليات السحب، وكنت أتوقع أن أكتب عن async. لكن تبين أن async هو أقل جزء مثير للاهتمام في القائمة.
ماذا اختبرت وكيف
ثماني تجارب على خادم محلي مخصّص للاختبار، لأن ما يقوله العميل عن نفسه ليس دليلًا على ما فعله فعلًا. الخادم يحصي اتصالات TCP — ويزيد العداد مرة واحدة لكل مقبس مقبول، قبل تحليل أي سطر طلب — وكذلك المسارات التي تم جلبها فعلًا. إعادة استخدام الاتصالات وتتبع التحويلات كلاهما ادعاء يتعلقان بالشبكة، والشبكة هي المكان الذي يمكن التحقق منهما فيه.
httpx 0.28.1 مع إضافة http2، وrequests 2.34.2، وPython 3.14.2، وmacOS arm64. جرى كل شيء داخل virtualenv جديد حتى لا يرث أحدهما أثر الآخر. الناتج الخام: httpx-probes.json.
أُدخلت ستة توقّعات في بيئة الاختبار قبل التشغيل الأول وبقيت بعدها. ثلاث منها أصابت، واثنتان أخطأتا، وواحدة كانت صحيحة بالنسبة للحالة التي تخيلتها لكنها فاتت الحالة المهمة. يحتوي prediction-scorecard.json على الحصيلة.
الإعدادات الافتراضية التي تتغيّر من تحتك

| السلوك | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| يتبع التحويلات تلقائيًا | نعم | لا |
| استدعاء مستوى الوحدة يعيد استخدام الاتصالات | لا | لا |
| توقف قبل الرؤوس | ReadTimeout | ReadTimeout |
| توقف في منتصف الجسم | ConnectionError | ReadTimeout |
| عند عدم التصريح عن ترميز | ISO-8859-1 | utf-8 |
| HTTP/2 | غير متاح | اختياري، ويعمل |
| مهلات منفصلة للاتصال/القراءة/الكتابة/المخزن | لا | نعم |
تمت ملاحظة التحويلات، وإعادة استخدام المقابس، ونتائج الاستثناءات، وفك الترميز، وتفاوض البروتوكول عبر الاختبارات. أما شكل واجهة مهلات timeout وعدم وجود مفتاح HTTP/2 في requests فهي ملاحظات تخص قدرات الـ API. httpx-probes.json.
ثلاثة من هذه الصفوف ستغيّر ما يفعله كودك يوم تنتقل، من دون أن تلاحظ.
التحويلات: معطلة افتراضيًا، والخادم يثبت ذلك
سلسلة تحويلات من أربع قفزات تنتهي عند /ok:
| العميل | ما رآه الخادم | الحالة المرجعة |
|---|---|---|
| requests | 5 طلبات | 200 |
| httpx | طلب واحد | 302 |
httpx مع follow_redirects=True | 5 طلبات | 200 |
الخمسة هنا تعني أربع قفزات إضافة إلى الوجهة النهائية. تنبئي كان أربعة، وهو حساب لم أتحقق منه؛ الاتجاه كان هو الفكرة الأساسية، وتم تصحيح العدد هنا بدلًا من تمريره بصمت داخل السرد.
هذا السلوك موثّق في httpx، وهو قرار تصميم قابل للدفاع عنه — فالتحويل شيء قد يرغب المستدعي في معرفته. لكنه أيضًا أكثر سبب محتمل لكسر عملية انتقال من دون ظهور خطأ. يحصل الكود على 302، وتكون response.text فارغة، ولا يجد المحلل أي صفوف، وتقول السجلات 200 OK… باستثناء أنها تقول 302، ولم يكن أحد يراقب رمز الحالة لأن requests لم يكن هناك أصلًا ما يجب مراقبته.
نتيجة المهلة، وهي النقطة التي أخطأت فيها
توقعت أن يطلق httpx اسم المرحلة التي فشلت، وأن يخلط requests المرحلتين في فئة واحدة. الحقيقة معكوسة.
مرجع رسمي: توثيق مهلات Requests.

مرجع رسمي: توثيق مهلات HTTPX.
| التوقف | requests | httpx |
|---|---|---|
| قبل سطر الحالة | ReadTimeout | ReadTimeout |
| في منتصف الجسم، بعد إرسال الرؤوس | ConnectionError | ReadTimeout |
httpx يمنح الاسم نفسه والدقيق للحالتين. أما requests فيفصل بينهما — ويفصل بينهما عند الحد الذي يُبنى عليه كود إعادة المحاولة.
والنتيجة ليست استنتاجًا من تسلسل الوراثة بين الأصناف. لقد شغّلت الحارس:
| التوقف | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| قبل سطر الحالة | يلتقطه | يلتقطه |
| في منتصف الجسم | يفلت كـ ConnectionError | يلتقطه |
timeout-retry-guard.json. requests.exceptions.ConnectionError ليس فرعًا من requests.exceptions.Timeout؛ بينما httpx.ReadTimeout فرع من httpx.TimeoutException.
رسالة استثناء requests تقول Read timed out. داخل ConnectionError. المكتبة تعرف ما الذي حدث، لكنها لا تنقله إلى نظام الأنواع، ونظام الأنواع هو ما تفحصه عبارة except لديك.
الحالة المقاسة هنا محددة: تصل الرؤوس، ثم يتوقف تقدم الجسم مدة تكفي لتجاوز مهلة القراءة. أما الاستجابة التي تواصل إرسال الأجزاء خلال نافذة المهلة، بما في ذلك البث المقصود، فقد تتصرف بشكل مختلف ولم تُختبر هنا.
تجميع الاتصالات: الفرق كله في واجهة العميل
عشر طلبات GET، بأربع طرق، مع عدّ المقابس من جهة الخادم:
| الطريقة | عدد المقابس المفتوحة |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
النتيجة متطابقة، ومن المهم قول ذلك لأن هذا هو أكثر اعتقاد شائع حول هاتين المكتبتين — أن httpx يجمع الاتصالات بينما requests لا يفعل. لا واحدة منهما تقوم بذلك على مستوى الوحدة. الاثنتان تجمعان الاتصالات عبر كائن العميل. إذا كنت تستخدم requests.get() داخل حلقة اليوم، فإن الانتقال إلى httpx.get() داخل حلقة لا يغيّر شيئًا في عدد الاتصالات المفتوحة.

HTTP/2 صريح ويتطلب الإضافة
في مواجهة نقطة نهاية عامة تدعم HTTP/2 وسُجلت في الملف:
مرجع رسمي: RFC 9113: HTTP/2.
| العميل | البروتوكول المتفاوض عليه |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1، ولا يوجد علم لذلك |
تحتاج إلى الإضافة httpx[http2]. افترضت أن pip install httpx وحدها ستعطيك عميلًا يتفاوض بصمت على 1.1، وذهبت لأتحقق قبل أن أكتب ذلك:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
الخطأ يظهر عند إنشاء Client، قبل أي طلب واحد، والرسالة توضّح الحل. هذه هي النسخة الجيدة من هذا الفشل، وكنت قد فهمتها بالعكس (http2-extra-missing.json).
هذه التجربة تثبت نجاح التفاوض على البروتوكول في تلك النقطة النهائية. لكنها لا تثبت أي فائدة في سرعة السحب؛ فلم تُختبر أي حمولة مطابقة على HTTP/1.1.
الأداء المتسلسل مقابل المتوازي على الخادم التجريبي
عشرون طلبًا إلى نقطة نهاية تنام 0.3 ثانية:
| الوضع | الزمن الكلي | المقابس |
|---|---|---|
متزامن، Client واحد | 6.138 ثانية | 1 |
متزامن/توازٍ، AsyncClient واحد | 0.357 ثانية | 20 |
المنفذ المتوازي اكتمل في 0.357 ثانية مقابل 6.138 ثانية للتشغيل المتسلسل. لكنه أيضًا فتح عشرين اتصالًا بينما أعاد العميل المتزامن استخدام اتصال واحد، لذا فالتجربة تغيّر نموذج التنفيذ ومستوى التوازي الفعلي بدلًا من عزل سرعة المكتبة نفسها.
هذا هو الوصف الصادق للرقم. إنه قياس للتوازي مقابل نقطة نهاية بطيئة عمدًا، وليس قياسًا لـ httpx نفسه. أي عميل يملك قصة async جيدة سيقع في المنطقة نفسها تقريبًا، ومع نقطة نهاية سريعة ينهار الفرق.
حالة الترميز التي لم يخطر ببالي أن أتنبأ بها
توقعت أن الاستجابة التي تكذب رؤيتها — charset=iso-8859-1 على بايتات utf-8 — ستنتج نفس التشويه النصي في المكتبتين. وهذا ما يحدث. كلتاهما تعيدان Café Ubersetzung â naïve résumé حيث النص الأصلي يقول Café Ubersetzung — naïve résumé.
الحالة التي لم أتوقعها هي الأهم:
| الاستجابة | كيف يفك requests الترميز | كيف يفك httpx الترميز |
|---|---|---|
charset=utf-8، بايتات utf-8 | صحيح | صحيح |
charset=iso-8859-1، بايتات utf-8 | تشويه نصي | تشويه نصي |
| لا يوجد charset إطلاقًا | تشويه نصي | صحيح |
requests يعود افتراضيًا إلى ISO-8859-1 عندما لا يذكر الرأس أي شيء، بينما httpx يفترض utf-8. لذلك، في عينة غياب charset، أنتج العميلان نصًا مفكوك الترميز بشكل مختلف عبر .text؛ أما من يستهلك response.content فيحتفظ بالبايتات الأصلية نفسها.
الذاكرة، بما أنها سهلة القياس
أقصى RSS، عبر /usr/bin/time -l, مع عملية جديدة لكل خلية:
| الخلية | requests | httpx |
|---|---|---|
| استيراد فقط | 36.0 MiB | 30.6 MiB |
| استيراد + طلب GET واحد | 35.8 MiB | 40.7 MiB |
هذه لقطات لعملية واحدة، وكون قيمة requests مع GET واحد أقل قليلًا من قيمة الاستيراد فقط يكشف ضجيج التشغيل. لا تسمح هذه الأرقام بأي استنتاج اتجاهي بشأن الذاكرة؛ فالأمر يحتاج إلى عينات متعددة ونطاقات.
ماذا يعني ذلك عند الاختيار
هل تبحث عن أخطاء في كود requests الموجود؟ راجع افتراضات التحويلات، والمعالجات التي تلتقط فقط requests.exceptions.Timeout مع أنها تتوقع تغطية توقف في منتصف الجسم، ومستهلكي .text الذين تصلهم استجابات بلا charset.
هل تقوم بترحيل ميكانيكي إلى httpx؟ غيّر مساحات أسماء الاستثناءات إلى httpx.TimeoutException أو إلى أصناف المرحلة الأضيق، وحدد هل تريد تفعيل follow_redirects، وأعد اختبار افتراضات فك الترميز. معالج requests الحالي لا يلتقط أصلًا حالة منتصف الجسم المعروضة هنا؛ الترحيل لا يخلق هذه المشكلة بعينها.
هل تبني شيئًا جديدًا يجلب عددًا كبيرًا من الروابط؟ يصبح httpx خيارًا مناسبًا عندما تحتاج إلى AsyncClient وإلى مهلات منفصلة للاتصال والقراءة والكتابة والمخزن. هذه المراحل تخبرك أين حدث الانتظار — إنشاء الاتصال، أو تقدم جسم الاستجابة، أو رفع الطلب، أو الحصول على مقبس محلي من المخزن — لا لماذا تصرّف المضيف البعيد بهذه الصورة.
هل تكتب شيئًا صغيرًا ومتزامنًا؟ requests مناسب وموجود في كل مكان. السبب للانتقال ليس السرعة.
مهما كان اختيارك، استخدم كائن العميل بدلًا من دالة مستوى الوحدة. هذا هو التغيير الوحيد في هذه القائمة الذي يمثل مكسبًا مباشرًا في كلتا المكتبتين.
أين يناسب API مُدار
كل ما سبق يتعلق بطبقة الجلب، وطبقة الجلب هي الجزء السهل. أي من ذلك لا ينفّذ JavaScript، ولا يتعامل مع تحدي anti-bot، ولا يحول HTML إلى الصفوف التي تريدها.
ملاحظة المؤلف: Thunderbit هو خيارنا المُدار عندما تحتاج إلى إدخال URL ثم الحصول على الصفحة مُعالجة ومُستخرجة. لم يُختبر هنا ضمن هذا الإطار الخاص بعملاء HTTP. لا تفكر في هذه الفئة إلا عندما تكون المشكلة هي جلب الصفحة أو الاستخراج المنظم — لا سلوك عميل HTTP نفسه — الذي تريد التخلص منه.
إذا كنت تجلب صفحات عادية وتحوّلها بنفسك، فكلتا المكتبتين ما تزالان ضمن النطاق. الخدمة المُدارة قرار مختلف بين البناء والشراء، وليست دليلًا لاختيار هذه المكتبة أو تلك.
وللاطلاع على المشهد الأوسع، تغطي مراجعتنا لأفضل واجهات برمجة تطبيقات استخراج البيانات من الويب الخيارات المستضافة، بينما يغطي دليل أدوات السحب مفتوحة المصدر الخيارات ذاتية الاستضافة.
جرّب Thunderbit لاستخراج بيانات الويب
الحكم النهائي
لبناء طبقة جلب جديدة في Python تحتاج إلى توازٍ async، ومهلات خاصة بكل مرحلة، وبديل افتراضي إلى UTF-8، فإن httpx هو خياري الافتراضي ضمن القيود التي اختُبرت هنا. أما requests فلا يزال صالحًا للكود المتزامن الناضج عندما تكون مخاطر الترحيل أكبر من هذه الفوائد. لم تُختبر البروكسيات، وسياسات إعادة المحاولة، وتبصيم TLS، والبث، ورفع الملفات، وتقلّبات الشبكة الواقعية، لذا فهذا ليس ترتيبًا شاملًا لعملاء السحب.
السبب الذي يستدعي الحذر هو الإعداد الافتراضي للتحويلات، وهو خطر حقيقي تحديدًا لأنه قرار تصميم جيد. الصراحة أفضل من الضمنية إلى أن يكون الشيء الضمني حاملًا لجزء أساسي من الكود الذي شحنته بالفعل.
انتهت بطاقة النتائج المسجّلة مسبقًا إلى ثلاث توقعات صحيحة، واثنتين خاطئتين، وتوقع واحد غير مكتمل. التصحيح المفيد كان في صنف الاستثناء الخاص بمنتصف الجسم؛ أما بقية القرار فيجب أن تأتي من السلوك المرصود لا من سرد بطاقة النتائج.
جرّب Thunderbit لاستخراج بيانات الويب Get Started Free
الأسئلة الشائعة
هل صحيح أن httpx لا يتبع التحويلات تلقائيًا؟
نعم، افتراضيًا لا يفعل ذلك. الخادم عدّ طلبًا واحدًا فقط لسلسلة من أربع قفزات، وعادت الاستجابة كـ 302. مرّر follow_redirects=True مع كل طلب، أو اضبطها مرة واحدة على Client. هذا موثّق ومقصود؛ لكنه مع ذلك أكثر شيء مرجح أن ينكسر بصمت أثناء الترحيل، لأن الفشل هنا يكون تحليلًا فارغًا لا استثناءً.
هل except requests.exceptions.Timeout غير كافٍ فعلًا؟
ليس مع خادم يتوقف بعد إرسال الرؤوس. تلك الحالة ترفع ConnectionError، وهو ليس فرعًا من Timeout, لذلك يفوته الحارس — وقد أثبتنا ذلك مباشرة بدل استنتاجه. التقط requests.exceptions.RequestException إذا أردت الاثنين، مع قبول أنك ستلتقط أيضًا أشياء ليست مهلات.
هل httpx أسرع من requests؟ ليس بشكل ملحوظ عند تنفيذ طلب واحد في كل مرة — فهذا ليس الغرض منه. نسبة 17.2× في هذا الاختبار هي قياس لعشرين طلبًا متوازيًا ضد نقطة نهاية تستغرق 0.3 ثانية، أي إنها قياس للتوازي. إذا كان عملك متسلسلًا، فلا تتوقع تسريعًا، واختر بناءً على الإعدادات الافتراضية.
هل أحتاج إلى إضافة http2؟
فقط إذا أردت HTTP/2 — وإذا ضبطت http2=True بدونها، فإن httpx يرمي ImportError عند إنشاء Client، مع رسالة تخبرك بتثبيت httpx[http2]. لا يوجد هبوط صامت يقلقك. كنت أتوقع هبوطًا كهذا وتحققت بدل أن أكتبه.
ما الذي لم يُختبر هنا؟ سلوك البروكسيات، وهو مهم جدًا في السحب ويحتاج إلى بيئة اختبار خاصة به. إعادة المحاولة — httpx لا يوفّر منطق إعادة المحاولة، بينما يحصل requests عليه من urllib3، لذلك المقارنة العادلة هنا هي بين مكتبتين لإعادة المحاولة. تبصيم TLS، وهو المحور الذي تنظر إليه أنظمة anti-bot فعليًا، ولا تعالجه أي من المكتبتين. البث ورفع الملفات. وكل ما هنا جرى على جهاز واحد، وإصدار Python واحد، وlocalhost في ست تجارب من أصل ثمانٍ — فأرقام التأخير من خادم تجريبي هي قياس للتصميم، لا لشبكتك.


