Mengapa Idempotensi Webhook Krusial?
Penyedia layanan pihak ketiga (seperti Stripe, Xendit, atau Midtrans) menerapkan mekanisme at-least-once delivery. Jika endpoint webhook Anda terlambat merespons akibat latensi jaringan atau beban pemrosesan, server pengirim akan mengirim ulang payload yang sama secara otomatis. Tanpa penanganan khusus, skenario ini menyebabkan double processing seperti penambahan saldo ganda atau invoice terbayar dua kali.
Idempotensi webhook PHP modern memastikan bahwa menerima request yang sama beberapa kali menghasilkan efek samping (side effects) yang persis sama dengan menerimanya satu kali saja.
Arsitektur Idempotensi: Alur Kerja State Machine
Pencegahan race condition memerlukan mekanisme atomik langsung pada basis data relational (PostgreSQL atau MySQL InnoDB) menggunakan constraint unik dan status pemrosesan:
- Verifikasi Signature: Validasi keaslian pengirim menggunakan hash HMAC SHA-256 sebelum membaca state transaksi.
- Atomic Lock Acquisition: Gunakan
INSERT ... ON DUPLICATE KEY UPDATE(MySQL) atauINSERT ... ON CONFLICT DO NOTHING(PostgreSQL) untuk mencatat kunci idempotensi dengan statusprocessing. - Execution Boundary: Jika lock berhasil diambil, jalankan logika bisnis utama (misal: mutasi rekening).
- Response Persistence: Simpan response payload dan ubah status menjadi
completed. Jika request duplikat datang belakangan, kembalikan response tersimpan tanpa mengeksekusi ulang logic.
Implementasi Skema Database
CREATE TABLE webhook_idempotency_keys (
idempotency_key VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL, -- 'processing', 'completed', 'failed'
response_code INT NULL,
response_body JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;Kode Implementasi Native PHP 8.3+
Berikut adalah skrip lengkap tanpa dependensi eksternal yang menerapkan verifikasi signature, penanganan header Idempotency-Key atau event ID unik, serta locking level database.
<?php
declare(strict_types=1);
final class WebhookHandler
{
public function __construct(
private PDO $pdo,
private string $secretKey
) {}
public function handle(array $headers, string $rawPayload): void
{
// 1. Verifikasi Signature HMAC SHA-256
$signature = $headers['X-Signature'] ?? $headers['x-signature'] ?? null;
if (!$signature || !hash_equals(hash_hmac('sha256', $rawPayload, $this->secretKey), $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
return;
}
$payload = json_decode($rawPayload, true);
$idempotencyKey = $headers['X-Idempotency-Key'] ?? $headers['x-idempotency-key'] ?? $payload['id'] ?? null;
$eventType = $payload['event'] ?? 'unknown';
if (!$idempotencyKey) {
http_response_code(400);
echo json_encode(['error' => 'Missing idempotency identifier']);
return;
}
// 2. Akuisisi Lock secara Atomik via Database
$this->pdo->beginTransaction();
try {
// Cek apakah key sudah ada
$stmt = $this->pdo->prepare(
'SELECT status, response_code, response_body FROM webhook_idempotency_keys WHERE idempotency_key = ? FOR UPDATE'
);
$stmt->execute([$idempotencyKey]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
if ($existing['status'] === 'completed') {
// Request identik sebelumnya sudah sukses diproses: Caching Response
$this->pdo->commit();
http_response_code((int)$existing['response_code']);
header('Content-Type: application/json');
echo $existing['response_body'];
return;
}
if ($existing['status'] === 'processing') {
// Sedang dikerjakan oleh worker/process paralel lain: Hindari race condition
$this->pdo->commit();
http_response_code(429); // Backoff
echo json_encode(['error' => 'Request currently being processed. Retry later.']);
return;
}
}
// Daftarkan key baru ke state processing
$insertStmt = $this->pdo->prepare(
'INSERT INTO webhook_idempotency_keys (idempotency_key, event_type, status) VALUES (?, ?, ?)'
);
$insertStmt->execute([$idempotencyKey, $eventType, 'processing']);
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Database transaction error during lock']);
return;
}
// 3. Jalankan Logika Bisnis Utama
$responseCode = 200;
$responseData = ['status' => 'success', 'message' => 'Event processed'];
try {
$this->executeBusinessLogic($eventType, $payload);
} catch (Throwable $e) {
$responseCode = 500;
$responseData = ['status' => 'failed', 'error' => $e->getMessage()];
}
// 4. Update State Akhir dan Simpan Response Cache
$updateStmt = $this->pdo->prepare(
'UPDATE webhook_idempotency_keys SET status = ?, response_code = ?, response_body = ? WHERE idempotency_key = ?'
);
$updateStmt->execute([
$responseCode === 200 ? 'completed' : 'failed',
$responseCode,
json_encode($responseData),
$idempotencyKey
]);
http_response_code($responseCode);
header('Content-Type: application/json');
echo json_encode($responseData);
}
private function executeBusinessLogic(string $eventType, array $payload): void
{
// ponytail: Business execution stub, ganti dengan event dispatcher atau handler terpisah.
if ($eventType === 'payment.succeeded') {
// Mutasi data saldo / order status
}
}
}
// Bootstrap & Invocation Native
$pdo = new PDO('mysql:host=localhost;dbname=payment_db', 'dbuser', 'dbpass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
$handler = new WebhookHandler($pdo, 'webhook_secret_key_prod_123');
$headers = getallheaders() ?: [];
$rawPayload = file_get_contents('php://input') ?: '';
$handler->handle($headers, $rawPayload);Menangani Edge Cases dan Kegagalan
1. Zombie Locks (State 'processing' Menggantung)
Jika server mati mendadak saat logika bisnis berjalan, baris tabel akan selamanya berstatus processing. Solusinya, tambahkan batasan timeout TTL saat verifikasi:
SELECT status, created_at FROM webhook_idempotency_keys WHERE idempotency_key = ? FOR UPDATE;Jika status masih processing tetapi created_at < NOW() - INTERVAL 5 MINUTE, anggap request sebelumnya hang dan timpa statusnya menjadi failed atau restart pemrosesan.
2. Response Caching
Mengembalikan status code HTTP yang persis sama (termasuk 200 OK dengan body yang sama) sangat penting agar webhook gateway provider menganggap webhook berhasil terkirim dan berhenti membanjiri server dengan retry.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!