D
P
0

WordPress & PHP

Simpan Post Malah Kena 502? `media_handle_sideload()` Sinkron Menabrak Timeout Edge Proxy

2 Juli 2026·5 menit baca
Simpan Post Malah Kena 502? `media_handle_sideload()` Sinkron Menabrak Timeout Edge Proxy

Di sebuah workflow editorial berbasis WordPress yang saya kerjakan, post yang masuk sering membawa referensi gambar eksternal: URL yang menunjuk ke file di server lain. Supaya semua aset tinggal rapi di satu tempat, saya menulis kode yang berjalan saat post disimpan: ambil setiap gambar remote, lalu impor ke media library lewat media_handle_sideload(). Fungsi itu memang enak dipakai. Satu panggilan, dan dia mengurus semuanya: download file, salin ke folder uploads, dan generate semua ukuran thumbnail yang terdaftar oleh theme dan plugin.

Satu detail yang belakangan terasa fatal: semua itu berjalan sinkron, di dalam request save itu sendiri. Selama post cuma punya satu-dua gambar, tidak ada yang terasa. Begitu mulai masuk post dengan beberapa gambar sekaligus, laporan berdatangan: user menekan simpan, spinner berputar lama, lalu yang muncul halaman 502.

502 saat save itu jenis error yang paling menakutkan buat user, bukan karena sisi teknisnya, tapi karena apa yang seolah dia artikan: post-nya kelihatan gagal disimpan. Ada yang panik, ada yang menekan simpan berulang-ulang, ada yang menulis ulang dari nol. Padahal setiap kali saya cek ke database, post-nya hampir selalu sudah tersimpan dengan benar. Yang mati bukan proses simpannya, melainkan responsnya.

Kira-kira begini bentuk kode yang jadi biang keroknya:

add_action('save_post', function ($post_id) {
    $remote_urls = extract_remote_image_urls($post_id);
 
    foreach ($remote_urls as $url) {
        // Download + copy into uploads + generate
        // every registered thumbnail size. Synchronously.
        $tmp = download_url($url);
        media_handle_sideload(
            ['name' => basename($url), 'tmp_name' => $tmp],
            $post_id
        );
    }
});

Kenapa ini terjadi

Hitungannya sederhana dan brutal. Men-sideload satu gambar berarti: satu HTTP request keluar untuk download, satu operasi tulis ke folder uploads, lalu resize untuk setiap ukuran thumbnail yang terdaftar. Dan resize gambar itu kerja CPU yang tidak murah. Satu gambar besar bisa makan beberapa detik sendirian. Kalikan dengan N gambar per post dan M ukuran thumbnail per gambar, dan satu request save bisa berjalan puluhan detik sampai hitungan menit.

Sementara itu, di depan server ada edge proxy dengan timeout keras. Proxy tidak peduli PHP di belakang masih sibuk resize; begitu batasnya lewat, koneksi diputus dan user dikirimi 502. PHP-nya sendiri sering tetap lanjut sampai selesai di belakang layar, dan itulah kenapa post tetap tersimpan meski user melihat error. Akar masalahnya bukan bug di satu fungsi, melainkan keputusan arsitektur: kerja media massal yang butuh detik-sampai-menit dijejalkan ke satu request yang ditunggu manusia, di belakang proxy yang kesabarannya ada batasnya.

Perbaikannya

Kuncinya satu kata: pisahkan. Request save harus kembali secepat mungkin, dan kerja berat pindah ke belakang layar. Saya membaginya jadi tiga langkah.

Pertama, saat save, lakukan yang murah saja. Simpan daftar URL remote ke post meta dan biarkan konten me-render gambar-gambar itu sebagai hotlink untuk sementara. Post tampil normal, gambarnya tetap kelihatan, dan respons save kembali instan:

add_action('save_post', function ($post_id) {
    $remote_urls = extract_remote_image_urls($post_id);
 
    if (empty($remote_urls)) {
        return;
    }
 
    // Cheap: remember the URLs, keep hotlinking them for now.
    update_post_meta($post_id, '_pending_image_imports', $remote_urls);
 
    // Heavy work happens later, outside this request.
    wp_schedule_single_event(time() + 10, 'import_post_images', [$post_id]);
});

