ลองเขียน guard แบบที่ทุกคนมักใช้กัน:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
จากนั้นชี้ไปที่เซิร์ฟเวอร์ที่ส่ง status line กับ headers มาให้ทันที แต่กลับค้างก่อนจะส่ง body พอถึงจังหวะอ่านข้อมูลก็ timeout ทว่า guard นี้ไม่ทำงาน สิ่งที่ถูกโยนออกมาคือ ConnectionError และ ConnectionError ก็ไม่ได้สืบทอดมาจาก Timeout
ถ้าเป็นเคสค้างแบบเดียวกันกับ httpx จะได้ ReadTimeout ซึ่งเป็น TimeoutException และ guard แบบเทียบเคียงกันก็ดักได้
ตอนแรกผมตั้งใจจะเขียนเรื่องความต่างระหว่าง httpx กับ requests ที่กระทบงาน scraping โดยคิดว่าจะเน้น async เป็นหลัก สุดท้ายกลับเจอว่า async กลับเป็นเรื่องที่น่าสนใจน้อยที่สุดในลิสต์นี้
สิ่งที่ทดสอบ และวิธีที่ใช้
ผมทดสอบ 8 เคสกับ fixture server บนเครื่อง local เพราะสิ่งที่ client รายงานว่าเกิดขึ้น ไม่ได้แปลว่ามันเกิดขึ้นจริง เซิร์ฟเวอร์จะนับ TCP connections — เพิ่มขึ้นทุกครั้งที่รับ socket ใหม่ ก่อนจะเริ่ม parse request line — และนับ paths ที่ถูกดึงจริง การ reuse connection และการตาม redirect เป็นเรื่องของพฤติกรรมบน wire และ wire นี่แหละคือจุดที่ต้องตรวจสอบ
ใช้ httpx 0.28.1 พร้อม extra http2, requests 2.34.2, Python 3.14.2, บน macOS arm64 ทุกอย่างรันใน virtualenv ใหม่ชุดเดียว เพื่อไม่ให้ตัวหนึ่งติดร่องรอยจากอีกตัว Raw output: httpx-probes.json.
ก่อนรันครั้งแรก ผมใส่คำทำนายไว้ 6 ข้อใน harness และไม่แก้ทีหลัง มี 3 ข้อถูก 2 ข้อผิด และ 1 ข้อถูกแค่กรณีที่ผมนึกถึง แต่พลาดกรณีสำคัญที่สุด ดูบัญชีเต็ม ๆ ได้ใน prediction-scorecard.json
ค่าเริ่มต้นที่เปลี่ยนไปแบบไม่เตือนคุณ

| พฤติกรรม | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| ตาม redirect โดยค่าเริ่มต้น | ใช่ | ไม่ใช่ |
| การเรียกในระดับ module reuse connections | ไม่ | ไม่ |
| ค้างก่อน headers | ReadTimeout | ReadTimeout |
| ค้างกลาง body | ConnectionError | ReadTimeout |
| ไม่มี charset ระบุมา | ISO-8859-1 | utf-8 |
| HTTP/2 | ไม่มีให้ใช้ | ต้องเปิดเพิ่ม และใช้งานได้ |
| แยก timeout สำหรับ connect/read/write/pool | ไม่ | ใช่ |
ผลของ redirect, การ reuse socket, ประเภท exception, การ decode, และการ negotiate โปรโตคอล ถูกสังเกตจาก probe ส่วนรูปแบบ API ของ timeout และการที่ requests ไม่มี flag สำหรับ HTTP/2 เป็นการสังเกตความสามารถของ API ดู httpx-probes.json.
สามแถวในนี้สามารถเปลี่ยนพฤติกรรมโค้ดคุณแบบเงียบ ๆ ได้ในวันที่คุณย้ายไปใช้ตัวใหม่
Redirect: ปิดไว้เป็นค่าเริ่มต้น และเซิร์ฟเวอร์พิสูจน์ให้เห็น
เส้นทาง redirect 4 hop ที่จบที่ /ok:
| Client | เซิร์ฟเวอร์เห็น | สถานะที่ส่งกลับ |
|---|---|---|
| requests | 5 requests | 200 |
| httpx | 1 request | 302 |
httpx, follow_redirects=True | 5 requests | 200 |
เลข 5 คือ 4 hop บวกปลายทาง คำทำนายของผมเขียนไว้ว่า 4 ซึ่งเป็นการบวกเลขแบบไม่ได้เช็กเอง ทิศทางของข้อสังเกตนั้นถูก แต่ผมแก้จำนวนให้ตรงตรงนี้แทนที่จะเงียบไว้ในเนื้อหา
พฤติกรรมนี้มีเอกสารทางการของ httpx รองรับ และเป็นแนวคิดที่ปกป้องได้ เพราะ redirect เป็นสิ่งที่ caller อาจอยากรู้ ขณะเดียวกันมันก็เป็นหนึ่งในสาเหตุที่ทำให้ migration พังแบบไม่ส่ง error ได้ง่ายที่สุด โค้ดคุณได้ 302, response.text ว่าง, parser หาแถวไม่เจอ และ log ของคุณบอก 200 OK… หรือจริง ๆ แล้วมันบอก 302 แต่ไม่มีใครดู status code เพราะตอนใช้ requests เดิมทีไม่เคยต้องดู
ผลเรื่อง timeout ที่ผมทายกลับด้าน
ผมเดาว่า httpx จะระบุ phase ที่ล้มเหลวอย่างชัดเจน ส่วน requests จะรวมทุกอย่างไว้ในคลาสเดียว ปรากฏว่าเป็นตรงข้าม
เอกสารอ้างอิงทางการ: Requests timeout documentation.

