Automasi developer di ekosistem GitHub makin mendorong tim untuk menghubungkan repository, pipeline build, deployment, dan status check ke banyak sistem internal. Pola ini terlihat jelas dari banyaknya proyek automasi dan workflow tooling yang muncul di komunitas GitHub, termasuk semangat eksperimen yang sering tampak pada peserta dan pemenang challenge komunitas seperti Finish-Up-A-Thon. Namun, begitu integrasi CI/CD mulai saling memanggil API, masalah klasik segera muncul: request ganda.

Jika endpoint trigger build atau deployment tidak dirancang dengan baik, satu retry karena timeout bisa memicu dua build, dua deployment, atau dua perubahan status. Solusinya bukan sekadar “jangan retry”, melainkan mendesain retry yang aman dengan idempotency key, deduplikasi di sisi server, kontrak status yang konsisten, dan observability yang memadai. Artikel ini fokus pada implementasi praktis untuk API integrasi CI/CD, bukan teori umum.

Mengapa API CI/CD rentan terhadap request ganda

Dalam integrasi CI/CD, duplikasi biasanya bukan bug tunggal, melainkan konsekuensi dari sistem terdistribusi:

  • Client timeout: client tidak menerima response tepat waktu lalu mengirim ulang request, padahal server mungkin sudah memprosesnya.
  • Retry otomatis: HTTP client, job runner, queue worker, atau gateway dapat melakukan retry.
  • Race condition: dua actor berbeda memicu operasi yang sama hampir bersamaan, misalnya webhook dan manual retry dari UI.
  • Webhook redelivery: platform seperti GitHub dapat mengirim ulang event jika endpoint sebelumnya gagal atau tidak merespons sesuai harapan.
  • Kegagalan parsial: record database sudah tersimpan, tetapi enqueue job gagal; atau job berhasil dibuat, tetapi response ke client terputus.

Pada konteks CI/CD, efeknya lebih berbahaya daripada sekadar data dobel. Duplikasi bisa berarti image dibangun dua kali, environment ter-deploy ulang tanpa perlu, rollback terpicu keliru, atau status commit menjadi tidak konsisten.

Apa itu idempotency key dan kapan harus dipakai

Idempotency key adalah identifier unik yang dikirim client untuk menyatakan bahwa beberapa request identik seharusnya dianggap sebagai operasi logis yang sama. Jika request yang sama dikirim ulang dengan key yang sama, server tidak membuat operasi baru, tetapi mengembalikan hasil dari operasi pertama atau status operasi yang sedang berjalan.

Idempotency key sangat cocok untuk endpoint seperti:

  • POST /builds untuk memicu build
  • POST /deployments untuk memulai deployment
  • POST /status-sync untuk sinkronisasi status dari sistem eksternal
  • POST /webhook/github jika Anda perlu melindungi downstream processing dari redelivery

Secara umum, idempotency key paling penting pada operasi yang:

  • mengubah state,
  • punya efek samping ke sistem lain,
  • mahal untuk diulang, atau
  • tidak aman jika dieksekusi lebih dari sekali.

Catatan: Idempotency key bukan pengganti validasi bisnis. Jika dua request berbeda kebetulan punya payload mirip, Anda tetap perlu aturan bisnis yang jelas untuk menentukan apakah keduanya memang operasi yang sama atau tidak.

Desain kontrak API: request, response, dan perilaku retry

1. Tentukan sumber idempotency key

Paling umum, client mengirim header seperti Idempotency-Key. Anda juga bisa menerima key di body, tetapi header lebih jelas untuk concern transport-level.

POST /deployments HTTP/1.1
Content-Type: application/json
Idempotency-Key: dep-20250706-8f3c2a

{
  "repository": "acme/payment-service",
  "commit_sha": "abc123def456",
  "environment": "staging",
  "triggered_by": "github-actions"
}

Key sebaiknya:

  • unik per operasi logis,
  • cukup sulit bertabrakan,
  • stabil di semua retry untuk request yang sama.

Praktiknya, key sering dibentuk dari UUID. Untuk webhook, jika platform pengirim memiliki event ID yang unik, gunakan itu sebagai bahan utama deduplikasi.

2. Simpan fingerprint request

Server sebaiknya tidak hanya menyimpan key, tetapi juga fingerprint payload, misalnya hash dari field-field penting. Tujuannya untuk mendeteksi kasus berbahaya: key sama, payload berbeda.

Contoh field penting untuk trigger deployment:

  • repository
  • commit SHA
  • environment
  • workflow atau pipeline identifier

