Integrasi berbasis event sering kali mengasumsikan webhook tiba tepat waktu dan berurutan. Di lingkungan produksi, jaringan terdistribusi memiliki latensi tidak terduga, mekanisme retry otomatis dari penyedia webhook, serta potensi clock skew antar server. Masalah kritis muncul ketika event memiliki batas waktu aktif (time-bound/sunset event)—seperti klaim hadiah sementara, batas verifikasi transaksi kilat, atau reservasi terbatas. Jika webhook tiba terlambat (late delivery) atau diproses di luar urutan (out-of-order retries), sistem berisiko memutasi resource ke state valid padahal periode validitas bisnisnya telah berakhir.
Anatomi Masalah: Late Delivery, Out-of-Order Retries, dan Clock Skew
Tiga kegagalan utama yang merusak integritas state saat memproses event berbasis batas waktu:
- Late Delivery: Penyedia pihak ketiga mengalami outage atau antrean internal tertahan. Event
CAT_SPOTTEDdengan masa kedaluwarsa 60 detik baru terkirim 10 menit kemudian. Tanpa validasi waktu kedaluwarsa, aplikasi penerima tetap mengeksekusi aksi. - Out-of-Order Retries: Jaringan gagal mengirim event versi 1, lalu mengirim event versi 2 (misal: status dibatalkan). Saat retry event versi 1 akhirnya berhasil menembus firewall beberapa detik kemudian, sistem menimpa status terbaru dengan state basi (stale state overwrite).
- Clock Skew: Jam sistem server pengirim dan server penerima memiliki selisih waktu (drift). Mengandalkan
now() > expires_atsecara naif tanpa toleransi batas ambang (drift margin) dapat menyebabkan event valid ditolak sebelum waktunya, atau sebaliknya, event basi tetap diterima.
Strategi Pertahanan Tiga Lapis
Untuk memastikan determinisme mutasi data, terapkan tiga lapisan validasi berikut:
- Validasi Kontrak Payload: Periksa tanda tangan kriptografis HMAC, batas toleransi waktu pembuatan event (
created_at), serta batas mutlak kadaluarsa (expires_at) langsung di boundary layer. - Idempotency Key dengan TTL Dinamis: Gunakan cache terdistribusi (Redis) untuk menyimpan status pemrosesan. Set masa aktif (TTL) kunci idempotensi berdasarkan sisa waktu kedaluwarsa event, bukan nilai statis arbitrer.
- Transisi State Deterministik via Conditional Update (CAS): Serahkan eksekusi mutasi akhir ke database menggunakan teknik Compare-And-Swap atau Optimistic Locking. Pastikan klausul
WHEREmemverifikasi versi state dan timestamp validitas sebelum update dieksekusi.
Implementasi: Validasi Payload dan Idempotency
Contoh berikut mendemonstrasikan implementasi receiver menggunakan TypeScript dan Node.js standar. Kode memverifikasi signature, memeriksa batas kadaluarsa relatif terhadap drift margin, dan menggunakan Redis untuk idempotency key dinamis.
import crypto from "node:crypto";
import type { Request, Response } from "express";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "super-secret-key";
const MAX_CLOCK_DRIFT_SEC = 5; // 5 detik toleransi clock skew
interface TimeBoundWebhookPayload {
event_id: string;
resource_id: string;
action: "CLAIM" | "EXPIRE";
issued_at: number; // UNIX epoch dalam detik
expires_at: number; // UNIX epoch dalam detik
version: number;
}
export function verifySignature(rawBody: string, signature: string): boolean {
const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET);
const digest = "sha256=" + hmac.update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
}
export async function webhookHandler(req: Request, res: Response): Promise<void> {
const signature = req.headers["x-webhook-signature"] as string;
const rawBody = (req as any).rawBody || JSON.stringify(req.body);
if (!signature || !verifySignature(rawBody, signature)) {
res.status(401).json({ error: "Invalid signature" });
return;
}
const payload: TimeBoundWebhookPayload = req.body;
const currentEpoch = Math.floor(Date.now() / 1000);
// 1. Validasi Expired Event dengan Margin Drift
if (currentEpoch > (payload.expires_at + MAX_CLOCK_DRIFT_SEC)) {
res.status(410).json({ error: "Event payload expired and discarded" });
return;
}
// 2. Dynamic TTL Idempotency via Redis SET NX EX
const idempotencyKey = `idemp:${payload.event_id}`;
const remainingTtl = Math.max(1, payload.expires_at - currentEpoch + MAX_CLOCK_DRIFT_SEC);
const acquired = await redis.set(idempotencyKey, "PROCESSING", "EX", remainingTtl, "NX");
if (!acquired) {
// Event duplikat atau sedang diproses
res.status(200).json({ message: "Duplicate or in-flight delivery" });
return;
}
try {
// Jalankan mutasi state database (CAS)
const success = await executeStateTransition(payload);
if (!success) {
res.status(409).json({ error: "State transition rejected" });
return;
}
res.status(200).json({ status: "SUCCESS" });
} catch (err) {
// Hapus idempotency lock jika error sistem internal untuk mengizinkan retry
await redis.del(idempotencyKey);
res.status(500).json({ error: "Internal processing failure" });
}
}
Transisi State Deterministik via Conditional Update (CAS)
Aplikasi tidak boleh mengandalkan pengecekan read-then-write (misal: SELECT status ... IF valid THEN UPDATE) tanpa lock, karena rentan terhadap race condition di level worker konkurensi. Gunakan conditional atomic update langsung di engine database:
-- Skema tabel resource
CREATE TABLE sunset_resources (
id VARCHAR(64) PRIMARY KEY,
state VARCHAR(32) NOT NULL,
current_version INT NOT NULL DEFAULT 1,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Query Eksekusi Atomic Transition
UPDATE sunset_resources
SET
state = 'CLAIMED',
current_version = current_version + 1,
updated_at = NOW()
WHERE
id = $1
AND state = 'PENDING'
AND current_version = $2
AND expires_at > NOW();
Evaluasi baris yang terpengaruh (affected rows):
- Jika
rows_affected === 1: Transisi berhasil dieksekusi secara atomic. - Jika
rows_affected === 0: Event ditolak karena resource telah berpindah state, versi telah berubah mendahului worker ini, atau record telah melewati batas waktu kadaluarsa database (sunset).
Trade-Offs dan Panduan Debugging
Menerapkan validasi berbasis waktu dan idempotency ketat menghadirkan beberapa kompromi arsitektural:
- Sinkronisasi Jam Server (NTP): Kegagalan sinkronisasi NTP pada host dapat memicu lonjakan false positive
HTTP 410 Gone. Pasang alerting pada offset drift NTP node backend Anda. - HTTP 410 vs HTTP 200 untuk Event Kedaluwarsa: Mengembalikan status HTTP 4xx akan memicu penyedia pihak ketiga untuk terus mencoba pengiriman ulang (retry) yang sia-sia jika mereka tidak membedakan error idempotensi. Jika penyedia webhook tidak mengenali status 410, kembalikan HTTP 200 disertai pesan internal
{"status": "ignored_expired"}. - Dead-Letter Queue (DLQ): Pisahkan event kadaluarsa ke storage pengarsipan atau DLQ untuk audit trail guna merekonsiliasi transaksi bermasalah di kemudian hari tanpa mengotori status database utama.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!