Pengembangan Web

Request HTTP Outbound di PHP - Tetapkan Batas Waktu Sebelum Menambahkan Retry

Request HTTP Outbound di PHP - Tetapkan Batas Waktu Sebelum Menambahkan Retry

Aplikasi PHP kecil dapat memanggil API remote hanya dengan beberapa baris. Pertanyaan yang lebih sulit muncul ketika sistem lain lambat, tidak dapat dijangkau, atau diam setelah menerima request. Berapa lama aplikasi perlu menunggu? Apakah request perlu dicoba lagi? Jika request pertama mengalami timeout, apakah service remote tidak melakukan apa pun, atau response-nya saja yang gagal kembali?

Tidak ada satu nilai timeout atau jumlah retry yang cocok untuk semua kasus. Pencarian data cuaca, request pembayaran, pengiriman webhook, dan pekerjaan sinkronisasi di background memiliki biaya serta semantik kegagalan yang berbeda. Karena itu, desain yang berguna dimulai dari batas: batasi setiap attempt, bedakan hal yang benar-benar gagal, tentukan apakah pengulangan aman, dan batasi keseluruhan operasi. Artikel ini menerapkan model tersebut pada ekstensi cURL PHP tanpa mengklaim bahwa satu helper singkat dapat menyelesaikan semua masalah distributed system.

Panggilan remote menciptakan batas yang tidak pasti

Outbound request melewati beberapa komponen yang tidak dikendalikan proses PHP. DNS resolution dapat tersendat, koneksi TCP dapat gagal, TLS handshake dapat terputus, server dapat mengembalikan error, atau response dapat menghilang setelah server telanjur mengubah state. Bagi pengguna yang menunggu halaman, hasil-hasil ini mungkin terlihat serupa, tetapi semuanya bukan bukti yang dapat dipertukarkan.

Aturan defensif pertama cukup sederhana: menunggu tanpa batas bukan strategi recovery. Dokumentasi resmi libcurl menyatakan bahwa CURLOPT_TIMEOUT_MS secara default bernilai nol, yang berarti tidak ada transfer timeout. Framework atau SDK mungkin menyediakan default sendiri, tetapi code PHP cURL mentah tidak seharusnya menganggapnya demikian.

Timeout tidak membuktikan bahwa operasi remote gagal. Timeout hanya membuktikan bahwa client berhenti menunggu berdasarkan aturan yang dikonfigurasikan. Perbedaan ini sangat penting untuk request yang mengubah state. Server remote mungkin melakukan commit atas pesanan atau mengirim pesan tepat sebelum client kehilangan koneksi. Retry buta kemudian dapat mengulangi efek yang seharusnya diselamatkan.

Waktu koneksi dan waktu transfer menjawab pertanyaan berbeda

libcurl menyediakan dua batas yang berguna:

  • CURLOPT_CONNECTTIMEOUT_MS membatasi fase koneksi.
  • CURLOPT_TIMEOUT_MS membatasi keseluruhan attempt transfer.

Menurut dokumentasi connect timeout libcurl, fase koneksi mencakup DNS resolution serta protocol handshake dan negotiation yang dibutuhkan untuk membentuk koneksi. Setelah terhubung, batas tersebut tidak lagi mengendalikan berapa lama response boleh berlangsung.

Total timeout tidak ditambahkan setelah connect timeout. Fase koneksi berada di dalam batas total. Jika batas koneksi adalah 1,5 detik dan batas total adalah 5 detik, keseluruhan attempt tidak dapat memakai 6,5 detik: koneksi dapat menggunakan paling lama 1,5 detik, sedangkan seluruh transfer dapat menggunakan paling lama 5 detik.

