Langsung ke konten
~/rifqi_abrory

Halaman

Beranda
Tentang
Stack
Pengalaman
Proyek
Tulisan
Kontak
Semua proyek

Proyek

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Artikel

Angular signals untuk halaman admin sehari-hari
Typed reactive forms di Angular, tanpa any
Error API yang konsisten di ASP.NET Core dengan ProblemDetails
Meneruskan CancellationToken sampai ke lapisan terbawah di ASP.NET Core
Graceful shutdown untuk service HTTP di Go
Idempotency key: membuat API pembayaran aman untuk di-retry
Form kontak yang tahan spam dengan Next.js Server Actions
Form yang ramah offline di React Native: draft dan antrean submit
Validasi request dan penanganan error di Spring Boot 3
Keyset pagination di SQL Server: ketika OFFSET mulai lambat

Aksi

Ganti ke mode gelap
Read in English
Salin alamat email
Semua artikel

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:

  1. Request pertama dengan sebuah key diproses seperti biasa.
  2. Pengulangan dengan key dan body yang sama mengembalikan respons yang tersimpan, tanpa menagih lagi.
  3. 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 422 itu benar. Untuk crash yang tidak terduga, hapus baris IN_PROGRESS supaya 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_id di 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-Key ditambah 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.