Tulis guard yang dipakai hampir semua orang:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
Arahkan ke server yang langsung mengirim status line dan header, lalu berhenti sebelum body dikirim. Read timeout terjadi. Guard itu tidak terpanggil. Exception yang muncul adalah ConnectionError, dan ConnectionError bukan turunan dari Timeout.
Stall yang sama di httpx memunculkan ReadTimeout, yang merupakan TimeoutException, jadi guard setara akan menangkapnya.
Saya sempat cari tahu mana perbedaan httpx dan requests yang benar-benar bikin scraper kacau, dengan asumsi saya bakal nulis soal async. Ternyata, async justru bagian yang paling tidak menarik dari daftar ini.
Apa yang saya uji dan bagaimana caranya
Delapan probe ke server fixture lokal, karena laporan klien tentang apa yang terjadi bukan bukti nyata tentang apa yang benar-benar berlangsung. Server menghitung koneksi TCP — bertambah satu untuk setiap socket yang diterima, sebelum request line diparse — dan path yang benar-benar di-fetch. Reuse koneksi dan follow redirect sama-sama klaim soal wire, dan wire-lah tempat klaim itu diuji.
httpx 0.28.1 dengan extra http2, requests 2.34.2, Python 3.14.2, macOS arm64. Keduanya dijalankan dalam satu virtualenv baru, jadi tidak saling mewarisi jejak konfigurasi. Output mentah: httpx-probes.json.
Enam prediksi dimasukkan ke harness sebelum run pertama dan tetap ada sesudahnya. Tiga tepat, dua salah, satu benar untuk kasus yang saya bayangkan tapi meleset di kasus yang justru penting. prediction-scorecard.json memuat rinciannya.
Default yang berubah diam-diam

| Perilaku | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| Follow redirect secara default | ya | tidak |
| Pemanggilan level modul memakai ulang koneksi | tidak | tidak |
| Stall sebelum headers | ReadTimeout | ReadTimeout |
| Stall di tengah body | ConnectionError | ReadTimeout |
| Tanpa charset yang dideklarasikan | ISO-8859-1 | utf-8 |
| HTTP/2 | tidak tersedia | opsional, berfungsi |
| Timeout terpisah untuk connect/read/write/pool | tidak | ya |
Redirect, reuse socket, hasil exception, decoding, dan negosiasi protokol diamati lewat probe. Bentuk API timeout dan ketiadaan flag HTTP/2 di requests adalah observasi kemampuan API. httpx-probes.json.
Tiga baris di atas bisa mengubah perilaku kode Anda di hari migrasi, tanpa peringatan.
Redirect: nonaktif secara default, dan server membuktikannya
Rantai redirect empat hop yang berakhir di /ok:
| Client | Yang dilihat server | Status yang dikembalikan |
|---|---|---|
| requests | 5 request | 200 |
| httpx | 1 request | 302 |
httpx, follow_redirects=True | 5 request | 200 |
Angka lima itu empat hop ditambah tujuan akhir. Prediksi saya menulis empat, yang ternyata cuma hitung-hitungan yang tidak saya cek; arah klaimnya benar, dan jumlahnya saya koreksi di sini, bukan diam-diam di dalam narasi.
Ini perilaku httpx yang terdokumentasi dan desainnya masuk akal — redirect adalah sesuatu yang memang mungkin perlu diketahui caller. Namun, ini juga cara paling umum migrasi bisa rusak tanpa memunculkan error. Kode Anda menerima 302, response.text kosong, parser tidak menemukan baris apa pun, dan log Anda bilang 200 OK… padahal sebenarnya tertulis 302, dan tidak ada yang mengawasi status code karena di requests dulu memang tidak pernah ada yang perlu diawasi.
Temuan soal timeout, yang justru sempat saya balik
Saya memprediksi httpx akan menamai fase yang gagal, sedangkan requests akan menyamarkan keduanya dalam satu kelas. Ternyata kebalikannya.
Referensi resmi: Dokumentasi timeout Requests.

Referensi resmi: Dokumentasi timeout HTTPX.
| Stall | requests | httpx |
|---|---|---|
| Sebelum status line | ReadTimeout | ReadTimeout |
| Di tengah body, setelah header terkirim | ConnectionError | ReadTimeout |
httpx memberi nama yang sama dan akurat untuk keduanya. requests memisahkannya — dan memisahkannya tepat di batas yang biasanya jadi dasar kode retry.
Konsekuensinya bukan hasil inferensi dari hierarki class. Saya menjalankan guard-nya:
| Stall | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| Sebelum status line | menangkap | menangkap |
| Di tengah body | lolos sebagai ConnectionError | menangkap |
timeout-retry-guard.json. requests.exceptions.ConnectionError bukan turunan dari requests.exceptions.Timeout; httpx.ReadTimeout adalah turunan dari httpx.TimeoutException.
Pesan exception requests berbunyi Read timed out. di dalam ConnectionError. Library-nya tahu apa yang terjadi. Hanya saja, ia tidak meneruskannya ke sistem tipe, padahal itu yang dipakai klausa except Anda.
Kasus yang saya ukur ini spesifik: header datang dulu, lalu progres body berhenti cukup lama sampai lewat read timeout. Respons yang terus mengirim chunk di dalam window timeout, termasuk stream yang memang sengaja dibuat, bisa berperilaku berbeda dan tidak diuji di sini.
Connection pooling: perbedaan utamanya ada di API client
Sepuluh GET, empat cara, socket dihitung di sisi server:
| Cara | Socket yang dibuka |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
Hasilnya identik, dan ini layak disebut karena justru itu pengetahuan umum yang paling sering salah tentang pasangan ini — bahwa httpx melakukan pooling dan requests tidak. Faktanya, level modul di keduanya tidak melakukan pooling. Pooling terjadi lewat objek client. Kalau hari ini Anda memanggil requests.get() dalam loop, lalu pindah ke httpx.get() dalam loop, tidak ada yang berubah soal pergantian socket.

