Portal/Notes πŸ“
Interview prep

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:

  1. Client kirim POST /payments
  2. Server proses payment β€” sukses
  3. Response lost di network
  4. Client retry POST /payments (dengan data yang sama)
  5. Server charge lagi β†’ DOUBLE CHARGE πŸ’Έ

Ini bukan bug hipotetis. Stripe, PayPal, Midtrans β€” semua payment gateway WAJIB support idempotency.

Cara Kerja

  1. Client generate unique Idempotency-Key (UUID) sebelum request
  2. Client kirim key via HTTP header: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
  3. 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.

Edit on GitHub

Last updated on

Idempotency Key β€” Payment Endpoint | Faisal Affan