Angka seperti 1,5 dan 5 detik adalah contoh, bukan default yang direkomendasikan. Export di background mungkin secara wajar membutuhkan beberapa menit; halaman interaktif mungkin mempunyai latency budget yang jauh lebih kecil. Nilai seharusnya berasal dari perilaku endpoint yang terdokumentasi, latency yang diamati, deadline yang dirasakan pengguna, dan biaya menghentikan pekerjaan. Timeout di bawah latency normal menciptakan kegagalan buatan sendiri, sedangkan nilai yang terlalu besar dapat menahan PHP worker lama setelah pemanggil tidak lagi menunggu.

Transport error bukan HTTP response

Dokumentasi curl_exec PHP menjelaskan perbedaan yang mudah terlewat. Dengan CURLOPT_RETURNTRANSFER aktif, kegagalan transfer mengembalikan Boolean false. HTTP response seperti 404 bukan kegagalan transfer cURL; request dapat selesai dengan baik pada tingkat transport tetapi tetap tidak berhasil bagi aplikasi.

Dengan demikian, client setidaknya memiliki tiga kelas hasil:

  1. Kegagalan transport: tidak ada HTTP response yang dapat digunakan. Simpan curl_errno() dan pesan diagnostik yang dibatasi. Sebagai contoh, daftar error libcurl memberikan kode 28 untuk operation timeout dan kode berbeda untuk kegagalan DNS, koneksi, TLS, pengiriman, serta penerimaan.
  2. HTTP response di luar kontrak yang diterima: status seperti 401, 404, 429, atau 503 diterima. Tidak semua status ini membenarkan tindakan yang sama.
  3. HTTP response yang diterima: statusnya diizinkan, tetapi aplikasi mungkin masih perlu memvalidasi content type, ukuran, sintaks JSON, dan field wajib.

Menyatukan ketiganya sebagai “API gagal” menghilangkan informasi yang dibutuhkan untuk recovery. Mencoba ulang credential yang tidak valid tidak akan memperbaikinya. Memperlakukan JSON yang malformed sebagai network timeout menyembunyikan masalah kontrak. Menganggap setiap body 2xx valid dapat memindahkan kegagalan lebih dalam ke aplikasi.

Tentukan dahulu apakah operasi dapat diulang

Retry policy membutuhkan dua persetujuan: kegagalannya harus masuk akal sebagai kondisi sementara dan operasinya harus aman untuk diulang. Hanya memeriksa kondisi pertama dapat mengubah fitur resilience menjadi bug yang menggandakan tindakan.

RFC 9110 mendefinisikan method idempotent sebagai method yang intended effect-nya dari beberapa request identik sama dengan satu request. PUT, DELETE, dan safe methods bersifat idempotent berdasarkan semantik HTTP; POST tidak secara inheren idempotent. RFC tersebut mengizinkan retry otomatis atas request idempotent setelah kegagalan koneksi dan memberi batasan yang lebih ketat untuk retry otomatis atas request non-idempotent.

Nama method tetap bukan sihir. Endpoint GET yang dirancang buruk mungkin memicu tindakan meskipun bertentangan dengan semantik HTTP, sedangkan API POST tertentu mungkin mendukung pengulangan aman melalui idempotency key yang terdokumentasi. Sebelum mencoba ulang mutation, periksa kontrak provider: bagaimana key di-scope, berapa lama key disimpan, dan apakah key yang sama dengan body berbeda akan ditolak? Jika jawabannya tidak tersedia, ambiguous timeout biasanya sebaiknya menjadi state yang terlihat untuk reconciliation, bukan POST kedua secara otomatis.

Kondisi seperti precondition, operation identifier yang stabil, atau idempotency key yang didukung provider dapat membuat beberapa operasi dapat diulang secara bersyarat. Semua itu mengurangi ambiguitas hanya sejauh jaminan sistem penerima. Header acak yang diabaikan server tidak memberikan perlindungan.

Ulangi hanya kelompok sempit hasil sementara

Untuk operasi yang sudah dinilai dapat diulang, kandidat sementara dapat mencakup kegagalan DNS atau koneksi tertentu, timeout, dan response server yang didokumentasikan API sebagai retryable. Allowlist yang tepat merupakan bagian dari integration, bukan milik satu snippet universal.

