Bahaya Mencoba Ulang Seketika

Panggilan API gagal. Logika retry Anda langsung aktif — tentu saja aktif, Anda menulisnya untuk itu. Lima percobaan, berurutan, secepat event loop mengizinkan. Yang pertama gagal dalam 40ms. Di 200ms kelima percobaan sudah ditembakkan. Itulah bugnya. Bukan karena Anda mencoba ulang — melainkan karena Anda mencoba ulang segera, hal terakhir yang mampu ditanggung server yang sedang gagal.

Loop retry yang terasa jelas benar. Ini versi yang hampir semua orang tulis pertama kali:

async function fetchWithRetry ( url , retries = 5 ) { for ( let i = 0 ; i < retries ; i ++ ) { try { const res = await fetch ( url ); if ( res . ok ) return res ; } catch { // network error — fall through and try again } } throw new Error ( `Failed after ${ retries } attempts: ${ url } ` ); }

Terbaca seperti rekayasa yang baik. Request yang flaky dapat kesempatan kedua, lalu ketiga, sampai lima, sebelum Anda menyerah dan menampilkan error. Uji terhadap endpoint sehat yang sengaja Anda gagalkan sekali, hasilnya sempurna — percobaan kedua berhasil, tidak ada yang menyadari apa pun.

Mekanisme Terjadinya Retry Storm

Ujiannya yang tidak pernah berjalan adalah uji di mana server-nya yang bermasalah. Sebuah deploy keluar dengan konfigurasi buruk, dependensi timeout, lonjakan trafik mendorong waktu respons melewati fetch timeout Anda. Kini setiap klien yang memanggil endpoint itu menerima kegagalan — dan loop retry setiap klien menembakkan lima percobaan dalam beberapa ratus milidetik yang sama. Server yang sudah kewalahan di bawah beban normal tiba-tiba menerima 5x volume request, instan, dari setiap pemanggil yang sudah berjalan. Itu bukan pemulihan. Itu lonjakan kedua, yang diciptakan sendiri, mendarat tepat di atas lonjakan pertama.

Punya nama — retry storm — dan ini mode kegagalan yang terdokumentasi baik dalam sistem terdistribusi, bukan sesuatu yang hipotetis. Perbaikannya bukan "retry lebih sedikit." Melainkan "retry dengan cara berbeda."

Tiga Fondasi Exponential Backoff

Apa yang sebenarnya hilang dari loop. Tiga hal, dan tidak satunya eksotis:

  • Jeda yang membesar antar percobaan, agar server yang kewalahan mendapat ruang bernapas alih-alih menerima pukulan kedua seketika.
  • Kerandoman dalam jeda itu, agar ribuan klien yang semuanya gagal pada momen yang sama juga tidak semuanya mencoba ulang pada momen yang sama.
  • Alasan untuk tidak mencoba ulang segalanya — 404 akan gagal dengan cara yang sama pada percobaan kelima seperti pada percobaan pertama. Mencoba ulang lima kali tidak membantu; itu hanya membuat Anda tampak lima kali lebih lambat melaporkan error yang sebenarnya.
  • Gabungkan ketiganya dan Anda dapat exponential backoff dengan jitter: setiap retry menunggu kira-kira dua kali lebih lama dari sebelumnya, dibatasi maksimum tertentu, dan waktu tunggu persisnya diacak di dalam jendela itu alih-alih tetap.
  • async function fetchWithRetry ( url , { retries = 4 , baseDelay = 300 , maxDelay = 8000 } = {}) { for ( let attempt = 0 ; ; attempt ++ ) { let res ; try { res = await fetch ( url , { signal : AbortSignal . timeout ( 5000 ) }); } catch ( err ) { if ( attempt === retries ) throw err ; await backoff ( attempt , baseDelay , maxDelay ); continue ; } if ( res . ok ) return res ; if ( res . status < 500 && res . status !== 429 ) return res ; // a client error won't fix itself if ( attempt === retries ) return res ; await backoff ( attempt , baseDelay , maxDelay , res . headers . get ( " Retry-After " )); } }
  • function backoff ( attempt , base , cap , retryAfter ) { if ( retryAfter ) { const seconds = Number ( retryAfter ); const ms = Number . isFinite ( seconds ) ? seconds * 1000 : new Date ( retryAfter ). getTime () - Date . now (); return sleep ( Math . max ( 0 , ms )); } const windowMs = Math . min ( cap , base * 2 ** attempt ); return sleep ( Math . random () * windowMs ); // "full jitter" — pick anywhere in the window, not the edge }
  • const sleep = ( ms ) => new Promise (( r ) => setTimeout ( r , ms ));
  • Telusuri apa yang berubah, karena setiap baris menjawab satu dari tiga celah di atas:
  • AbortSignal.timeout(5000) membatasi berapa lama satu percobaan boleh menggantung sebelum dihitung gagal dan masuk ke backoff — tanpa ini, satu percobaan lambat bisa menghabiskan seluruh anggaran retry Anda hanya dengan duduk dalam status pending di fetch.
  • res.status < 500 && res.status !== 429 menghentikan loop membuang percobaan pada 400 atau 404 — itu server memberi tahu Anda bahwa request-nya sendiri yang salah, dan tidak ada jumlah penantian yang mengubahnya. 429 (rate limited) dan 5xx (masalah server) yang layak dicoba ulang.
  • Retry-After adalah header response HTTP nyata — server mengirimnya pada 429 dan 503 untuk memberi tahu klien berapa lama menunggu sebelum mencoba lagi (muncul juga di beberapa redirect 3xx, dengan alasan berbeda) — dalam bentuk detik atau HTTP date. Saat header itu ada, gunakan alih-alih menebak; server sedang memberi tahu estimasi pemulihannya sendiri.
  • Math.random() * windowMs adalah "full jitter": alih-alih setiap klien menunggu tepat 300ms, lalu tepat 600ms, lalu tepat 1200ms — yang hanya menyinkronkan ulang retry semua orang menjadi badai-badai baru yang lebih kecil — setiap klien memilih titik acak di dalam jendela itu. Sebarkan jumlah total retry yang sama ke dalam waktu, bukan serempak, dan server melihat aliran tetes, bukan gelombang.
  • Percobaan yang tidak boleh di-retry otomatis. Satu hal lagi yang loop naif lewatkan: ia mencoba ulang semuanya, termasuk request yang tidak aman diulang. GET bersifat idempoten — menjalankannya lima kali punya efek sama dengan menjalankannya sekali. POST yang membuat order tidak. Jika request itu benar-benar sampai ke server dan ordernya terbuat, tetapi response hilang di jalan pulang, retry buta membuat order kedua.
  • Perbaikan jujurnya lebih kecil cakupannya dari kedengarannya: bungkus hanya request yang benar-benar idempoten (GET, HEAD, PUT yang sepenuhnya mengganti sumber daya) dalam retry otomatis secara default. Untuk POST yang memang perlu di-retry — konfirmasi pembayaran, misalnya — perbaikan sebenarnya adalah idempotency key: ID unik yang Anda generate sekali dan kirimkan pada setiap percobaan, agar server bisa mengenali "saya sudah melakukan ini" dan mengembalikan hasil asli alih-alih melakukannya dua kali. Itu kontrak sisi server, bukan sesuatu yang bisa dipalsukan fetchWithRetry sendirian — dan justru karena itu layak disebut, alih-alih diam-diam meng-retry POST dan berharap yang terbaik.
  • Apa yang benar-benar Anda dapatkan. Tidak ada ini yang membuat request berhasil jika memang akan gagal — loop backoff yang mencoba ulang terhadap server yang down selama satu jam tetap akan, pada akhirnya, menyerah. Yang diubah adalah bentuk beban yang Anda kembalikan ke server yang sedang pulih: tersebar alih-alih tersinkronisasi, terbatas alih-alih tak terbatas, dan diarahkan hanya ke kegagalan yang punya peluang berhasil pada percobaan kedua.
  • Perbaikan tiga baris itu memang tiga baris — jeda yang membesar, jendela acak di dalamnya, dan pengecekan status code mana yang layak mendapat percobaan kedua. Sampai ke sini berarti mengakui versi pertama bukan "terlalu sedikit retry" atau "terlalu banyak retry." Ia retry pada tempo yang justru paling salah.
  • Apa jumlah retry Anda saat ini, dan pernahkah Anda benar-benar mengamatis efeknya terhadap server yang sudah kewalahan?
  • Sumber: dev.to/parsajiravand, "Stop Retrying Immediately. Exponential Backoff Fixes It." (https://dev.to/parsajiravand/stop-retrying-immediately-exponential-backoff-fixes-it-1hce). Panduan, playground, dan kuis terkait tersedia di bestpractic.org.