เอกสารอ้างอิงทางการ: HTTPX timeout documentation.
| การค้าง | requests | httpx |
|---|---|---|
| ก่อน status line | ReadTimeout | ReadTimeout |
| กลาง body หลังส่ง headers แล้ว | ConnectionError | ReadTimeout |
httpx ใช้ชื่อเดียวกันได้ถูกต้องทั้งสองกรณี ส่วน requests แยกออกจากกัน — และแยกข้ามเส้นแบ่งที่โค้ด retry มักอ้างอิงอยู่
ผลลัพธ์นี้ไม่ได้สรุปจากลำดับชั้นของ class ผมทดสอบ guard จริง ๆ:
| การค้าง | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| ก่อน status line | จับได้ | จับได้ |
| กลาง body | หลุดออกไปเป็น ConnectionError | จับได้ |
timeout-retry-guard.json. requests.exceptions.ConnectionError ไม่ได้สืบทอดจาก requests.exceptions.Timeout; ส่วน httpx.ReadTimeout สืบทอดจาก httpx.TimeoutException.
ข้อความ exception ของ requests จะบอกว่า Read timed out. อยู่ใน ConnectionError ไลบรารีรู้ว่าเกิดอะไรขึ้น แค่ไม่บอก type system และ except ของคุณก็อิงตาม type system นี่แหละ
เคสที่วัดนี้มีเงื่อนไขเฉพาะ: headers มาถึงแล้ว แต่ body หยุดส่งนานพอจะเกิน read timeout response ที่ยังส่ง chunk ต่อเนื่องภายในช่วง timeout รวมถึง stream ที่ตั้งใจให้เป็นแบบนั้น อาจมีพฤติกรรมต่างออกไป และไม่ได้ทดสอบในบทความนี้
การ pool connection: ความต่างทั้งหมดอยู่ที่ client API
GET 10 ครั้ง, 4 วิธี, นับ socket ฝั่งเซิร์ฟเวอร์:
| วิธี | เปิด socket |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
ผลเหมือนกัน และควรพูดให้ชัด เพราะนี่คือความเข้าใจผิดที่พบบ่อยที่สุดเกี่ยวกับคู่นี้ — คนมักคิดว่า httpx pool ได้ แต่ requests ไม่ได้ ความจริงคือระดับ module ของทั้งคู่ไม่ pool การ pool เกิดผ่าน object แบบ client ถ้าวันนี้คุณเรียก requests.get() ในลูป แล้วเปลี่ยนเป็น httpx.get() ในลูป จำนวน socket churn ก็ไม่ต่างจากเดิม

