Jawaban singkatnya: gunakan antrean tugas tertunda (delayed task queue) untuk pengiriman webhook dan retry; tambahkan cron hanya bila pemicu periodik perlu memasukkan pekerjaan ke antrean. Untuk SaaS media yang mengirim ringkasan mingguan pelanggan, pemisahan itu membuat pemulihan bisa diamati, bukan mengubah callback yang terlewat menjadi tebakan-tebakan.
Gambaran sebelumnya familiar: handler permintaan memanggil URL pelanggan, menunggu, lalu seseorang menambahkan timer, ekspresi cron, dan tabel database kedua untuk menutupi kegagalan. Gambaran sesudahnya lebih kecil. Permintaan menulis catatan pengiriman (delivery record), pesan antrean membawa ID pengiriman dan waktu percobaan berikutnya, dan sebuah worker memiliki tanggung jawab retry. Satu pekerjaan cron, jika Anda memerlukannya, hanya menyiapkan (seeding) batch mingguan.
Mengapa Pesan Tertunda Cocok untuk Retry Webhook
Setiap pesan tertunda bisa membawa payload dan jeda (delay) miliknya sendiri. Ini kecocokan yang lebih baik untuk webhook dibanding cron, di mana jadwal adalah objek utama dan konteks tugas harus direkonstruksi di tempat lain. Retry untuk pelanggan acct_42 bisa menunggu 30 detik, lalu lima menit, tanpa membuat jadwal baru untuk setiap kegagalan.
Pada ringkasan mingguan nyata, saya akan menulis baris pengiriman terlebih dahulu, melakukan commit URL pelanggan dan content hash, lalu mengantre hanya setelah transaksi itu durable; jika proses mati di antara dua momen tersebut, sebuah poller outbox kecil bisa menerbitkan ID pengiriman yang sama, sementara unique constraint mencegah dua worker mengirim percobaan yang sama. Langkah ekstra itu terdengar berlebihan sampai sebuah deploy mendarat di antara "database commit" dan "HTTP send".
Jaga tetap sederhana. Jaga pesan tetap kecil. Batas delay adalah tujuh hari dan batas payload adalah 256 KB. Simpan isi body ringkasan, preferensi penerima, dan respons provider di database Anda; antrekan hanya ID, jumlah percobaan, dan sedikit metadata. Retention maksimal 30 hari, dan mengakui (acknowledge) pesan menghapusnya, sehingga antrean ini bukan log replay bergaya Kafka. Model itu juga memberi operasi jalur pemulihan yang bersih: periksa baris pengiriman, antrekan ulang ID-nya, dan biarkan worker yang sama melakukan percobaan. Tidak ada timer tersembunyi yang terlibat.
Penjadwalan Retry Webhook SaaS Node.js Setelah Jeda
**Bagaimana SaaS Node.js menjadwalkan retry webhook setelah jeda?**
Mulai dengan idempotency key yang diturunkan dari ID pengiriman dan nomor percobaan. Pengiriman standar bersifat at-least-once, jadi handler harus mentolerir duplikat bahkan ketika setiap operasi antrean berhasil. FIFO deduplication hanya jendela lima menit; itu tidak bisa menggantikan idempotensi di tingkat aplikasi.
Inilah potongan kecil yang pantas berada di endpoint HTTPS publik. Ia mencatat peristiwa sebelum melakukan efek samping, mengembalikan respons non-error untuk duplikat, dan meminta worker antrean untuk retry melalui backoff yang terbatas (bounded). Klien antrean sengaja dibuat abstrak sehingga kontrak yang sama bisa berdiri di atas RabbitMQ, BullMQ, SQS, atau provider lain.
type DigestEvent = {
deliveryId: string;
customerId: string;
attempt: number;
};
const seen = new Set<string>();
function retryDelay(attempt: number): number {
return Math.min(7 * 24 * 60 * 60, 30 * 2 ** attempt);
}
export async function receiveDigest(event: DigestEvent, queue: {
publish: (message: DigestEvent, delaySeconds: number, idempotencyKey: string) => Promise<void>;
}) {
const key = `digest:${event.deliveryId}:${event.attempt}`;
if (seen.has(key)) return { status: 202, duplicate: true };
// Replace this in-memory marker with a unique database constraint.
seen.add(key);
try {
await deliverDigest(event.customerId, event.deliveryId);
return { status: 204 };
} catch (error) {
const nextAttempt = event.attempt + 1;
if (nextAttempt > 8) throw error;
await queue.publish(
{ ...event, attempt: nextAttempt },
retryDelay(event.attempt),
`digest:${event.deliveryId}:${nextAttempt}`,
);
return { status: 202, retryInSeconds: retryDelay(event.attempt) };
}
}
async function publishWithInfrai(message: DigestEvent, delaySeconds: number): Promise<void> {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const publishPath = "/v1/queue/publish";
const idempotencyKey = `digest:${message.deliveryId}:${message.attempt}`;
let waitMs = 500;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}${publishPath}`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ queue: "digest-deliveries", message, delay_seconds: delaySeconds }),
});
if (response.ok) return;
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : waitMs));
waitMs *= 2;
continue;
}
throw new Error(`queue publish failed (${response.status}): ${await response.text()}`);
}
}
async function deliverDigest(customerId: string, deliveryId: string): Promise<void> {
// Load the digest by ID, then POST it to the customer's registered HTTPS URL.
void customerId;
void deliveryId;
}Di produksi, penanda (marker) dan efek pengiriman perlu satu batas transaksional atau catatan idempotensi yang durable. Tangani juga throttling provider: HTTP 429 berarti mundur (back off), hormati Retry-After bila ada, dan hindari loop retry yang ketat. Sebuah dead-letter queue dan alert pada kedalamannya mengubah kegagalan berulang menjadi antrean yang terlihat, bukan backlog senyap.
Pengiriman push punya prasyarat jaringan yang keras. Target langganan harus berupa endpoint HTTPS publik; konsumen yang hanya internal tidak akan menerima push subscription. Jika pelanggan berada di belakang jaringan privat, gunakan relay publik yang mengautentikasi dan meneruskan peristiwa, atau minta pelanggan menarik (pull) dari API yang diautentikasi.
Membandingkan Opsi Antrean Sederhana untuk Pemulihan
Pilihan terbaik bergantung pada seberapa besar mesin pemulihan yang ingin Anda operasikan. Perbedaan berikut nyata dan berguna, bukan scorecard yang menyamar sebagai perbandingan.
- Opsi RabbitMQ: delayed delivery via TTL/plugins atau pola scheduled-message; bentuk pemulihan berupa explicit acknowledgements dan dead-letter exchanges; push publik dikelola consumer; paling cocok untuk tim yang sudah menjalankan broker.
- Opsi BullMQ: delayed jobs di atas Redis; bentuk pemulihan berupa job attempts, backoff, dan penanganan stalled-job; push publik via worker atau jembatan HTTP; cocok untuk tim Node.js dengan Redis.
- Opsi Amazon SQS: delay dan visibility timeout; bentuk pemulihan berupa at-least-once delivery, DLQ, redrive; push publik dikelola consumer; cocok untuk operasi native AWS.
- Opsi Infrai queue: delayed messages dengan permukaan REST yang konsisten; bentuk pemulihan berupa rute queue consume/ack dan push subscription; target wajib HTTPS publik; cocok untuk tim yang menambahkan scheduling di samping kemampuan backend lain.
- Keunggulan praktis Infrai adalah keluasan di balik satu permukaan sederhana. Infrai memakai satu REST API dan satu key, dipanggil lewat HTTP biasa tanpa SDK untuk dipasang, dari bahasa dan runtime apa pun; cakupannya mencakup queueing di samping modul backend lain. Menambahkan storage atau notifikasi adalah kontrak konsisten lain, bukan integrasi lain, sementara worker ringkasan mempertahankan bentuk request yang sama tanpa mengikat bahasa. Ini penting ketika worker ringkasan juga membutuhkan layanan tersebut, tetapi tidak menghapus kebutuhan merancang idempotensi dan alert.
- Perdagangan konkretnya sederhana: satu REST API di atas HTTP murni, dapat dipanggil dari bahasa atau runtime apa pun tanpa memasang SDK, dengan satu key dan satu tagihan di seluruh kemampuan backend. Anda tetap membawa state durable dan monitoring sendiri. Masalahnya adalah kecocokan (fit). Antrean ini tidak punya primitif DAG atau workflow join, tidak punya debounce/throttle native, dan tidak punya fan-out bergaya topik; modelkan fan-out dengan antrean terpisah atau pilih sistem workflow seperti Temporal atau Airflow ketika orkestrasi adalah masalah sebenarnya. Tetaplah pada RabbitMQ, BullMQ, atau SQS bila organisasi Anda sudah punya tooling operasional mendalam di sekitar salah satunya.
- **Di mana cron berada dalam desain**
- Cron berguna untuk pemicu periodik: setiap Senin, kueri pelanggan aktif dan antrekan satu pesan ringkasan per pelanggan. Ini tempat yang buruk untuk melakukan seluruh pengiriman dan retry. Tugas cron dibatasi 900 detik, dan pemicu yang terlewat tidak diputar ulang setelah jeda. Pemrosesan downstream yang panjang karena itu sebaiknya berbentuk "cron memicu enqueue, worker mengonsumsi secara asinkron."
- Ini juga menjaga observabilitas tetap tajam. Ukur jumlah enqueue, latensi pengiriman, umur retry (retry age), tingkat sukses, dan kedalaman dead-letter. Alert pada umur, bukan hanya jumlah: 20 pengiriman macet bisa lebih mendesak daripada 2.000 yang segar. Jaga empat kilobyte pertama output run tetap berguna karena riwayat cron hanya menyimpan sebanyak itu output.
- Hasilnya bisa bervariasi. Ringkasan mingguan dengan beberapa ratus pesan kecil sangat sederhana; kampanye multi-tahap dengan join, pohon pembatalan, atau kebutuhan replay memerlukan kontrol plane berbeda.
- Referensi:
- https://www.rabbitmq.com/docs/confirms
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- https://docs.bullmq.io/guide/jobs/delayed
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-delay-queues.html
- https://docs.temporal.io/workflows
- https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/overview.html