Kedua, impor yang sesungguhnya berjalan sebagai job async lewat wp_schedule_single_event(). Handler-nya men-sideload gambar satu per satu, menukar URL di konten dan meta begitu masing-masing selesai, dan kalau mulai mendekati batas waktu PHP, dia menyimpan progres lalu menjadwalkan dirinya sendiri lagi. Ketiga, job itu harus idempoten: setiap attachment hasil impor ditandai meta berisi URL sumbernya, dan sebelum men-sideload, handler mengecek dulu apakah URL itu sudah pernah diimpor. Kalau sudah, lewati. Dengan begitu job boleh mati di tengah dan dijalankan ulang kapan pun tanpa menduplikasi gambar:

add_action('import_post_images', function ($post_id) {
    $started = time();
    $pending = get_post_meta($post_id, '_pending_image_imports', true);
 
    if (empty($pending)) {
        return;
    }
 
    foreach ($pending as $index => $url) {
        // Idempotent: skip URLs already imported, keyed by a
        // source-url meta stored on the attachment.
        if (find_attachment_by_source_url($url)) {
            unset($pending[$index]);
            continue;
        }
 
        // Close to the PHP time limit? Save progress and re-schedule.
        if (time() - $started > 20) {
            update_post_meta($post_id, '_pending_image_imports', array_values($pending));
            wp_schedule_single_event(time() + 10, 'import_post_images', [$post_id]);
            return;
        }
 
        $tmp = download_url($url);
        $attachment_id = media_handle_sideload(
            ['name' => basename($url), 'tmp_name' => $tmp],
            $post_id
        );
 
        if (is_wp_error($attachment_id)) {
            continue; // Stays pending, a later run retries it.
        }
 
        update_post_meta($attachment_id, '_source_url', $url);
        replace_image_url_in_post($post_id, $url, wp_get_attachment_url($attachment_id));
 
        unset($pending[$index]);
        update_post_meta($post_id, '_pending_image_imports', array_values($pending));
    }
 
    if (empty($pending)) {
        delete_post_meta($post_id, '_pending_image_imports');
    } else {
        // A download failed: keep the rest pending and retry later.
        update_post_meta($post_id, '_pending_image_imports', array_values($pending));
        wp_schedule_single_event(time() + 60, 'import_post_images', [$post_id]);
    }
});

Perhatikan bahwa daftar pending di-update setiap satu gambar selesai, bukan sekali di akhir. Itu yang membuat job ini resumable: kalau prosesnya mati di gambar ketiga, run berikutnya mulai dari gambar keempat, bukan dari nol. Di versi produksinya saya juga menambahkan batas retry per URL, supaya satu link mati tidak membuat job berputar selamanya. Hasil akhirnya: save kembali dalam hitungan ratusan milidetik, gambar tampil sejak awal sebagai hotlink, dan beberapa menit kemudian semuanya sudah tergantikan versi lokal dari media library, tanpa ada manusia yang menunggu apa pun.

Pelajaran

Checklist yang saya bawa pulang dari insiden ini:

  • Jangan pernah melakukan kerja media massal di dalam request yang ditunggu user. Download dan generate thumbnail itu kerja detik-sampai-menit, bukan milidetik.
  • Simpan cepat, proses belakangan. Respons ke user hanya perlu mengonfirmasi datanya aman, bukan menunggu semua turunannya selesai dibuat.
  • Hotlink-lalu-ganti itu keadaan antara yang bisa diterima. Gambar tampil dari server orang selama dua menit jauh lebih baik daripada save yang kena 502.
  • Job async harus resumable dan idempoten, karena cepat atau lambat dia pasti terinterupsi. Rancang dari awal supaya dijalankan ulang itu selalu aman.

Dan kalau ada user melapor kena 502 padahal datanya ternyata tersimpan, sekarang saya tahu harus curiga ke mana: ada kerja berat yang numpang di request yang salah.