Saya sudah lama menganggap AbortController sebagai jaring pengaman. Pasang signal, pasang setTimeout yang memanggil abort(), selesai. Request yang menggantung akan diputus, promise-nya reject, kode yang menunggu dapat error dan bisa bereaksi. Itu kontraknya, setidaknya di kepala saya.
Sampai suatu sore sebuah aksi di panel admin tidak pernah selesai, dan waktu saya buka console untuk mencari errornya, di sana tidak ada apa-apa. Bukan error yang salah alamat. Benar-benar kosong.
Gejalanya: promise yang menggantung tanpa suara
Wrapper fetch internal kami mengirim request ke satu endpoint yang memang lambat. Yang terjadi:
- Tidak ada error di console. Tidak ada
AbortError. Tidak ada apa pun. - Blok
catchsaya tidak pernah jalan. Blokfinallysaya juga tidak. - Timer-nya jalan, dan
abort()benar-benar terpanggil. Itu saya buktikan dengan log, bukan asumsi. - Di sisi server, pekerjaannya justru selesai dengan sukses.
Jadi ini bukan request yang gagal. Ini request yang berhasil, tapi hasilnya tidak pernah sampai ke kode yang menunggunya. Dari sudut pandang JavaScript, promise itu berhenti di state pending lalu tinggal di sana selamanya.
Kenapa endpoint-nya lambat sendiri punya jawaban yang membosankan: saya sedang menembak lingkungan sandbox milik layanan pihak ketiga, dan handler-nya melakukan dua panggilan berurutan yang di produksi masing-masing beres dalam hitungan detik. Di sandbox totalnya kira-kira semenit. Lambatnya artefak lingkungan uji, bukan bug.
Tapi semenit itu yang membongkar bug sungguhan: wrapper fetch saya tidak punya batas waktu yang benar-benar bekerja.
Yang saya kira sudah aman
Wrapper-nya kira-kira seperti ini. Saya samarkan namanya, tapi bentuknya persis:
async function apiFetch(url, options = {}) {
const controller = new AbortController();
// DEFAULT_TIMEOUT: batas bawaan wrapper, angkanya tidak penting di sini
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}Di atas kertas ini benar. Timer jalan, abort() dipanggil, fetch reject dengan AbortError, finally bersih-bersih. Saya sudah menulis pola ini puluhan kali.
Sore itu polanya tidak jalan. Timer jalan, abort() terpanggil, dan await di baris itu tetap tidak pernah settle.
Akar masalahnya: ada dua fase di dalam satu fetch
Bagian yang paling lama saya terima: AbortController tidak berkuasa atas seluruh siklus hidup sebuah request, karena satu fetch sebenarnya dua langkah yang terpisah.
- Fase header. Browser membuka koneksi, mengirim request, lalu menunggu baris status dan header balasan. Begitu header sampai, promise dari
fetch()resolve. Sinyal abort memotong fase ini dengan rapi. - Fase body. Yang kamu pegang setelah
await fetch()bukan isi respons, melainkan janji atas isi respons. Baru dires.json()ataures.text()browser mulai menarik byte-nya dari koneksi yang masih terbuka.
Pemisahan ini yang selama ini saya lewatkan. await fetch(...) bisa sudah selesai dengan sukses sementara request-nya, dalam arti yang sebenarnya, belum selesai sama sekali.
Situs yang saya kerjakan itu dilayani lewat LiteSpeed dengan kompresi brotli aktif. Yang saya amati: respons dari endpoint lambat itu tidak mengalir sedikit demi sedikit. Proxy menahannya sampai utuh, mengompresnya, baru melepasnya sebagai satu paket. Selama buffer itu masih dipegang, dari sisi browser tidak ada apa pun yang bergerak. Tidak ada byte yang masuk, tidak ada error, tidak ada koneksi yang tertutup. Cuma diam.
Kenapa abort tidak menolong di fase kedua
Dua hal berjalan bareng di sini, dan keduanya bermuara ke hasil yang sama.
Yang pertama soal siapa yang saya tunggu. Begitu header mendarat, promise dari fetch() sudah settle dengan sukses. Tidak ada lagi yang bisa ditolak di baris itu. Penantian saya sudah pindah ke res.json(), dan itu objek lain dengan siklus hidupnya sendiri.
Yang kedua soal ada apa di ujung koneksi. Spesifikasi bilang membatalkan di tengah pembacaan body seharusnya membuat stream-nya error. Di kasus saya itu tidak terjadi. Body yang dikirim mengalir punya potongan yang bisa robek kalau koneksi diputus, dan robekan itulah yang biasanya muncul sebagai error. Body yang masih diam di buffer proxy tidak punya potongan apa pun di sisi browser. Tidak ada yang bisa robek, karena belum ada satu byte pun yang sampai. Yang tersisa cuma koneksi yang parkir menunggu proxy selesai, dan itu bukan sesuatu yang bisa diubah sinyal saya jadi rejection.
Hasilnya: tidak ada yang menolak, tidak ada yang me-resolve, promise-nya tetap pending.
Ini pelajaran pertamanya, dan saya menuliskannya besar-besar buat diri saya sendiri: AbortController adalah permintaan, bukan jaminan. Dia meminta request dibatalkan. Yang menjawab permintaan itu lapisan jaringan, dan lapisan jaringan tidak selalu ada di bawah kendali kodemu. Kalau ada proxy di depan yang sedang duduk di atas body terkompresi, permintaanmu bisa saja tidak pernah dijawab.
Kalau kamu butuh batas waktu yang pasti berakhir, batas itu harus hidup di sisi JavaScript, terpisah dari request itu sendiri.
Perbaikannya: batasnya harus membungkus kedua fase
Polanya sendiri tidak eksotis. Adu request dengan timer JavaScript murni, supaya selalu ada yang menang, apa pun keputusan lapisan jaringan. Yang baru buat saya di sini bukan polanya, tapi letak balapannya. Percobaan pertama saya masih bocor:
// Masih bocor: balapannya berhenti di fase header.
const res = await Promise.race([
fetch(url, { ...options, signal: controller.signal }),
timeoutReject(limit),
]);
return res.json(); // gantungnya justru di sini, di luar balapanBalapan ini menang telak melawan koneksi yang tidak pernah nyambung, dan sama sekali tidak berguna melawan bug yang saya hadapi. Gantungnya ada di fase kedua, sementara res.json() berdiri di luar balapan dan bebas menunggu selamanya.
Yang benar: perlakukan header dan body sebagai satu unit kerja, lalu adu unit itu.
function timeoutReject(ms) {
return new Promise((_, reject) => {
setTimeout(() => {
const err = new Error("Request timed out");
// Flag ini bikin pemanggil bisa membedakan "jaringan lambat"
// dari "server menolak", dua hal yang butuh reaksi berbeda.
err.timeout = true;
reject(err);
}, ms);
});
}
async function apiFetch(url, options = {}) {
const controller = new AbortController();
const limit = options.timeout ?? DEFAULT_TIMEOUT;
const timer = setTimeout(() => controller.abort(), limit);
// Satu unit kerja: dari header sampai body terbaca habis.
const request = (async () => {
const res = await fetch(url, { ...options, signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})();
try {
return await Promise.race([request, timeoutReject(limit)]);
} finally {
clearTimeout(timer);
}
}AbortController-nya tetap saya pasang. Kalau dia memang bisa melepas koneksi, dia melepas, dan itu hemat sumber daya nyata. Dia cuma tidak lagi jadi satu-satunya yang saya andalkan untuk mengakhiri penantian.
Sejak itu tidak ada lagi promise yang menggantung diam-diam. Selalu ada jawaban, entah dari server atau dari timer saya sendiri. Apa yang sebaiknya dilakukan sesudah timeout, terutama untuk aksi yang menyentuh uang, itu cerita terpisah yang sudah saya tulis di tempat lain.
Yang saya bawa pulang
- Satu
fetchitu dua fase: header dulu, body menyusul.await fetch()yang sukses tidak berarti responsnya sudah ada di tanganmu. AbortControlleritu permintaan pembatalan, bukan jaminan pembatalan. Kalau ada proxy yang masih menahan body terkompresi di depan, belum ada satu byte pun yang sampai ke browser, dan tidak ada apa pun yang bisa diubah sinyalmu jadi rejection.- Kalau kamu memasang batas waktu, pastikan batas itu mencakup pembacaan body. Batas yang berhenti di
fetch()menutup lubang yang salah. - Bug yang paling sulit dikejar bukan yang melempar error, tapi yang tidak melempar apa-apa. Promise pending tidak muncul di console, tidak muncul di monitoring, dan tidak akan pernah memanggil
finallymilikmu.