Pola Idempotency Key pada API Pembayaran dan Order di Go
"Implementasi pola Idempotency Key di Go menggunakan Redis dan PostgreSQL untuk mencegah transaksi ganda pada sistem pembayaran."
Pola Idempotency Key pada API Pembayaran dan Order di Go
Dalam sistem terdistribusi, jaringan tidak pernah dapat diandalkan 100% (unreliable network). Masalah koneksi seperti timeout, koneksi terputus saat response dikirim (half-open TCP), atau retry otomatis dari HTTP client/mobile app dapat menyebabkan satu request eksekusi berulang kali.
Pada endpoint pembayaran (checkout atau transfer uang), request duplikat berakibat fatal: saldo user terpotong ganda (double charge). Pola Idempotency Key memastikan request yang memiliki identitas sama hanya diproses tepat satu kali, sementara request duplikat berikutnya akan mengembalikan response yang identik tanpa memicu transaksi ulang.
1. Bagaimana Idempotency Key Bekerja?
Client membuat identifier unik (misal UUID v4) dan mengirimkannya via HTTP header:
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d.Server menerima request dan mencoba mengakuisisi lock atomik (misal via Redis
SETNXatau PostgreSQL conditional insert) menggunakan key tersebut.Jika key belum ada: Status diset
PROCESSING. Server menjalankan logika bisnis (potong saldo, create invoice). Setelah selesai, server menyimpan statusCOMPLETEDbersama status code dan body response, lalu me-return response ke client.Jika key sudah berstatus
COMPLETED: Server langsung mengembalikan cached response yang disimpan sebelumnya tanpa mengeksekusi logika bisnis lagi.Jika key masih berstatus
PROCESSING: Server mengembalikan HTTP409 Conflict(atau menunggu dengan timeout polling) untuk mencegah concurrent race condition dari client yang sama.
2. Struktur Data dan Database Schema
Kita dapat menyimpan state idempotency di Redis untuk performa tinggi, atau langsung di PostgreSQL untuk konsistensi transaksional ACID:
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('PROCESSING', 'COMPLETED', 'FAILED')),
response_code INT NULL,
response_body JSONB NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_idempotency_created ON idempotency_keys(created_at);
3. Implementasi Idempotency Middleware di Go
Berikut implementasi HTTP middleware idiomatik di Go menggunakan Redis:
package idempotency
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
type CachedResponse struct {
StatusCode int `json:"status_code"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body"`
}
type Middleware struct {
rdb *redis.Client
ttl time.Duration
}
func NewMiddleware(rdb *redis.Client, ttl time.Duration) *Middleware {
return &Middleware{rdb: rdb, ttl: ttl}
}
// responseRecorder untuk menangkap status code dan body sebelum dikirim ke client
type responseRecorder struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
}
func (rec *responseRecorder) WriteHeader(code int) {
rec.statusCode = code
rec.ResponseWriter.WriteHeader(code)
}
func (rec *responseRecorder) Write(b []byte) (int, error) {
rec.body.Write(b)
return rec.ResponseWriter.Write(b)
}
func (m *Middleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Hanya terapkan pada method non-idempotent (POST, PATCH)
if r.Method != http.MethodPost && r.Method != http.MethodPatch {
next.ServeHTTP(w, r)
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, `{"error":"Idempotency-Key header is required"}`, http.StatusBadRequest)
return
}
ctx := r.Context()
redisKey := fmt.Sprintf("idempotency:%s", key)
// 1. Baca request body untuk menghitung payload hash
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, `{"error":"failed to read request body"}`, http.StatusInternalServerError)
return
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // restore body
reqHash := calculateHash(bodyBytes)
// 2. Cek apakah key sudah pernah diproses (Redis SETNX sebagai distributed lock)
lockAcquired, err := m.rdb.SetNX(ctx, redisKey+":lock", reqHash, 30*time.Second).Result()
if err != nil {
http.Error(w, `{"error":"internal storage error"}`, http.StatusInternalServerError)
return
}
if !lockAcquired {
// Lock sudah dipegang request lain atau sudah selesai
cachedData, err := m.rdb.Get(ctx, redisKey+":data").Bytes()
if err == nil {
// Response sudah siap: kembalikan cached response
var resp CachedResponse
if err := json.Unmarshal(cachedData, &resp); err == nil {
for k, v := range resp.Headers {
w.Header().Set(k, v)
}
w.Header().Set("X-Cache-Lookup", "HIT-IDEMPOTENT")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(resp.Body)
return
}
}
// Masih dalam proses
http.Error(w, `{"error":"request is currently being processed"}`, http.StatusConflict)
return
}
// 3. Eksekusi handler asli dan rekam responnya
rec := &responseRecorder{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(rec, r)
// 4. Simpan response sukses ke Redis
if rec.statusCode >= 200 && rec.statusCode < 500 {
cachedResp := CachedResponse{
StatusCode: rec.statusCode,
Headers: map[string]string{
"Content-Type": rec.Header().Get("Content-Type"),
},
Body: rec.body.Bytes(),
}
respBytes, _ := json.Marshal(cachedResp)
_ = m.rdb.Set(ctx, redisKey+":data", respBytes, m.ttl).Err()
}
// Hapus lock processing
_ = m.rdb.Del(ctx, redisKey+":lock").Err()
})
}
func calculateHash(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
4. Validasi Payload Hash Mismatch
Jika attacker atau user mengirimkan Idempotency-Key yang sama persis namun mengubah nominal pembayaran atau akun tujuan (payload tampering), server harus menolak request dengan status 422 Unprocessable Entity atau 400 Bad Request.
func validatePayloadMismatch(existingHash, currentHash string) error {
if existingHash != currentHash {
return errors.New("idempotency key reused with different request payload")
}
return nil
}
5. Ringkasan Praktik Terbaik
Gunakan SHA-256 Request Hashing: Pastikan client tidak dapat menggunakan key yang sama untuk request body yang berbeda.
Set TTL Realistis: Simpan cache response idempotency selama 24 hingga 72 jam, sesuai siklus retry client.
Handle Timeout Gracefully: Jika proses payment downstream timeout, rilis lock atau update status menjadi
FAILEDagar user dapat mencoba ulang.Cakupan Scope Key: Gabungkan
UserIDdanIdempotency-Keypada storage key (idempotency:{userID}:{key}) untuk mencegah key collision antar pengguna berbeda.
About the Author
huud
@huud
Systems architect and software engineer building high-performance distributed platforms.