Idempotency key: membuat API pembayaran aman untuk di-retry
Kenapa retry menyebabkan tagihan ganda, dan bagaimana header Idempotency-Key yang didukung unique constraint mencegahnya di Spring Boot.
3 menit baca
Di halaman ini
Masalah pembayaran ganda
Sebuah aplikasi mobile mengirim POST /payments. Server menagih nasabah, tetapi responsnya hilang
karena koneksi lemah. Aplikasi melihat timeout dan melakukan hal yang wajar: mencoba lagi. Tanpa
perlindungan, nasabah ditagih dua kali.
Retry bukan bug; di jaringan seluler, retry pasti terjadi. Solusinya adalah membuat endpoint idempoten: mengirim request yang sama dua kali menghasilkan efek yang sama dengan mengirimnya sekali.
Kontraknya
Client membuat satu key unik per aksi pengguna (UUID sudah cukup) dan mengirimnya di setiap percobaan untuk aksi tersebut:
POST /api/payments HTTP/1.1
Idempotency-Key: 6f1c2a8e-3b7d-4c55-9a0e-2d8f4b1e7c90
Content-Type: application/json
{ "policyNumber": "P-001", "amount": 250000.00 }Server menjanjikan tiga hal:
- Request pertama dengan sebuah key diproses seperti biasa.
- Pengulangan dengan key dan body yang sama mengembalikan respons yang tersimpan, tanpa menagih lagi.
- Pengulangan dengan key yang sama tetapi body berbeda ditolak dengan
422, karena hampir pasti itu bug di client.
Simpan key dengan unique constraint
Database mengerjakan bagian tersulitnya. Unique constraint menjamin hanya satu request yang bisa mengklaim sebuah key, bahkan ketika dua retry datang bersamaan di instance yang berbeda:
CREATE TABLE idempotency_keys (
idempotency_key VARCHAR(64) NOT NULL PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- IN_PROGRESS | COMPLETED
response_status INT NULL,
response_body NVARCHAR(MAX) NULL,
created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);Alur di service
@Service
class IdempotentPaymentService {
private final IdempotencyKeyRepository keys;
private final PaymentService payments;
private final ObjectMapper json;
IdempotentPaymentService(IdempotencyKeyRepository keys, PaymentService payments, ObjectMapper json) {
this.keys = keys;
this.payments = payments;
this.json = json;
}
ResponseEntity<String> create(String key, CreatePaymentRequest request) throws Exception {
var hash = sha256(json.writeValueAsString(request));
// 1. Claim the key. Only one concurrent request can win this insert.
if (!keys.tryInsert(key, hash)) {
var existing = keys.findById(key).orElseThrow();
if (!existing.getRequestHash().equals(hash)) {
throw new BusinessRuleException("IDEMPOTENCY_KEY_REUSED",
"This Idempotency-Key was already used with a different request.");
}
if (existing.isInProgress()) {
// The first attempt is still running; tell the client to try again shortly.
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
// 2. Same request, already done: replay the stored response.
return ResponseEntity.status(existing.getResponseStatus()).body(existing.getResponseBody());
}
// 3. First attempt: do the work and remember the result.
var result = payments.create(request);
var body = json.writeValueAsString(result);
keys.complete(key, HttpStatus.CREATED.value(), body);
return ResponseEntity.status(HttpStatus.CREATED).body(body);
}
}tryInsert adalah INSERT biasa yang mengembalikan false ketika primary key sudah ada:
@Repository
class IdempotencyKeyRepository {
private final JdbcTemplate jdbc;
IdempotencyKeyRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
boolean tryInsert(String key, String hash) {
try {
jdbc.update("""
INSERT INTO idempotency_keys (idempotency_key, request_hash, status)
VALUES (?, ?, 'IN_PROGRESS')
""", key, hash);
return true;
} catch (DuplicateKeyException e) {
return false;
}
}
// findById(...) and complete(...) are ordinary SELECT and UPDATE statements.
}Controller membaca header lalu mendelegasikannya. Pengecekan @Size pada header hanya berjalan kalau
class controller diberi anotasi @Validated:
@PostMapping
ResponseEntity<String> create(
@RequestHeader("Idempotency-Key") @Size(min = 8, max = 64) String key,
@Valid @RequestBody CreatePaymentRequest request) throws Exception {
return idempotentPayments.create(key, request);
}Detail yang penting
- Kegagalan. Kalau pembayaran gagal karena validasi atau aturan bisnis, simpan juga respons
tersebut. Mengulang
422itu benar. Untuk crash yang tidak terduga, hapus barisIN_PROGRESSsupaya client bisa retry dengan aman. - Panggilan ke payment provider. Teruskan key yang sama (atau turunannya) ke provider kalau provider mendukung idempotency. Dengan begitu, crash di antara "sudah ditagih" dan "sudah dicatat" pun tidak menghasilkan tagihan ganda.
- Masa berlaku. Key tidak perlu disimpan selamanya. Job terjadwal yang menghapus baris berumur lebih dari sehari atau seminggu menjaga tabel tetap kecil.
- Cakupan. Buat key unik per client atau per pengguna, misalnya dengan menyimpan
user_iddi primary key, supaya seorang pengguna tidak pernah bisa memutar ulang respons milik pengguna lain.
Di sisi client
Buat key ketika pengguna menekan Bayar, bukan per percobaan HTTP, dan simpan sampai mendapat jawaban final. Kalau aplikasi ditutup di tengah request, menyimpan key bersama pembayaran yang tertunda memungkinkan aplikasi mencoba lagi dengan key yang sama saat dibuka berikutnya, alih-alih membuat tagihan baru.
Ringkasan
- Retry itu normal; rancang endpoint penulisan data untuk menghadapinya.
- Header
Idempotency-Keyditambah unique constraint mencegah duplikasi, bahkan lintas instance. - Key sama dan body sama: putar ulang respons. Key sama, body berbeda: tolak.
- Buat key sekali per aksi pengguna, bukan sekali per request.