Jika request datang dengan idempotency key yang sama tetapi fingerprint berbeda, server sebaiknya menolak dengan error yang jelas karena client menggunakan key secara tidak konsisten.

3. Gunakan response yang dapat diulang dengan aman

Untuk operasi async seperti build dan deployment, pola yang paling aman adalah:

  • request pertama membuat operasi dan mengembalikan representasi job,
  • retry dengan key yang sama mengembalikan representasi job yang sama, bukan membuat job baru.

Contoh response awal:

HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /deployments/dep_789

{
  "deployment_id": "dep_789",
  "status": "queued",
  "idempotency_key": "dep-20250706-8f3c2a"
}

Jika client retry setelah timeout dan operasi yang sama sudah tercatat, server dapat mengembalikan:

HTTP/1.1 200 OK
Content-Type: application/json
Location: /deployments/dep_789

{
  "deployment_id": "dep_789",
  "status": "queued",
  "idempotency_key": "dep-20250706-8f3c2a"
}

Atau tetap 202 Accepted jika prosesnya masih berjalan. Yang penting adalah tidak menciptakan operasi baru dan response-nya konsisten.

4. Pilih status code dengan sengaja

Tidak ada satu-satunya jawaban mutlak, tetapi pola berikut umumnya masuk akal:

  • 201 Created: operasi sinkron dan resource baru berhasil dibuat.
  • 202 Accepted: operasi diterima untuk diproses async, umum untuk build/deploy.
  • 200 OK: retry menemukan hasil yang sudah ada dan mengembalikan representasi yang sama.
  • 409 Conflict: idempotency key sama tetapi payload berbeda, atau state bisnis tidak mengizinkan duplikasi tertentu.
  • 422 Unprocessable Entity: payload valid secara format, tetapi tidak lolos aturan domain.
  • 500/502/503/504: kegagalan server atau upstream; client boleh retry jika aman dan terdokumentasi.

Kesalahan umum adalah memakai 409 Conflict untuk semua duplikasi. Jika duplicate request dengan key yang sama memang bagian dari kontrak idempotent, biasanya lebih berguna mengembalikan hasil operasi sebelumnya daripada menjawab conflict.

Implementasi deduplikasi di sisi server

Pola data minimal

Anda membutuhkan penyimpanan yang bisa diakses atomik, misalnya database relasional, key-value store, atau kombinasi keduanya. Record idempotency minimal biasanya berisi:

  • idempotency_key
  • request_fingerprint
  • operation_type seperti deployment.create
  • resource_id atau job ID hasil operasi
  • status misalnya processing, succeeded, failed
  • response_snapshot opsional, jika ingin mengembalikan response identik
  • expires_at untuk TTL
  • created_at dan updated_at

Hal pentingnya adalah ada unique constraint pada kombinasi yang relevan, biasanya:

(operation_type, idempotency_key)

Ini mencegah dua proses paralel sama-sama “merasa” bahwa key belum ada.

Pseudocode alur aman

function createDeployment(request):
    key = request.headers["Idempotency-Key"]
    if key is missing:
        return 400

    fingerprint = hash(normalize(request.body))

    begin transaction

    existing = find idempotency record by (operation_type="deployment.create", key)

    if existing exists:
        if existing.request_fingerprint != fingerprint:
            rollback
            return 409 with error "idempotency key reused with different payload"

        if existing.status in ["processing", "succeeded", "failed"]:
            rollback
            return replay(existing)

    insert idempotency record with status="processing"
        if unique constraint violation:
            rollback
            existing = re-read record
            return replay(existing)

    deployment = insert deployment row(status="queued")
    enqueue deployment job(deployment.id)

    update idempotency record:
        resource_id = deployment.id
        status = "succeeded"
        response_snapshot = {
            deployment_id: deployment.id,
            status: "queued"
        }

    commit

    return 202 with deployment info

Poin terpenting di atas:

  • cek dan insert harus tahan race condition,
  • penulisan idempotency record dan pembuatan resource idealnya berada dalam satu boundary konsisten,
  • jika enqueue job tidak transactional terhadap database, Anda perlu strategi tambahan seperti outbox pattern.

Kapan memakai database, kapan memakai Redis

Database relasional cocok jika Anda butuh konsistensi kuat, unique constraint, dan keterkaitan dengan resource utama seperti deployment record.

Redis cocok untuk deduplikasi cepat dan TTL sederhana, terutama sebagai lapisan tambahan untuk webhook redelivery ber-volume tinggi. Namun, untuk operasi penting seperti deployment, hanya mengandalkan cache sementara sering tidak cukup karena data dedupe bisa hilang saat eviksi atau restart jika konfigurasi persistence tidak sesuai.

