Prerequisite: Before reading this chapter, please ensure you have read the previous article in this series: Chapter 6: API Gateway vs Service Mesh in Microservices Architecture.
In E-commerce or Fintech, the ultimate nightmare is not a system crash, but charging a customer twice for a single order. This is usually caused by network lag, an impatient user double-clicking “Pay”, or automated app retry logic.
The mandatory solution for any transactional API (Payment/Order) is Idempotency.
1. What is Idempotency?
An operation is idempotent if executing it once or N times yields the exact same system state and outcome. While GET and PUT are natively idempotent, POST requires explicit engineering.
With HTTP REST APIs:
GET,PUT,DELETE: Inherently idempotent. (Deleting a user 10 times results in the same state: the user is gone).POST: Not idempotent. Calling POST/charge10 times will execute 10 financial deductions.
2. Idempotency-Key and the Request Lifecycle
Clients must attach a unique Idempotency-Key UUID to their requests. The server validates this key against Redis to determine if the transaction is new, processing, or already completed.
To enforce idempotency on a POST API, the Client (Mobile/Web) must generate a Unique ID (typically a UUID v4) and attach it to the Request Header: Idempotency-Key: 123e4567...
The Golang server handles this via 3 strict states:
- State 1 (New Key):
- The server registers the Key in Redis with an
IN_FLIGHTstate. - It executes the business logic (calling payment gateways, deducting balances).
- Upon completion, it updates the Key to
DONEand stores the entire Response Payload in Redis. It returns the result to the Client.
- The server registers the Key in Redis with an
- State 2 (Key is IN_FLIGHT):
- The user double-clicks. Request 2 arrives while Request 1 is still processing.
- The server checks Redis, sees
IN_FLIGHT, instantly blocks Request 2, and returns anHTTP 409 Conflict(or 423 Locked) error.
- State 3 (Key is DONE):
- The user drops connection after Request 1 finishes, missing the response. The user retries the request with the identical Key.
- The server checks Redis, sees
DONE. The server DOES NOT re-run the deduction logic. Instead, it pulls the cached Response Payload from Redis and returns it immediately. The user receives the exact success payload they missed.
stateDiagram-v2
[*] --> NewRequest
NewRequest --> CheckRedis: Header contains Idempotency-Key
CheckRedis --> IN_FLIGHT: Key Not Exists (SET NX)
CheckRedis --> CONFLICT: Key == IN_FLIGHT
CheckRedis --> DONE: Key == DONE
IN_FLIGHT --> ExecuteLogic: Process Payment
ExecuteLogic --> SaveResponse: Update Key to DONE
SaveResponse --> ReturnNewResponse
CONFLICT --> Return409Error: "Processing"
DONE --> ReturnCachedResponse: Return old payload
3. Idempotency State Machine: Hardening with Retriability
To build a production-grade billing system, the idempotency engine must support transient failure retries. This is handled by modeling explicit state transitions:
stateDiagram-v2
[*] --> IN_FLIGHT : Client submits Request (SET NX)
IN_FLIGHT --> DONE : Processing Succeeds
IN_FLIGHT --> RETRIABLE_FAIL : Transient Failure (e.g., Timeout)
IN_FLIGHT --> NON_RETRIABLE_FAIL : Hard Business Error (e.g., Insufficient Balance)
RETRIABLE_FAIL --> IN_FLIGHT : Client Retries Request
NON_RETRIABLE_FAIL --> [*] : Key Locked (Cannot retry)
RETRIABLE_FAIL: If the downstream payment gateway returns a timeout (HTTP 504), the transaction is incomplete. The idempotency engine deletes the key or marks it asRETRIABLE. This allows the client to submit another request with the exact same key.NON_RETRIABLE_FAIL: If the transaction fails due to a validation error (e.g., product out of stock), the state transitions toDONEwith the failed response cached. Any retry with the same key returns the cached failure payload immediately without re-checking inventory.
4. PostgreSQL Unique Index Locks
In Core Banking and high-value payments, relying solely on in-memory systems like Redis is a security risk. If Redis restarts or evicts keys under memory pressure, the system could allow duplicate transactions. Therefore, financial systems implement distributed transactional deduplication using the relational database.
This is achieved by maintaining an idempotency_keys table inside PostgreSQL with a UNIQUE constraint:
CREATE TABLE idempotency_keys (
key_id VARCHAR(255) PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
status VARCHAR(50) NOT NULL,
response_code INT NOT NULL,
response_body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
The Database Lock Flow
When two concurrent requests attempt to insert the same key, PostgreSQL handles the synchronization at the database level:
- Transaction 1 executes:
INSERT INTO idempotency_keys (key_id, ...) VALUES ('key_abc', ...) - PostgreSQL acquires an exclusive write lock on the index leaf node for
'key_abc'. - Transaction 2 attempts to insert the same key. Because of the
UNIQUEconstraint, Transaction 2 blocks, waiting for Transaction 1 to complete. - If Transaction 1 commits, Transaction 2 instantly fails with a unique constraint violation error (PostgreSQL error code
23505). - If Transaction 1 aborts (rolls back), the lock is released, and Transaction 2 proceeds to insert the key.
To prevent thread pool exhaustion on the application side while waiting for locks to release, you should set a strict lock timeout inside your database session:
SET lock_timeout = '2000'; -- 2 seconds max wait
5. High-Security Edge Case: Payload Hashing
Malicious clients can exploit idempotency by reusing an old Key with a new, expensive payload. Counter this by storing a SHA256 Hash of the Request Body alongside the Idempotency Key.
A common exploit involves a malicious client reusing an old DONE Idempotency-Key but transmitting a new payload (e.g., purchasing an expensive TV). If the system only checks the Key, it will return the old success response (for a cheap item) while ignoring the new payload entirely!
The Defense: Hash (e.g., SHA256) the entire Request Body. Store this Hash value alongside the Idempotency-Key in Redis or PostgreSQL. If a duplicate Key arrives but the Body Hash differs, block it instantly and return HTTP 400 Bad Request.
Go Implementation: Resilient Database Deduplication Middleware
The following Go code implements an API idempotency layer using PostgreSQL unique constraints to guarantee transactional safety.
package main
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/jackc/pgconn"
_ "github.com/jackc/pgx/v4/stdlib"
)
// IdempotencyRecord maps to the DB schema.
type IdempotencyRecord struct {
KeyId string
RequestHash string
Status string
ResponseCode int
ResponseBody string
}
type PaymentHandler struct {
db *sql.DB
}
// ComputeHash computes the SHA256 hash of the request body.
func ComputeHash(body []byte) string {
hash := sha256.Sum256(body)
return hex.EncodeToString(hash[:])
}
// HandlePayment processes payments with database deduplication.
func (h *PaymentHandler) HandlePayment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
idemKey := r.Header.Get("Idempotency-Key")
if idemKey == "" {
http.Error(w, "Missing Idempotency-Key header", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
reqHash := ComputeHash(body)
ctx := r.Context()
// 1. Attempt to insert IN_FLIGHT status to claim ownership of the key
tx, err := h.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Set session-level lock timeout to protect connection pool
_, _ = tx.ExecContext(ctx, "SET LOCAL lock_timeout = '1500'")
query := `INSERT INTO idempotency_keys (key_id, request_hash, status, response_code, response_body)
VALUES ($1, $2, 'IN_FLIGHT', 0, '')`
_, err = tx.ExecContext(ctx, query, idemKey, reqHash)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // Unique Constraint Violation
// 2. Key exists, fetch current status and response
record, fetchErr := h.fetchIdempotencyRecord(ctx, idemKey)
if fetchErr != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Validate if the request body matches the original request
if record.RequestHash != reqHash {
http.Error(w, "Idempotency Key reused with different payload", http.StatusBadRequest)
return
}
if record.Status == "IN_FLIGHT" {
// Request is still processing in another thread
w.Header().Set("Retry-After", "2")
http.Error(w, "Concurrent request processing", http.StatusConflict)
return
}
// Return the cached response
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache-Lookup", "HIT - Idempotent Response")
w.WriteHeader(record.ResponseCode)
_, _ = w.Write([]byte(record.ResponseBody))
return
}
http.Error(w, "Database Lock Timeout / Error", http.StatusGatewayTimeout)
return
}
// Commit claiming the key
if err := tx.Commit(); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// 3. Execute the actual payment transaction
code, respPayload := h.executePayment()
// 4. Update the key to DONE along with response payload
updateQuery := `UPDATE idempotency_keys
SET status = 'DONE', response_code = $1, response_body = $2
WHERE key_id = $3`
_, err = h.db.ExecContext(ctx, updateQuery, code, respPayload, idemKey)
if err != nil {
fmt.Printf("Failed to update idempotency key: %v\n", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(respPayload))
}
func (h *PaymentHandler) fetchIdempotencyRecord(ctx context.Context, key string) (*IdempotencyRecord, error) {
var record IdempotencyRecord
query := "SELECT key_id, request_hash, status, response_code, response_body FROM idempotency_keys WHERE key_id = $1"
err := h.db.QueryRowContext(ctx, query, key).Scan(
&record.KeyId, &record.RequestHash, &record.Status, &record.ResponseCode, &record.ResponseBody,
)
if err != nil {
return nil, err
}
return &record, nil
}
func (h *PaymentHandler) executePayment() (int, string) {
// Simulate billing processing time
time.Sleep(200 * time.Millisecond)
resp := map[string]interface{}{
"transaction_id": "tx_99281729",
"status": "SUCCESS",
"billed_at": time.Now().Format(time.RFC3339),
}
payload, _ := json.Marshal(resp)
return http.StatusOK, string(payload)
}
func main() {
db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/payment_db?sslmode=disable")
if err != nil {
panic(err)
}
defer db.Close()
handler := &PaymentHandler{db: db}
http.HandleFunc("/charge", handler.HandlePayment)
_ = http.ListenAndServe(":8080", nil)
}
This database-backed idempotency mechanism guarantees absolute consistency, preventing duplicate charges even during concurrent network retries.
🎯 Architecture Review & Consulting (Hire Me)
If your enterprise e-commerce or B2B platform is struggling with slow database queries, checkout timeouts, or scaling bottlenecks, don’t let it jeopardize your business revenue.
👉 Book a 1:1 Architecture Consultation this week with Lê Tuấn Anh (Vesviet) to identify bottlenecks and implement proven scaling strategies.
🔗 Next Step: Chapter 8: Distributed Locking — Redlock vs ZooKeeper
