Idempotency Key β Payment Endpoint
Implementasi idempotency key untuk payment endpoint di Go: mencegah double charge, concurrent request handling, response caching β dengan Redis dan PostgreSQL.
Kenapa Penting
Di payment system, satu request = satu charge. Tapi network unreliable:
- Client kirim POST /payments
- Server proses payment β sukses
- Response lost di network
- Client retry POST /payments (dengan data yang sama)
- Server charge lagi β DOUBLE CHARGE πΈ
Ini bukan bug hipotetis. Stripe, PayPal, Midtrans β semua payment gateway WAJIB support idempotency.
Cara Kerja
- Client generate unique
Idempotency-Key(UUID) sebelum request - Client kirim key via HTTP header:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 - Server cek key di storage:
- Pertama kali: proses request, simpan response, return
- Retry (key sama): return cached response, TANPA proses ulang
Implementasi Go
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
var ErrConcurrentRequest = errors.New("concurrent request with same idempotency key")
type PaymentHandler struct {
service PaymentService
redis *redis.Client
}
type PaymentRequest struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Source string `json:"source"`
}
type PaymentResponse struct {
ID string `json:"id"`
Status string `json:"status"`
Amount int64 `json:"amount"`
ProcessorRef string `json:"processor_ref"`
}
func (h *PaymentHandler) CreatePayment(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Validate idempotency key
idemKey := r.Header.Get("Idempotency-Key")
if idemKey == "" {
http.Error(w, `{"error":"Idempotency-Key header required"}`, http.StatusBadRequest)
return
}
if len(idemKey) > 64 {
http.Error(w, `{"error":"Idempotency-Key too long (max 64)"}`, http.StatusBadRequest)
return
}
// 2. Parse request body
var req PaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
// 3. Check idempotency cache (fast path β Redis, <1ms)
cached, err := h.getCachedResponse(ctx, idemKey)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Idempotent-Replayed", "true")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(cached)
return
}
// 4. Acquire lock untuk mencegah race condition
lockKey := fmt.Sprintf("lock:%s", idemKey)
ok, err := h.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if !ok {
// Another request is processing β wait and retry
for i := 0; i < 10; i++ {
time.Sleep(100 * time.Millisecond)
cached, err := h.getCachedResponse(ctx, idemKey)
if err == nil {
w.Header().Set("Idempotent-Replayed", "true")
json.NewEncoder(w).Encode(cached)
return
}
}
http.Error(w, `{"error":"concurrent request timeout"}`, http.StatusConflict)
return
}
defer h.redis.Del(ctx, lockKey)
// 5. Double-check cache (another request might have completed while we waited for lock)
cached, err = h.getCachedResponse(ctx, idemKey)
if err == nil {
w.Header().Set("Idempotent-Replayed", "true")
json.NewEncoder(w).Encode(cached)
return
}
// 6. Process payment
resp, err := h.service.ProcessPayment(ctx, req)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
return
}
// 7. Cache response (Redis + DB)
if err := h.cacheResponse(ctx, idemKey, resp); err != nil {
// Log but don't fail β response is still valid
log.Printf("WARN: failed to cache idempotency response: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
func (h *PaymentHandler) getCachedResponse(ctx context.Context, key string) (*PaymentResponse, error) {
data, err := h.redis.Get(ctx, cacheKey(key)).Bytes()
if err != nil {
return nil, err
}
var resp PaymentResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func (h *PaymentHandler) cacheResponse(ctx context.Context, key string, resp *PaymentResponse) error {
data, _ := json.Marshal(resp)
return h.redis.Set(ctx, cacheKey(key), data, 24*time.Hour).Err()
}
func cacheKey(idempotencyKey string) string {
// Hash untuk mencegah key terlalu panjang
hash := sha256.Sum256([]byte(idempotencyKey))
return fmt.Sprintf("idem:%s", hex.EncodeToString(hash[:]))
}Middleware Pattern (Lebih Bersih)
func IdempotencyMiddleware(redis *redis.Client) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only for mutating methods
if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodPatch {
next.ServeHTTP(w, r)
return
}
idemKey := r.Header.Get("Idempotency-Key")
if idemKey == "" {
next.ServeHTTP(w, r)
return
}
// Check cache
cacheKey := fmt.Sprintf("idem:resp:%s", sha256Hash(idemKey))
if cached, err := redis.Get(r.Context(), cacheKey).Bytes(); err == nil {
w.Header().Set("Idempotent-Replayed", "true")
w.Header().Set("Content-Type", "application/json")
w.Write(cached)
return
}
// Wrap response writer untuk capture response
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
// Cache hanya kalau sukses
if rec.statusCode >= 200 && rec.statusCode < 300 {
redis.Set(r.Context(), cacheKey, rec.body.Bytes(), 24*time.Hour)
}
})
}
}
type responseRecorder struct {
http.ResponseWriter
statusCode int
body bytes.Buffer
}
func (r *responseRecorder) WriteHeader(code int) {
r.statusCode = code
r.ResponseWriter.WriteHeader(code)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
r.body.Write(b)
return r.ResponseWriter.Write(b)
}Idempotency di Database Level
Selain Redis, simpan idempotency key di database:
CREATE TABLE idempotency_keys (
key_hash VARCHAR(64) PRIMARY KEY,
request_body_hash VARCHAR(64) NOT NULL, -- detect different body with same key
response_body JSONB,
status_code INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at)
WHERE response_body IS NOT NULL;Cleanup expired keys:
// Run every hour
func cleanupExpiredIdempotencyKeys(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx,
`DELETE FROM idempotency_keys WHERE expires_at < NOW()`)
return err
}Interview Talking Points
"Kenapa pake Redis, bukan database?"
- Latency: Redis <1ms, DB 5-10ms. Buat check per-request, 10ms terlalu lambat.
- TTL built-in: Redis auto-expire, DB butuh cleanup job.
- Tapi Redis bisa down. Solusi: fallback ke DB query kalau Redis unreachable.
"Apa yang terjadi kalau idempotency key sama tapi body beda?"
Ini harus ditolak. Simpan hash request body bareng response. Kalau key sama tapi body hash beda β return 422 Unprocessable Entity.
"Berapa lama idempotency key harus disimpan?" Minimum 24 jam (standar Stripe). Payment reconciliation biasanya dalam 24 jam. Setelah itu key expired β kalau client retry setelah 24 jam dengan key yang sama, server proses ulang.
Last updated on