寫出大家都會寫的防護程式:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
把它對準一台會立刻送出狀態列與標頭、但在正文前就卡住的伺服器。讀取逾時了,這個防護卻沒有被觸發。最後拋出的例外是 ConnectionError,而 ConnectionError 並不是 Timeout 的子類別。
同樣的卡住情況在 httpx 上會拋出 ReadTimeout,而 ReadTimeout 屬於 TimeoutException,所以對應的防護程式就能接住。
我原本想研究 httpx 和 requests 在哪些地方會害爬蟲出問題,還以為重點會是 async。結果 async 反而是清單裡最不有趣的部分。
我測了什麼,以及怎麼測的
我對一台本機 fixture server 做了八項探測,因為客戶端自己說它做了什麼,不能算證據。伺服器會統計 TCP 連線數——每接受一個 socket 就加一次,還沒解析任何 request line 前就算——以及 實際抓取的路徑。連線重用與 redirect 跟隨,都是對線上實際傳輸行為的主張,而真正驗證它們的地方,就是 wire。
測試環境是 httpx 0.28.1(含 http2 extra)、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 |
|---|---|---|
| 預設會跟隨 redirects | 是 | 否 |
| 模組層級呼叫會重用連線 | 否 | 否 |
| 在標頭前卡住 | ReadTimeout | ReadTimeout |
| 在正文中段卡住 | ConnectionError | ReadTimeout |
| 未宣告 charset | ISO-8859-1 | utf-8 |
| HTTP/2 | 不支援 | 需明確啟用,可運作 |
| 可分開設定 connect / read / write / pool timeout | 否 | 是 |
redirect、socket 重用、例外結果、編碼解碼與協定協商,都是透過探測實測得出。timeout API 的形狀,以及 requests 沒有 HTTP/2 flag,則屬於 API 能力層面的觀察。httpx-probes.json。
其中有三個欄位,會在你切換的那天直接改變程式行為,而且通常不會出聲。
Redirect:預設關閉,而且伺服器證明了這件事
一條四段式 redirect 鏈,最後導向 /ok:
| 客戶端 | 伺服器看到的請求數 | 回傳狀態 |
|---|---|---|
| requests | 5 次請求 | 200 |
| httpx | 1 次請求 | 302 |
httpx,follow_redirects=True | 5 次請求 | 200 |
這裡的 5,是 4 次跳轉加上最終目的地。我原本預測成 4,那只是我沒重新核對算術;方向性的主張是對的,數字則在這裡直接更正,不會偷偷埋在內文裡。
這是 httpx 文件明確寫出的行為,也是合理的設計——redirect 本來就是呼叫端可能想知道的事。只是它也是最容易讓遷移在不報錯的情況下壞掉的地方。你的程式拿到 302,response.text 是空的,parser 找不到任何列,而 log 卻顯示 200 OK……只是實際上 log 會寫 302,而且沒人監看狀態碼,因為在 requests 裡根本沒這個需要。
超時發現:這次我判斷反了
我原本以為 httpx 會清楚標示失敗發生在哪個階段,而 requests 會把兩種情況混成一類。實際上剛好相反。
官方參考:Requests timeout 文件。

官方參考:HTTPX timeout 文件。
| 卡住的位置 | requests | httpx |
|---|---|---|
| 在狀態列前卡住 | ReadTimeout | ReadTimeout |
| 在標頭送出後、正文中段卡住 | ConnectionError | ReadTimeout |
httpx 會把這兩種情況都用同一個、而且正確的名稱表示。requests 則把它們拆開了,而且拆在重試程式最常依賴的邊界上。
這個結果不是我從 class hierarchy 推論出來的,而是實際把防護程式跑了一遍:
| 卡住位置 | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| 在狀態列前 | 會接住 | 會接住 |
| 正文中段 | 以 ConnectionError 逃出 | 會接住 |
timeout-retry-guard.json。requests.exceptions.ConnectionError 並不是 requests.exceptions.Timeout 的子類別;httpx.ReadTimeout 則是 httpx.TimeoutException 的子類別。
requests 的例外訊息會在 ConnectionError 裡寫著 Read timed out.。也就是說,library 知道發生了什麼,只是它沒有把資訊傳給 type system,而你的 except 子句看的就是 type system。
這次測到的情境很特定:標頭已經到達,接著正文進度停住,直到超過 read timeout。若 response 在 timeout 視窗內持續送出 chunk(包含刻意設計的串流),行為可能不同;這裡沒有測這一類情況。
連線池:差異其實全在 client API
十個 GET,四種寫法,由伺服器端統計 socket 數:
| 做法 | 打開的 socket 數 |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
這兩者在這一點上其實一樣,而且值得特別講,因為很多人對這對工具最常見的印象就是「httpx 會 pool、requests 不會」。其實 module-level 都不會,真正會 pool 的是 client 物件本身。如果你今天是在 loop 裡呼叫 requests.get(),改成 loop 裡呼叫 httpx.get(),對 socket churn 沒有任何改善。

