Jaringan selalu bisa gagal: server sedang sibuk, koneksi terputus di tengah jalan, atau sebuah layanan mati sementara. Tanpa persiapan, program kita hanya punya dua nasib — menggantung selamanya menunggu response, atau langsung mati pada gangguan pertama. Pada Chapter ini kita memasang dua pertahanan berlapis: timeout sebagai batas kesabaran, dan retry sebagai kesempatan kedua yang dilakukan dengan sopan.

Timeout, Batas Kesabaran Klien #

Parameter timeout pada requests dan httpx sebenarnya mencakup dua fase yang berbeda. Fase connect berlaku sejak koneksi dimulai sampai server menyapa balik; fase read berlaku sejak request terkirim sampai satu byte response diterima. Keduanya bahkan bisa disetel terpisah sebagai tuple:

python
requests.get(url, timeout=(3, 10))  # 3 detik untuk connect, 10 detik untuk read

Merasakan ReadTimeout Secara Nyata #

Teori timeout paling mudah dipahami dengan melihatnya terjadi. Server berikut sengaja tidur dua detik sebelum menjawab rute /lambat, dan gagal dua kali pertama pada rute /flaky — simpan sebagai server_lambat.py dan jalankan:

python
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PANGGILAN = 0


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        global PANGGILAN
        if self.path == "/lambat":
            time.sleep(2)
            body = b"akhirnya selesai"
            self.send_response(200)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        elif self.path == "/flaky":
            PANGGILAN += 1
            if PANGGILAN < 3:
                self.send_response(500)
                self.send_header("Content-Length", "0")
                self.end_headers()
                return
            body = b"oke pada percobaan ketiga"
            self.send_response(200)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        else:
            self.send_response(404)
            self.send_header("Content-Length", "0")
            self.end_headers()

    def log_message(self, format, *args):
        pass


ThreadingHTTPServer(("127.0.0.1", 8123), Handler).serve_forever()

Sekarang klien dengan kesabaran setengah detik bertemu server yang butuh dua detik:

python
import requests

try:
    requests.get("http://127.0.0.1:8123/lambat", timeout=0.5)
except requests.exceptions.ReadTimeout as err:
    print("Tipe :", type(err).__name__)
    print("Info :", err)

Output yang Dihasilkan:

text
Tipe : ReadTimeout
Info : HTTPConnectionPool(host='127.0.0.1', port=8123): Read timed out. (read timeout=0.5)

Mari kita telaah baris-baris kode di atas:

  1. Baris 4 (timeout=0.5): Koneksi berhasil dibuat (fase connect cepat), tetapi fase read melebihi setengah detik — server memang sedang tidur dua detik.
  2. Baris 5 (except requests.exceptions.ReadTimeout): Exception spesifik ini hanya menyertai kegagalan fase read, sehingga kita bisa membedakannya dari server yang tidak bisa dihubungi sama sekali.
  3. Baris 7 (print("Info :", err)): Pesan error dari library menyertakan host, port, dan nilai timeout — informasi yang memudahkan diagnosis saat kejadian terulang.

Retry dengan Exponential Backoff #

Server yang gagal sekali sering berhasil dicoba lagi beberapa saat kemudian — tetapi mengulang permintaan secepat kilat justru menumpuk beban. Solusinya adalah exponential backoff: jeda yang melipat dua setiap kegagalan. Function berikut merangkai pola tersebut:

python
import time

import requests


def get_dengan_retry(url, percobaan_maks=4):
    for percobaan in range(1, percobaan_maks + 1):
        try:
            respons = requests.get(url, timeout=3)
            respons.raise_for_status()
            return respons
        except requests.exceptions.RequestException:
            print(f"Percobaan {percobaan} gagal, coba lagi setelah jeda...")
            if percobaan == percobaan_maks:
                raise
            time.sleep(0.2 * 2 ** (percobaan - 1))


respons = get_dengan_retry("http://127.0.0.1:8123/flaky")
print("Isi respons:", respons.text)

Output yang Dihasilkan:

text
Percobaan 1 gagal, coba lagi setelah jeda...
Percobaan 2 gagal, coba lagi setelah jeda...
Isi respons: oke pada percobaan ketiga

Mari kita bedah function get_dengan_retry():

  1. Baris 6 (for percobaan in range(1, percobaan_maks + 1)): Loop berjalan maksimal percobaan_maks kali; nomor percobaan dimulai dari 1 agar jeda terhitung alami.
  2. Baris 9-10 (requests.get → raise_for_status()): Kegagalan jaringan maupun status 4xx/5xx keduanya ditangkap di satu tempat, karena keduanya layak dicoba ulang oleh pola ini.
  3. Baris 12 (except requests.exceptions.RequestException): Class dasar ini menangkap semua turunan exception requests, termasuk ReadTimeout dan ConnectionError.
  4. Baris 14-15 (if percobaan == percobaan_maks: raise): Percobaan terakhir yang gagal tidak ditelan — exception aslinya diteruskan ke pemanggil, karena melaporkan kegagalan jujur lebih baik daripada mengembalikan None secara diam-diam.
  5. Baris 16 (time.sleep(0.2 * 2 ** (percobaan - 1))): Rumus backoff — jeda 0,2 detik setelah gagal pertama, lalu berlipat dua.

Alur lengkap strategi ini:

Pada nilai contoh kita, tabel jadanya sebagai berikut:

Percobaan gagal ke-Jeda sebelum mencoba lagi
10,2 detik
20,4 detik
30,8 detik

Idempotency, Permintaan yang Aman Diulang #

Sebelum menyalakan retry otomatis, tanyakan satu hal: apakah permintaan ini aman diulang? Method GET, PUT, dan DELETE bersifat idempotent — diulang lima kali, kondisi akhir server tetap sama, sehingga retry tidak menimbulkan efek samping. Method POST berbeda: lima kali kirim POST pembuatan pesanan berarti lima pesanan. Untuk POST, ulangi hanya bila yakin permintaan belum sampai (misalnya koneksi gagal sebelum request terkirim), atau gunakan idempotency key yang disediakan API pembayaran modern.