HTTP/2 ต้องเปิดให้ชัด และต้องมี extra
ทดสอบกับ endpoint HTTP/2 สาธารณะหนึ่งตัวที่บันทึกไว้ใน artifact:
เอกสารอ้างอิงทางการ: RFC 9113: HTTP/2.
| Client | Negotiate ได้ |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, ไม่มี flag ให้เปิด |
คุณต้องใช้ extra httpx[http2] ผมเดาว่าการติดตั้งแค่ pip install httpx จะได้ client ที่ negotiate กลับไปเป็น 1.1 เงียบ ๆ เลยไปเช็กก่อนจะเขียนลงมา:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
มัน error ตั้งแต่ตอนสร้าง Client ก่อนจะส่ง request สักครั้ง และข้อความก็ระบุวิธีแก้ชัดเจน นี่คือเวอร์ชันที่ดีของความล้มเหลวแบบนี้ ซึ่งตอนแรกผมเข้าใจกลับด้าน (http2-extra-missing.json)
probe นี้ยืนยันได้ว่า protocol negotiation สำเร็จบน endpoint นั้น แต่ไม่ได้พิสูจน์ว่าเร็วขึ้นสำหรับงาน scraping เพราะไม่ได้ทดสอบ workload HTTP/1.1 ที่จับคู่กันโดยตรง
เปรียบเทียบ throughput แบบต่อเนื่องกับแบบ concurrent
ยี่สิบ requests ไปยัง endpoint ที่ sleep 0.3 วินาที:
| โหมด | เวลาจริง | จำนวน socket |
|---|---|---|
Sync, Client เดียว | 6.138 s | 1 |
Async, AsyncClient เดียว | 0.357 s | 20 |
การรันแบบ concurrent จบใน 0.357 วินาที เทียบกับ 6.138 วินาทีของการรันแบบ sequential แต่มันก็เปิด 20 connections ในขณะที่ client แบบ synchronous reuse เพียง 1 ดังนั้นการทดลองนี้จึงเปลี่ยนทั้ง execution model และระดับ concurrency ที่ใช้จริง มากกว่าจะเป็นการแยกวัดความเร็วของไลบรารีล้วน ๆ
นี่คือการตีความตัวเลขอย่างซื่อสัตย์ มันคือการวัด concurrency กับ endpoint ที่ตั้งใจให้ช้า ไม่ใช่การวัด httpx ไลบรารีใดก็ตามที่รองรับ async ได้ดีก็จะลงอยู่ในโซนเดียวกัน และถ้า endpoint เร็ว ช่องว่างนี้ก็หดลง
เคส charset ที่ผมไม่ได้คิดจะทำนาย
ผมทำนายว่า response ที่ header โกหกว่า charset=iso-8859-1 แต่จริง ๆ เป็น utf-8 bytes จะทำให้ทั้งคู่เพี้ยนเหมือนกัน ซึ่งก็เป็นจริง ทั้งคู่คืนค่าเป็น Café Ubersetzung â naïve résumé ในขณะที่ต้นฉบับเขียนว่า Café Ubersetzung — naïve résumé
แต่เคสที่ผมไม่ทำนาย และมันสำคัญกว่า คือ:
| Response | requests decode | httpx decode |
|---|---|---|
charset=utf-8, utf-8 bytes | ถูกต้อง | ถูกต้อง |
charset=iso-8859-1, utf-8 bytes | เพี้ยน | เพี้ยน |
| ไม่ระบุ charset เลย | เพี้ยน | ถูกต้อง |
requests จะ fallback ไปที่ ISO-8859-1 เมื่อ header ไม่ได้บอกอะไรไว้ ขณะที่ httpx ใช้ utf-8 เป็นค่าเริ่มต้น ดังนั้นใน fixture ที่ไม่มี charset ทั้งคู่จึงให้ข้อความที่ decode ต่างกันเมื่อใช้ .text ส่วนผู้ใช้ที่อ่าน response.content จะยังได้ bytes ดั้งเดิมเหมือนเดิม
เรื่อง memory เพราะวัดได้ง่าย
Peak RSS, /usr/bin/time -l, หนึ่ง process ใหม่ต่อช่อง:
| ช่อง | requests | httpx |
|---|---|---|
| Import อย่างเดียว | 36.0 MiB | 30.6 MiB |
| Import แล้ว GET หนึ่งครั้ง | 35.8 MiB | 40.7 MiB |
นี่คือ snapshot จากหนึ่ง process และค่าของ requests ที่ต่ำกว่าตอน import-only เล็กน้อยสะท้อน noise ของการรันมากกว่าความหมายเชิงทิศทาง จึงสรุปเรื่อง memory ตามแนวโน้มไม่ได้ ต้องมี sample ซ้ำและช่วงค่าเพิ่มเติม
แล้วทั้งหมดนี้หมายความว่าอย่างไรเวลาจะเลือกใช้
กำลังไล่บั๊กในโค้ด requests ที่มีอยู่? ตรวจ assumption เรื่อง redirect, handler ที่จับแค่ requests.exceptions.Timeout แต่คิดว่าจะครอบคลุมเคสค้างกลาง body ได้ และผู้ใช้ .text ที่รับ response ไม่มี charset
กำลังย้ายไป httpx แบบกลไกตรง ๆ? เปลี่ยน namespace ของ exception ไปใช้ httpx.TimeoutException หรือ class ที่เจาะจงตาม phase, ตัดสินใจว่าจะเปิด follow_redirects หรือไม่ และทดสอบ assumption เรื่อง decoding ใหม่ โค้ด handler ของ requests ที่มีอยู่เดิมก็พลาดเคสค้างกลาง body ที่สาธิตไว้แล้ว การย้ายไม่ได้เป็นตัวสร้างบั๊กเฉพาะกรณีนั้นขึ้นมา
กำลังเขียนของใหม่ที่ต้องดึง URL จำนวนมาก? httpx เป็นตัวเลือกที่น่าสนใจเมื่อคุณต้องใช้ AsyncClient และ timeout แยกสำหรับ connect, read, write, และ pool phase เหล่านี้บอกว่ารออยู่ตรงไหน — ตั้ง connection, ความคืบหน้าของ response body, การอัปโหลด request, หรือการรอ slot ใน local pool — ไม่ได้บอกว่าทำไมเซิร์ฟเวอร์ปลายทางถึงเป็นแบบนั้น
กำลังเขียนของเล็ก ๆ แบบ synchronous? requests ก็ใช้ได้ และพบได้ทุกที่ เหตุผลที่จะย้ายไม่ใช่เรื่องความเร็ว
ไม่ว่าจะเลือกอะไร ให้ใช้ object แบบ client แทนการเรียกฟังก์ชันระดับ module นั่นคือการเปลี่ยนเพียงข้อเดียวในลิสต์นี้ที่ได้ประโยชน์ชัดเจนในทั้งสองไลบรารี
managed API เหมาะตรงไหน
ทุกอย่างด้านบนคือชั้น fetch และชั้น fetch นี่แหละเป็นส่วนที่ง่ายที่สุด ไม่มีส่วนไหน render JavaScript, ไม่มีส่วนไหนจัดการ anti-bot challenge, และไม่มีส่วนไหนแปลง HTML ให้กลายเป็นแถวข้อมูลที่คุณต้องการ
หมายเหตุจากผู้เขียน: Thunderbit คือทางเลือกแบบ managed สำหรับการ render และ extraction จาก URL โดยตรง เราไม่ได้ทดสอบมันใน harness ของ HTTP client นี้ พิจารณาหมวดนี้ก็ต่อเมื่อปัญหาที่คุณอยากแก้คือการดึงหน้าเว็บหรือการ extract แบบมีโครงสร้าง — ไม่ใช่ semantics ของ HTTP client
ถ้าคุณแค่ดึงหน้าเว็บทั่วไปแล้ว parse เอง ทั้งสองตัวก็ยังอยู่ในขอบเขตการใช้งาน แต่ managed service คือการตัดสินใจคนละแบบกับการ build เอง ไม่ใช่หลักฐานว่าต้องเลือกไลบรารีไหน
ถ้าอยากดูภาพรวมของตลาดกว้างกว่านี้ บทสรุป web scraping API roundup ครอบคลุมตัวเลือกแบบ hosted และ open-source scraper pillar ครอบคลุมแบบ self-hosted
ลองใช้ Thunderbit สำหรับดึงข้อมูลเว็บ
บทสรุป
ถ้าคุณกำลังสร้าง fetch layer ใหม่บน Python ที่ต้องการ async concurrency, timeout แยกตาม phase, และ fallback เป็น UTF-8, httpx คือค่าตั้งต้นของผมภายใต้ข้อจำกัดที่ทดสอบครั้งนี้ ส่วน requests ยังเหมาะกับโค้ด synchronous ที่สุกงอมแล้ว ซึ่งความเสี่ยงจากการย้ายมีมากกว่าประโยชน์ที่ได้ โปรดระวังว่าเราไม่ได้ทดสอบ proxies, retry policy, TLS fingerprinting, streaming, uploads, และความแปรผันของเครือข่ายจริง ดังนั้นนี่ไม่ใช่อันดับจัดเรียง scraper client แบบครอบจักรวาล
เหตุผลหลักที่ต้องระวังคือค่าเริ่มต้นเรื่อง redirect และมันเป็นความเสี่ยงจริง ๆ ก็เพราะมันเป็นการตัดสินใจด้านการออกแบบที่ดีอยู่แล้ว สิ่งที่ implicit มักดีกว่า explicit จนกระทั่งมันกลายเป็นสิ่งสำคัญของโค้ดที่คุณปล่อยใช้งานไปแล้ว
scorecard ที่ลงทะเบียนไว้ล่วงหน้าจบด้วยคำทำนายถูก 3 ข้อ ผิด 2 ข้อ และอีก 1 ข้อที่ยังไม่ครบ สิ่งที่ควรเอาไปใช้จริงคือการแก้ความเข้าใจผิดเรื่อง exception กลาง body ส่วนที่เหลือควรตัดสินจากพฤติกรรมที่สังเกตได้ มากกว่าจะยึด narrative ของ scorecard
ลองใช้ Thunderbit สำหรับดึงข้อมูลเว็บ Get Started Free
คำถามที่พบบ่อย
httpx ไม่ตาม redirect จริงหรือ?
จริง ถ้าไม่เปิดเอง โดยค่าเริ่มต้นมันจะส่ง request แค่ครั้งเดียวสำหรับ chain 4 hop และ response จะกลับมาเป็น 302 ต้องใส่ follow_redirects=True ในแต่ละครั้ง หรือกำหนดครั้งเดียวที่ Client พฤติกรรมนี้มีเอกสารและตั้งใจออกแบบไว้แล้ว แต่ก็ยังเป็นจุดที่มีโอกาสพังเงียบที่สุดตอน migrate เพราะความล้มเหลวจะออกมาเป็นผล parse ว่าง ๆ ไม่ใช่ exception
except requests.exceptions.Timeout ยังไม่พอจริงหรือ?
ไม่พอสำหรับเซิร์ฟเวอร์ที่ค้างหลังส่ง headers กรณีนั้นจะโยน ConnectionError ซึ่งไม่ใช่ subclass ของ Timeout ดังนั้น guard จึงพลาด — ตรงนี้พิสูจน์จากการทดลองจริง ไม่ใช่การอนุมาน ถ้าต้องการจับทั้งคู่ ให้ใช้ requests.exceptions.RequestException แต่ก็ต้องยอมรับว่าคุณจะจับอย่างอื่นที่ไม่ใช่ timeout ไปด้วย
httpx เร็วกว่า requests ไหม? ไม่ได้ต่างกันแบบมีนัยสำคัญสำหรับการยิงทีละ request นั่นไม่ใช่เป้าหมายของมัน ตัวเลข 17.2× ในการทดสอบนี้คือผลของ 20 requests แบบ concurrent ไปยัง endpoint ที่หน่วง 0.3 วินาที ซึ่งเป็นการวัด concurrency ถ้างานของคุณเป็นแบบ sequential ก็อย่าคาดหวังความเร็วเพิ่ม แล้วเลือกจากค่าเริ่มต้นและพฤติกรรมแทน
ต้องใช้ extra http2 ไหม?
เฉพาะตอนที่คุณต้องการ HTTP/2 — และถ้าตั้ง http2=True โดยไม่มีมัน httpx จะ raise ImportError ตอนสร้าง Client พร้อมข้อความบอกให้ติดตั้ง httpx[http2] ไม่มีการ downgrade เงียบ ๆ ให้กังวล ผมตอนแรกคิดว่าจะเป็นแบบนั้น แต่ไปเช็กจริงก่อนเขียน
อะไรที่ไม่ได้ทดสอบในบทความนี้? พฤติกรรมของ proxy ซึ่งสำคัญมากสำหรับงาน scraping และควรมี harness แยกต่างหาก การ retry — httpx ไม่มี retry logic มาให้ ส่วน requests ได้มาจาก urllib3 ดังนั้นถ้าจะเทียบกันอย่างแฟร์ จริง ๆ ต้องเทียบ library สำหรับ retry สองตัวด้วยกัน การ fingerprint TLS ซึ่งเป็นแกนที่ระบบ anti-bot ดูจริง และทั้งสองไลบรารีไม่ได้แก้ให้คุณ Streaming และการอัปโหลดไฟล์ รวมถึงทุกอย่างในนี้รันบนเครื่องเดียว Python เวอร์ชันเดียว และใช้ localhost ใน 6 จาก 8 probes ดังนั้นตัวเลข latency จาก fixture server คือการวัดการออกแบบ ไม่ใช่การวัดเครือข่ายของคุณ