Pilihan praktis yang sering masuk akal:

  • database sebagai source of truth untuk idempotency operasi bisnis,
  • Redis opsional untuk throttle atau short-term dedupe di depan.

TTL penyimpanan idempotency key: berapa lama?

Tidak ada angka tunggal yang selalu benar. TTL harus mengikuti jendela retry realistis dan risiko bisnis duplikasi.

Pertimbangan utamanya:

  • berapa lama client, worker, atau gateway dapat melakukan retry,
  • berapa lama webhook redelivery mungkin terjadi,
  • berapa mahal jika operasi sama terulang setelah beberapa jam atau hari,
  • apakah user mungkin menekan tombol ulang secara manual dengan konteks yang sama.

Untuk trigger build/deploy, TTL sering dipilih cukup panjang agar aman melewati timeout, antrean, dan redelivery. Namun TTL terlalu lama juga punya trade-off:

  • penyimpanan membesar,
  • key lama bisa memblokir operasi baru jika client salah mendaur ulang key,
  • debugging lebih rumit bila kebijakan retensi tidak jelas.

Pendekatan yang aman adalah mendokumentasikan TTL secara eksplisit dan memisahkan antara:

  • TTL dedupe: berapa lama key dianggap operasi yang sama,
  • retensi audit: berapa lama metadata disimpan untuk investigasi.

Praktik baik: jika TTL habis, request dengan key lama dapat diperlakukan sebagai operasi baru. Karena itu, client tidak boleh mendaur ulang idempotency key untuk aksi berbeda.

Menangani partial success dan kegagalan ambigu

Kasus tersulit bukan duplicate request biasa, melainkan partial success: sebagian proses berhasil, sebagian lagi gagal atau tidak pasti.

Skenario umum

  1. Record deployment berhasil dibuat, tetapi response ke client gagal terkirim.
  2. Idempotency record tersimpan, tetapi enqueue job ke broker gagal.
  3. Job sebenarnya sudah jalan di worker, tetapi status akhir belum tercatat karena database timeout.
  4. Webhook event sudah diproses ke downstream system, tetapi ack ke pengirim gagal sehingga event di-redeliver.

Cara merancang agar tetap aman

1. Simpan state proses secara eksplisit

Jangan hanya punya “ada/tidak ada” record idempotency. Simpan state seperti processing, succeeded, dan failed. Dengan begitu retry bisa memutuskan apakah harus mengembalikan hasil lama, menunggu, atau menjalankan recovery flow.

2. Pisahkan penerimaan request dari eksekusi efek samping

Untuk deployment async, endpoint sebaiknya hanya mencatat intent dan membuat job. Eksekusi ke cluster, cloud API, atau orchestrator dilakukan worker terpisah. Ini memudahkan retry karena unit idempotent-nya adalah intent atau job creation, bukan seluruh deployment langsung.

3. Gunakan outbox pattern bila ada message broker

Jika Anda menulis ke database lalu mengirim pesan ke queue/broker, ada risiko satu berhasil dan satu gagal. Outbox pattern membantu memastikan event untuk worker diterbitkan dari data yang sudah committed, bukan dikirim secara rentan di tengah transaksi.

4. Sediakan endpoint cek status

Alih-alih memaksa client menebak apakah retry aman, berikan endpoint seperti GET /deployments/{id} atau pencarian berdasarkan idempotency key. Ini penting saat response awal hilang di jaringan.

Observability: tanpa ini, idempotency sulit dioperasikan

Idempotency bukan hanya fitur logika, tetapi juga fitur operasional. Anda perlu bisa menjawab pertanyaan seperti:

  • berapa banyak request yang berhasil dideduplicate,
  • berapa banyak key reuse dengan payload berbeda,
  • berapa lama operasi dengan status processing tertahan,
  • apakah ada lonjakan redelivery dari webhook source tertentu.

Minimum yang perlu dicatat

  • idempotency key di log terstruktur
  • correlation/request ID
  • operation type
  • resource ID hasil operasi
  • dedupe outcome: new, replayed, conflict, expired
  • latency dan status code

Metric yang berguna

  • jumlah request per endpoint
  • rasio replay terhadap request baru
  • jumlah conflict karena fingerprint mismatch
  • jumlah key dengan status processing terlalu lama
  • jumlah enqueue failure setelah record idempotency dibuat

Jika memakai tracing terdistribusi, masukkan idempotency key sebagai atribut span secara hati-hati. Jangan masukkan payload sensitif langsung ke log atau trace.