HTTP menyediakan signal yang berguna tanpa menjanjikan bahwa setiap server akan memakainya. RFC 6585 mendefinisikan 429 Too Many Requests dan mengizinkan response-nya menyertakan Retry-After. RFC 9110 mendefinisikan field tersebut sebagai HTTP date atau jumlah detik. Client yang memahaminya dapat menunda sesuai petunjuk, dengan tetap tunduk pada waktu tunggu maksimum dan deadline keseluruhan milik client. Server dapat menghilangkan field itu, dan server yang mengalami beban berat tidak diwajibkan mengirim 429.

Sebagian besar response 4xx biasa seharusnya memicu pemeriksaan, bukan pengulangan: input malformed, autentikasi gagal, permission kurang, atau resource yang tidak ada biasanya membutuhkan perubahan. Beberapa response 5xx mungkin sementara, tetapi keamanan retry tetap bergantung pada semantik request dan dokumentasi provider. Bahkan daftar konvensional seperti 408, 429, 500, 502, 503, dan 504 hanyalah titik awal klasifikasi, bukan izin untuk memutar ulang setiap request.

Batasi retry dengan backoff, jitter, dan satu deadline

Retry langsung mungkin berhasil ketika koneksi sempat terputus singkat. Melakukan pengulangan langsung beberapa kali juga dapat menambah tekanan pada dependency yang sedang kesulitan. Exponential backoff memberi jarak lebih panjang pada attempt berikutnya. Jitter menambahkan variasi acak agar banyak client tidak kembali pada saat yang sama.

Backoff sendiri bukan batas. Policy juga membutuhkan jumlah attempt maksimum, delay maksimum, dan sebaiknya deadline untuk keseluruhan operasi. Tiga attempt dengan timeout lima detik sudah dapat menghabiskan sekitar lima belas detik dalam transfer, belum termasuk sleep, DNS, dan pemrosesan lokal. Karena itu, timeout per attempt tidak menjawab pertanyaan “berapa lama pekerjaan ini dapat menahan worker?”

Panduan retry Everything curl menunjukkan perbedaan ini secara eksplisit untuk command-line tool: batas maksimum per transfer dan batas total waktu retry merupakan kontrol terpisah. Application code membutuhkan pemisahan konsep yang sama meskipun menerapkan loop-nya sendiri.

Contoh PHP berbatas untuk GET request

Contoh berikut sengaja hanya mendukung GET. Contoh ini memisahkan hasil transport dan HTTP, mencoba ulang kelompok sempit hasil sementara, memakai full jitter, dan berhenti berdasarkan deadline keseluruhan. Nilainya hanya ilustrasi dan perlu dikalibrasi untuk API yang sebenarnya.

<?php
function fetchGet(string $url): array
{
    $deadlineMs = (hrtime(true) / 1_000_000) + 12_000;
    $retryableCurl = [
        CURLE_COULDNT_RESOLVE_HOST,
        CURLE_COULDNT_CONNECT,
        CURLE_OPERATION_TIMEDOUT,
        CURLE_SEND_ERROR,
        CURLE_RECV_ERROR,
    ];
    $retryableHttp = [408, 429, 500, 502, 503, 504];

    for ($attempt = 1; $attempt <= 3; $attempt++) {
        $remainingMs = (int) floor($deadlineMs - (hrtime(true) / 1_000_000));
        if ($remainingMs <= 0) {
            throw new RuntimeException('HTTP operation deadline exceeded');
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => min(1500, $remainingMs),
            CURLOPT_TIMEOUT_MS => min(5000, $remainingMs),
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
        ]);

        $body = curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $duration = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
        curl_close($ch);

        if ($body !== false && $status >= 200 && $status < 300) {
            return ['status' => $status, 'body' => $body, 'seconds' => $duration];
        }

        $mayRetry = $body === false
            ? in_array($errno, $retryableCurl, true)
            : in_array($status, $retryableHttp, true);

        if (!$mayRetry || $attempt === 3) {
            $detail = $body === false ? "cURL {$errno}: {$error}" : "HTTP {$status}";
            throw new RuntimeException("Remote request failed: {$detail}");
        }

        $capMs = min(2000, 250 * (2 ** ($attempt - 1)));
        $sleepMs = random_int(0, $capMs);
        $remainingMs = (int) floor($deadlineMs - (hrtime(true) / 1_000_000));
        if ($sleepMs >= $remainingMs) {
            throw new RuntimeException('No time remains for another attempt');
        }
        usleep($sleepMs * 1000);
    }

    throw new LogicException('Unreachable retry state');
}