HTTP/2 itu eksplisit dan butuh extra
Terhadap satu endpoint HTTP/2 publik yang tercatat di artefak:
Referensi resmi: RFC 9113: HTTP/2.
| Client | Negosiasi |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, tidak ada flag |
Anda butuh extra httpx[http2]. Saya sempat mengira pip install httpx biasa akan memberi client yang diam-diam menegosiasikan 1.1, lalu saya cek dulu sebelum menuliskannya:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
Error-nya muncul saat konstruksi Client, sebelum satu request pun dikirim, dan pesannya langsung nunjukin solusinya. Ini versi gagal yang baik, dan saya sempat salah menduganya (http2-extra-missing.json).
Probe ini membuktikan negosiasi protokol berhasil pada endpoint tersebut. Ia tidak membuktikan manfaat kecepatan untuk scraping; tidak ada workload HTTP/1.1 yang dipasangkan untuk perbandingan.
Throughput fixture: sekuensial versus konkuren
Dua puluh request ke endpoint yang tidur 0,3 detik:
| Mode | Waktu total | Socket |
|---|---|---|
Sync, satu Client | 6.138 s | 1 |
Async, satu AsyncClient | 0.357 s | 20 |
Run konkuren selesai dalam 0,357 detik, dibanding 6,138 detik untuk run sekuensial. Ia juga membuka dua puluh koneksi sementara client sinkron memakai ulang satu koneksi, jadi eksperimen ini mengubah model eksekusi dan tingkat concurrency efektif, bukan mengisolasi kecepatan library.
Itulah framing yang jujur untuk angka tersebut. Ini ukuran concurrency terhadap endpoint yang sengaja lambat, bukan ukuran murni httpx. Client apa pun yang punya async story yang jalan akan ada di kisaran yang sama, dan terhadap endpoint yang cepat, jaraknya akan mengecil.
Kasus charset yang tidak saya duga akan penting
Saya memprediksi bahwa respons dengan header yang bohong — charset=iso-8859-1 pada byte utf-8 — akan menghasilkan mojibake yang sama di keduanya. Ternyata benar. Keduanya mengembalikan Café Ubersetzung â naïve résumé padahal sumber aslinya Café Ubersetzung — naïve résumé.
Kasus yang tidak saya prediksi justru yang penting:
| Respons | requests mendekode | httpx mendekode |
|---|---|---|
charset=utf-8, byte utf-8 | benar | benar |
charset=iso-8859-1, byte utf-8 | mojibake | mojibake |
| tanpa charset sama sekali | mojibake | benar |
requests kembali ke ISO-8859-1 saat header tidak menyebutkan apa pun, sedangkan httpx default ke utf-8. Pada fixture tanpa charset, kedua client karena itu menghasilkan teks terdekode yang berbeda lewat .text; consumer yang memakai response.content tetap melihat byte asli yang sama.
Memori, karena ini murah untuk diukur
Peak RSS, /usr/bin/time -l, satu proses baru per sel:
| Sel | requests | httpx |
|---|---|---|
| Import saja | 36.0 MiB | 30.6 MiB |
| Import + satu GET | 35.8 MiB | 40.7 MiB |
Ini snapshot satu proses, dan nilai requests yang satu GET-nya sedikit di bawah nilai import-only menunjukkan noise pengukuran. Dari sini tidak ada kesimpulan arah memori yang layak diambil; perlu sampel berulang dan rentang data.
Artinya saat Anda harus memilih
Mencari bug di kode requests yang sudah ada? Audit asumsi redirect, handler yang cuma menangkap requests.exceptions.Timeout padahal ingin menutup stall di tengah body, dan consumer .text yang menerima respons tanpa charset.
Migrasi mekanis ke httpx? Ubah namespace exception ke httpx.TimeoutException atau kelas fase yang lebih spesifik, tentukan apakah follow_redirects perlu diaktifkan, dan uji ulang asumsi decoding. Handler requests yang ada memang sudah melewatkan kasus mid-body yang saya demonstrasikan; migrasi tidak menciptakan bug spesifik itu.
Membuat sesuatu yang baru dan mengambil banyak URL? httpx layak dipilih saat Anda butuh AsyncClient dan timeout terpisah untuk connect, read, write, dan pool. Fase-fase itu memberi tahu di mana waktu tunggu terjadi — saat koneksi dibuat, body respons berjalan, upload request, atau pengambilan slot dari pool lokal — bukan kenapa host remote berperilaku seperti itu.
Membuat sesuatu yang kecil dan sinkron? requests tetap oke dan ada di mana-mana. Alasan pindah bukan speed.
Apa pun pilihan Anda, pakailah objek client, bukan fungsi level modul. Itu satu-satunya perubahan dalam daftar ini yang jelas menguntungkan di kedua library.
Di mana managed API cocok
Semua yang dibahas di atas adalah lapisan fetch, dan lapisan fetch justru bagian yang mudah. Tidak ada yang merender JavaScript, tidak ada yang menangani challenge anti-bot, dan tidak ada yang mengubah HTML menjadi baris data yang Anda inginkan.
Catatan penulis: Thunderbit adalah opsi managed kami untuk rendering dan ekstraksi dari URL. Ia tidak diuji dalam harness HTTP client ini. Pertimbangkan kategori ini hanya ketika masalah Anda adalah pengambilan halaman atau ekstraksi terstruktur — bukan semantik HTTP client.
Kalau Anda cuma mengambil halaman biasa dan memprosesnya sendiri, kedua client masih relevan. Layanan managed adalah keputusan build-versus-buy yang terpisah, bukan bukti untuk memilih salah satu library ini.
Untuk gambaran yang lebih luas, roundup API web scraping kami membahas opsi hosted, dan pilar open-source scraper membahas opsi self-hosted.
Coba Thunderbit untuk Ekstraksi Data Web
Kesimpulan
Untuk lapisan fetch Python baru yang butuh concurrency async, timeout spesifik per fase, dan fallback UTF-8, httpx jadi default saya dalam batasan yang diuji di sini. Requests tetap layak untuk kode sinkron yang matang ketika risiko migrasi lebih besar daripada manfaatnya. Proxy, kebijakan retry, fingerprint TLS, streaming, upload, dan variasi jaringan yang realistis tidak diuji, jadi ini bukan peringkat universal untuk semua client scraping.
Alasan untuk hati-hati ada pada default redirect, dan itu memang berbahaya justru karena merupakan keputusan desain yang bagus. Explicit memang lebih baik daripada implicit — sampai sesuatu yang implicit ternyata jadi penopang kode yang sudah terlanjur Anda kirim.
Scorecard yang dipra-registrasi berakhir dengan tiga prediksi benar, dua salah, dan satu prediksi yang belum lengkap. Koreksi yang paling berguna adalah kelas exception di tengah body; sisanya sebaiknya ditentukan dari perilaku yang diamati, bukan dari narasi scorecard.
Coba Thunderbit untuk Ekstraksi Data Web Get Started Free
FAQ
Apakah benar httpx tidak mengikuti redirect?
Tidak secara default. Server cuma menghitung satu request untuk rantai empat hop, dan respons kembali sebagai 302. Tambahkan follow_redirects=True per pemanggilan, atau set sekali di Client. Ini terdokumentasi dan memang disengaja; tetap saja ini yang paling mungkin diam-diam merusak migrasi, karena gagalnya berupa parse kosong, bukan exception.
Apakah except requests.exceptions.Timeout memang tidak cukup?
Tidak untuk server yang berhenti setelah mengirim header. Kasus itu memunculkan ConnectionError, yang bukan turunan Timeout, jadi guard-nya meleset — dibuktikan langsung, bukan disimpulkan. Tangkap requests.exceptions.RequestException kalau Anda ingin keduanya, dan terima bahwa Anda juga akan menangkap hal-hal yang bukan timeout.
Apakah httpx lebih cepat daripada requests? Tidak secara berarti, kalau cuma satu request pada satu waktu — memang bukan untuk itu. Angka 17,2× pada pengujian ini adalah ukuran dua puluh request konkuren terhadap endpoint 0,3 detik, jadi itu ukuran concurrency. Kalau workload Anda sekuensial, jangan berharap ada percepatan dan pilih berdasarkan default-nya.
Apakah saya perlu extra http2?
Hanya kalau Anda ingin HTTP/2 — dan kalau Anda mengaktifkan http2=True tanpa extra itu, httpx akan melempar ImportError saat Anda membangun Client, dengan pesan yang menyuruh Anda menginstal httpx[http2]. Tidak ada downgrade diam-diam yang perlu dikhawatirkan. Saya sempat mengira ada, lalu memeriksanya sebelum menulis.
Apa yang tidak diuji di sini? Perilaku proxy, yang sangat penting untuk scraping dan butuh harness tersendiri. Retry — httpx tidak menyertakan logika retry, sedangkan requests mendapatkannya dari urllib3, jadi perbandingan yang adil sebenarnya adalah perbandingan dua library retry. TLS fingerprinting, yaitu sumbu yang benar-benar dilihat sistem anti-bot dan yang tidak ditangani oleh kedua library. Streaming dan file upload. Dan semuanya di sini memakai satu mesin, satu versi Python, dan localhost untuk enam dari delapan probe — angka latensi dari fixture server adalah ukuran desain, bukan jaringan Anda.


