Hari terakhir Juni tahun ini, setiap halaman artikel yang punya body di sebuah situs yang saya pegang berhenti menampilkan isinya dan menggantinya dengan UI not-found. Homepage aman. Semua halaman listing aman. Yang jatuh cuma halaman detail per slug, dan jatuhnya serempak, tanpa satu pun yang selamat.
Situsnya berdiri di atas CMS headless, Next.js, dan ISR. Lima rute dinamis per slug di situ semuanya mengembalikan HTTP 200 untuk slug yang tidak ada, sambil merender UI not-found. Jadi yang muncul di browser bukan 404 sungguhan, melainkan halaman "tidak ditemukan" yang secara protokol bilang semuanya baik-baik saja. Rutenya force-dynamic dan dirender lewat streaming, dan status 200 sudah ter-commit sebelum notFound() sempat bicara. Itu cerita tersendiri yang pernah saya tulis terpisah, dan sifatnya jadi relevan lagi di bawah.
Sekitar satu jam saya habiskan untuk mencurigai deploy terakhir. Itu satu jam yang terbuang.
Kenapa listing tetap terlihat sehat
Bagian yang membuat saya salah arah justru bagian yang tidak rusak. Halaman listing dan halaman statis di situs itu dilayani sebagai static dan ISR, artinya datanya sudah ikut ter-cache sejak waktu build. Mereka tidak menyentuh CMS saat ada request masuk. Selama cache itu masih ada, mereka terus menampilkan judul, ringkasan, dan gambar dengan rapi walaupun jalur fetch runtime sudah putus total. Halaman-halaman itu bukan bukti bahwa datanya aman, mereka cuma rekaman dari kondisi terakhir yang sehat.
Yang berbicara jujur cuma halaman per slug yang force-dynamic, karena cuma mereka yang benar-benar melakukan fetch langsung setiap kali dibuka. Jadi pola kegagalannya bukan kebetulan. Satu-satunya kelompok halaman yang gagal adalah satu-satunya kelompok halaman yang saat itu masih bertanya ke CMS secara langsung.
Null yang datang terlalu cepat
Fetch ke CMS di situs itu punya timeout lima detik. Kalau CMS-nya lambat atau jaringannya bermasalah, saya berharap harus menunggu sampai batas itu sebelum dapat kegagalan. Yang terjadi sebaliknya: null-nya balik dalam satu sampai dua detik. Kasus paling lambatnya pun masih tiga detik lebih cepat dari batas timeout, karena lima dikurangi dua sama dengan tiga.
Kecepatan itu sebenarnya petunjuk, dan saya salah membacanya. Error kuota memang balik cepat. Servernya tidak sedang berusaha melayani query saya lalu kehabisan napas, dia menolak di depan pintu.
Masalahnya, dari sisi pemanggil, penolakan cepat itu tidak terlihat beda dari jawaban "dokumennya memang tidak ada". Fungsi fetch ke CMS mengembalikan null untuk dua kondisi yang sama sekali berbeda, yaitu dokumen yang betul-betul tidak ada dan kegagalan fetch yang sifatnya sementara.
const doc = await cmsFetch(query, { slug })
// null di sini berarti dua hal sekaligus:
// 1. dokumennya memang tidak ada
// 2. fetch-nya gagal sementara, termasuk karena kuota
if (!doc) notFound()Satu nilai, dua arti, dan halaman memilih arti yang salah.
Bucket anonim yang masih hidup
Pemeriksaan yang akhirnya memutar arah diagnosis saya sederhana. Saya ambil dokumen yang sama lewat endpoint CDN anonim, tanpa token aplikasi.
curl -s "https://cdn.contoh-cms.example/api/query?query=..." | head -c 300Dokumennya ada, lengkap, dan datang cepat. Datanya sehat, CDN-nya sehat, query-nya benar. Yang tidak sehat cuma satu jalur, yaitu fetch bertoken milik aplikasi ke endpoint API. Bucket bertoken untuk API terpisah dari bucket anonim di CDN. Satu bisa kena blokir kuota sementara yang lain terus melayani seolah tidak terjadi apa-apa.
Itu yang membuat insiden semacam ini susah dipercaya waktu sedang terjadi. Saya bisa menarik data mentahnya dengan satu perintah curl, sementara aplikasi yang meminta dokumen yang sama persis cuma dapat null.
Deploy cuma membuka, bukan menyebabkan
Selama satu jam pertama, tersangka utama saya adalah deploy terakhir. Alasannya masuk akal di permukaan, gejalanya muncul berdekatan dengan deploy dan yang berubah cuma kode.
Yang tidak saya perhitungkan, deploy melakukan satu hal lain yang tidak ada hubungannya dengan kode, yaitu membersihkan cache yang sedang hangat. Selama cache itu masih hangat, halaman bisa terus terlihat baik di atas jalur fetch yang sebenarnya sudah rusak sejak sebelumnya. Begitu cache dibersihkan, kerusakan yang sudah ada duluan itu jadi kelihatan. Deploy tidak menyebabkan apa pun, dia cuma membuka tutupnya, dan korelasi dengan deploy bukan sebab akibat.
Konsekuensi praktisnya, jangan refleks rollback. Rollback justru membersihkan cache sekali lagi, dan tidak ada satu bagian pun dari rollback yang bisa memperbaiki kuota yang habis.
Yang sebenarnya terjadi
Akar masalahnya kuota CMS yang habis di akhir bulan. Saya yang memastikannya di hari yang sama, dan satu layanan data pihak ketiga lain di situs itu kena hal serupa pada hari yang sama juga. Kuotanya reset tanggal 1.
Jadi tidak ada yang perlu diperbaiki dalam pengertian biasa. Tanggal 1 Juli kuotanya reset dan artikel-artikel itu merender lagi tanpa saya sentuh sama sekali. Kodenya tidak berubah, deploy-nya tidak diputar balik, tidak ada satu baris pun yang saya perbaiki. Jarak antara insiden dan pulihnya persis satu pergantian tanggal.
Perbaikan yang hampir saya kirim
Bagian yang paling perlu saya ceritakan bukan penyebabnya, tapi perbaikan yang nyaris saya rilis di tengah insiden. Karena halaman-halaman itu merender not-found, saya sempat menulis noindex saat data null di generateMetadata, supaya mesin pencari tidak mengindeks halaman kosong.
export async function generateMetadata({ params }) {
const doc = await cmsFetch(query, { slug: params.slug })
if (!doc) {
return { robots: { index: false } } // dibatalkan sebelum sempat dirilis
}
// ...
}Saya membatalkannya, dan sekarang saya lega. Logika itu memutuskan status indeks saat runtime dari nilai null yang ambigu tadi. Artinya satu kedipan di sisi CMS, atau satu hari kuota habis seperti yang baru saja terjadi, sudah cukup untuk memasang noindex di artikel yang benar-benar hidup. Efeknya juga tidak berhenti di satu request. Dengan TTL dua jam di edge situs ini sendiri, satu respons salah ikut ter-cache lalu menyebar ke permintaan berikutnya selama dua jam berikutnya.
Justru karena itu, halaman not-found yang umum sengaja saya biarkan tetap soft-404 dengan status 200. Kalau dilihat dari kacamata SEO itu terdengar salah, tapi sifatnya melindungi. Kegagalan sementara berakhir sebagai 200, jadi Google akan datang lagi nanti, bukan menerima hard-404 dan mencoret halamannya. Halaman yang membalas hard-404 bisa dicoret, sementara yang membalas 200 akan didatangi lagi.
Untuk URL yang saya tahu pasti sudah mati, statusnya diputuskan di tempat lain, yaitu 410 yang dikirim dari middleware.ts, dengan satu aturan keras: pemicunya wajib daftar statis di dalam kode, tidak boleh hasil lookup ke CMS. Sebuah nilai null tidak pernah boleh diberi wewenang menentukan status.