Ini adalah contoh pembelajaran, bukan HTTP client siap pakai. Contoh ini tidak melakukan parsing Retry-After, membatasi ukuran response, memvalidasi URL, melakukan decode JSON, menangani cancellation dari upstream request, atau menampilkan metrics. Contoh ini juga menganggap target GET mematuhi safe HTTP semantics. SDK yang dirawat mungkin sudah menerapkan aturan retry dan idempotency khusus provider dengan lebih akurat; menduplikasi retry layer-nya dapat memperbanyak attempt tanpa disengaja.

Untuk POST atau business mutation lain, struktur yang lebih aman berbeda: tetapkan dahulu idempotency protocol operasi, simpan local state secukupnya untuk melakukan reconciliation atas hasil ambigu, lalu aktifkan retry otomatis hanya untuk hasil yang disetujui secara eksplisit.

Buat kegagalan terlihat tanpa membocorkan secret

Retry dapat membuat sistem terasa lebih tenang bagi pengguna sekaligus membuat dependency-nya lebih sulit dipahami. Catat structured context secukupnya untuk menjawab apa yang terjadi: nama dependency, nama operasi, nomor attempt, kelas hasil akhir, HTTP status atau cURL error code, elapsed time, serta correlation atau operation identifier. Jangan masukkan access token, authorization header, payload sensitif lengkap, atau response body tanpa batas ke routine log.

Pengukuran yang berguna mencakup jumlah request, latency, jumlah timeout, retry yang dilakukan, retry yang habis, dan response berdasarkan kelompok status. Semua ini tidak membuktikan alasan dependency gagal, tetapi dapat menunjukkan apakah batas yang dipilih sesuai dengan kenyataan. Jika hampir semua panggilan yang berhasil membutuhkan attempt terakhir, retry loop mungkin sedang menyembunyikan timeout yang buruk atau service yang tidak sehat.

Pengujian perlu mencakup lebih dari happy path 200: koneksi yang tertunda, response yang tertunda, kegagalan DNS, kegagalan TLS, content malformed, 4xx permanen, response sementara dengan dan tanpa Retry-After, deadline yang habis, serta hasil mutation yang ambigu. Fake server atau test double yang dikendalikan lebih aman dan reproducible daripada menunggu production dependency gagal pada saat yang diinginkan.

Kesimpulan

Outbound HTTP yang andal tidak tercipta hanya dengan menambahkan loop di sekitar curl_exec. Keandalan berasal dari urutan keputusan yang lebih sempit: tetapkan batas koneksi dan batas total attempt, bedakan kegagalan transport dari HTTP response, pastikan operasi dapat diulang, ulangi hanya hasil sementara yang disetujui, dan hentikan keseluruhan operasi dalam budget yang diketahui.

Kontrol ini tidak dapat menghapus ketidakpastian setelah mutation mengalami timeout. Kontrol tersebut dapat mencegah ketidakpastian disembunyikan di balik waktu tunggu tanpa batas atau duplicate request otomatis. Karena itu, pertanyaan lanjutan yang paling berguna untuk setiap integration bukan “Berapa jumlah retry yang diperlukan?” melainkan “Hasil mana yang dapat dikenali, diulang, atau direkonsiliasi aplikasi ini dengan aman?”

References