Contoh alur end-to-end untuk trigger deployment

Alur normal

  1. GitHub Actions atau sistem internal memanggil POST /deployments dengan Idempotency-Key.
  2. API memvalidasi payload dan menghitung fingerprint.
  3. API membuat record idempotency status processing.
  4. API membuat deployment record status queued.
  5. API menyimpan response snapshot dan mengembalikan 202 Accepted.
  6. Worker mengambil job dan menjalankan deployment.
  7. Status deployment diperbarui menjadi running, lalu succeeded atau failed.

Alur saat timeout dan retry

  1. Client mengirim request, tetapi koneksi putus sebelum menerima response.
  2. Client retry dengan key yang sama.
  3. API menemukan record idempotency yang sudah ada.
  4. API mengembalikan deployment yang sama, bukan membuat deployment kedua.

Alur webhook redelivery

  1. GitHub mengirim event status atau deployment callback.
  2. Endpoint menerima event ID unik dari header atau body event.
  3. Sistem menyimpan event ID sebagai dedupe key untuk pemrosesan downstream.
  4. Jika event yang sama dikirim ulang, API mengabaikan efek samping kedua dan cukup mengembalikan ack yang sesuai.

Contoh pseudocode webhook dedupe

function handleWebhook(event):
    deliveryId = event.headers["X-Delivery-ID"] or event.body.event_id
    fingerprint = hash(raw_event_body)

    if not acquireOrRead(deliveryId, fingerprint):
        return 409

    if alreadyProcessed(deliveryId):
        return 200

    processEventSafely(event)
    markProcessed(deliveryId)
    return 200

Untuk webhook, strategi dedupe sering lebih sederhana daripada trigger deployment karena tujuan utamanya adalah mencegah pemrosesan event yang sama berulang. Namun jika event men-trigger operasi bisnis besar, tetap gunakan model status yang eksplisit.

Kesalahan umum dalam implementasi idempotency key

  • Hanya memeriksa key di memory process. Ini gagal pada multi-instance deployment.
  • Tidak menyimpan fingerprint request. Akibatnya key sama dengan payload berbeda bisa diam-diam diterima.
  • Menganggap idempotency sama dengan “ignore duplicate”. Padahal client sering butuh response hasil operasi pertama.
  • Tidak ada unique constraint atau operasi atomik. Race condition tetap bisa membuat duplikasi.
  • TTL terlalu pendek. Retry sah dari client datang setelah key kedaluwarsa dan operasi terulang.
  • Menandai sukses terlalu dini. Misalnya sebelum resource benar-benar tercipta atau job benar-benar tercatat.
  • Tidak membedakan operasi sinkron dan async. Untuk CI/CD, banyak operasi lebih aman dimodelkan sebagai async job.

Checklist implementasi untuk API integrasi CI/CD yang lebih aman

  1. Tentukan endpoint mana yang wajib memakai idempotency key.
  2. Standarkan lokasi key, idealnya lewat header Idempotency-Key.
  3. Definisikan aturan pembentukan key di sisi client dan dokumentasikan bahwa key harus stabil saat retry.
  4. Simpan request fingerprint untuk mendeteksi key reuse dengan payload berbeda.
  5. Buat unique constraint pada kombinasi operation type dan idempotency key.
  6. Simpan status record idempotency: processing, succeeded, failed.
  7. Kembalikan response yang konsisten saat request di-retry.
  8. Pilih status code yang jelas: 202 untuk async, 200 untuk replay hasil lama, 409 untuk mismatch.
  9. Tetapkan TTL penyimpanan key berdasarkan jendela retry dan risiko bisnis.
  10. Sediakan endpoint lookup status untuk operasi async.
  11. Tambahkan log terstruktur, metric dedupe, dan alert untuk key yang macet di status processing.
  12. Uji race condition dan network failure, bukan hanya happy path.

Penutup

Dalam integrasi CI/CD, retry itu normal, timeout itu normal, dan webhook redelivery juga normal. Yang tidak normal adalah membiarkan semua itu membuat build, deployment, atau sinkronisasi status berjalan dua kali. Dengan desain API yang memanfaatkan idempotency key, deduplikasi atomik, kontrak response yang konsisten, dan observability yang baik, Anda bisa membuat retry menjadi aman tanpa mengorbankan keandalan automasi.

Jika Anda sedang membangun endpoint trigger build, deployment, atau status sync di ekosistem GitHub dan automasi developer, mulai dari satu prinsip ini: anggap duplicate request pasti akan terjadi. Dari sana, desain idempotency Anda akan jauh lebih realistis dan operasional.