HTTP/2 是明確選項,而且需要 extra
針對一個公開的 HTTP/2 endpoint,記錄如下:
官方參考:RFC 9113: HTTP/2。
| 客戶端 | 協商結果 |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1,且沒有可用的旗標 |
你需要安裝 httpx[http2] extra。我原本以為單純 pip install httpx 也會讓 client 自動悄悄退回 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)。
這個探測只證明該 endpoint 的協定協商成功,並不能證明爬取速度變快;這裡沒有測同等的 HTTP/1.1 工作負載。
依序執行與並發執行的 fixture 吞吐量
對一個會 sleep 0.3 秒的 endpoint 發出 20 次請求:
| 模式 | 總耗時 | Socket 數 |
|---|---|---|
Sync,單一 Client | 6.138 s | 1 |
Async,單一 AsyncClient | 0.357 s | 20 |
並發版本在 0.357 秒內完成,而依序版本則花了 6.138 秒。它同時打開了 20 條連線,而同步 client 只重用了 1 條,所以這個實驗改變的是執行模型與實際並發度,而不是單純隔離 library 速度。
這才是這個數字最誠實的解讀:它是在刻意放慢的 endpoint 上測並發性,不是在測 httpx 本身。任何有正常 async 能力的 client,大致都會落在同一區間;如果 endpoint 很快,差距也會縮小。
我沒想到要預測的 charset 案例
我原本猜測:如果 response header 說謊——在 utf-8 bytes 上標成 charset=iso-8859-1——兩邊都會產生一樣的亂碼。確實如此。兩者都會回傳 Café Ubersetzung â naïve résumé,而原始內容其實是 Café Ubersetzung — naïve résumé。
但我沒預測到的是這個真正重要的情況:
| Response | requests 解碼結果 | httpx 解碼結果 |
|---|---|---|
charset=utf-8,utf-8 bytes | 正確 | 正確 |
charset=iso-8859-1,utf-8 bytes | 亂碼 | 亂碼 |
| 完全沒有 charset | 亂碼 | 正確 |
當 header 沒有寫明任何編碼時,requests 會退回 ISO-8859-1,而 httpx 則預設 utf-8。因此在缺少 charset 的 fixture 裡,兩者透過 .text 會解出不同文字;若消費端使用的是 response.content,則仍會保留相同的原始 bytes。
記憶體,因為這很容易量
峰值 RSS,/usr/bin/time -l,每個 cell 都是全新的 process:
| 測項 | requests | httpx |
|---|---|---|
| 只 import | 36.0 MiB | 30.6 MiB |
| import 再加一次 GET | 35.8 MiB | 40.7 MiB |
這些都是單一 process 的快照,而 requests 的「只 import」比「import + 一次 GET」還高一點,正好暴露了執行雜訊。它們無法支持任何方向性的記憶體結論;若要有結論,必須有重複樣本與範圍。
這代表什麼,取決於你怎麼選
如果你是在現有的 requests 程式裡找 bug? 請檢查 redirect 假設、那些只捕捉 requests.exceptions.Timeout 卻以為能涵蓋正文中段卡住的 handler,以及會對沒有 charset 的 response 直接使用 .text 的程式。
如果你只是機械式地遷移到 httpx? 請把例外命名空間改成 httpx.TimeoutException 或更細的階段別 class,決定是否啟用 follow_redirects,並重新測一次解碼假設。原本的 requests handler 其實已經漏掉了這個已展示的正文中段情況;遷移本身不會新造出這個 bug。
如果你要寫一個新的、會抓很多網址的工具? 當你需要 AsyncClient,以及可分開設定 connect、read、write、pool timeout 時,httpx 是合理候選。這些階段告訴你卡在哪裡——連線建立、response body 前進、請求上傳,或本機 pool 取得——而不是告訴你遠端主機為什麼那樣做。
如果你只是在寫一個小型同步程式? requests 完全可以,而且它很普及。你要換的理由不是速度。
不管你選哪個, 都要用 client 物件,不要用 module-level function。這是清單裡唯一對兩個 library 都是直接加分的改動。
受管理 API 在哪裡適合
上面講的全部都是抓取層,而抓取層其實是最簡單的部分。它們都不會處理 JavaScript,不會應付 anti-bot challenge,也不會把 HTML 轉成你要的資料列。
作者註記:Thunderbit 是我們提供的受管理方案,適合處理 URL 進站渲染與抽取。它沒有在這個 HTTP client 測試架構裡被測試過。只有在你要解決的是頁面取得或結構化抽取,而不是 HTTP client 語意時,才應該考慮這類方案。
如果你抓的是一般網頁,而且打算自己解析,那兩種 client 都還在討論範圍內。受管理服務是另一個 build-versus-buy 的決策,不能拿來當作這兩個 library 之間的選擇證據。
更完整的脈絡可參考我們的 web scraping API 綜覽,以及 開源爬蟲總整理。
結論
如果你要打造一個新的 Python 抓取層,並且需要 async 並發、按階段設定 timeout、以及 utf-8 fallback,那在這次測試條件下,httpx 是我的預設選擇。對於成熟的同步程式而言,如果遷移風險高於這些優勢,requests 依然完全可用。代理、重試策略、TLS 指紋、串流、上傳,以及真實網路波動都沒測,所以這不是一份通用的 scraping client 排名。
之所以要小心,是因為 redirect 的預設值;而它真正危險,正是因為它其實是一個好設計。顯式優於隱式,直到那個隱式行為在你已經上線的程式裡,變成了關鍵依賴。
預先登記的 scorecard 最後是三個預測正確、兩個錯誤、一個不完整。真正有用的修正,是正文中段的例外類別;其他決策,還是應該以實測行為為準,而不是 scorecard 敘事。
試用 Thunderbit 進行網頁資料擷取 Get Started Free
常見問題
httpx 真的不會自動跟隨 redirects 嗎?
沒錯,預設不會。伺服器對一條四段式鏈只收到了 1 次請求,而回應狀態是 302。你可以每次呼叫時加上 follow_redirects=True,或在 Client 上一次設定好。這是文件明確定義的行為;但它仍然最容易在遷移時悄悄壞掉,因為失敗形式是 parser 沒東西可抓,而不是例外。
except requests.exceptions.Timeout 真的不夠嗎?
如果伺服器是在送出標頭後才卡住,那就不夠。那種情況會拋出 ConnectionError,而它不是 Timeout 的子類別,所以防護會漏掉——這是直接實測,不是推論。如果你想兩種都接住,可以改捕捉 requests.exceptions.RequestException,但要接受你也會一起捕捉到一些不是 timeout 的錯誤。
httpx 比 requests 快嗎? 對單次請求來說,沒有什麼實質差別——它本來就不是拿來比這個的。這次測到的 17.2 倍,是在 0.3 秒 endpoint 上同時發出 20 個並發請求的結果,本質上是在測並發。如果你的工作負載是依序執行,那就不要期待速度提升,改看預設行為比較實際。
我需要 http2 extra 嗎?
只有當你想用 HTTP/2 時才需要;如果你沒有裝它卻設了 http2=True,httpx 會在建立 Client 時直接丟出 ImportError,並告訴你要安裝 httpx[http2]。不用擔心默默降級。我原本以為會默默退回,結果實際去確認了才發現不是。
這裡沒有測哪些東西? 代理行為,這對爬蟲非常重要,而且需要獨立的測試架構。重試機制——httpx 本身不附重試邏輯,而 requests 是從 urllib3 取得,所以公平比較其實是在比兩個重試 library。TLS 指紋,這才是 anti-bot 系統真正看的指標,而兩者都沒處理。串流與檔案上傳。還有,這裡全都只是在一台機器、一個 Python 版本、以及六個探測都用 localhost 的條件下完成;fixture server 的 latency 數字,反映的是設計,不是你的網路。


