Panduan · Terbit
Integrasi payment gateway di Python dan Django: empat hal yang menolak pengiriman webhook sebelum view-nya sempat jalan, dan satu nasihat lama yang ternyata keliru
Panduan ini memasang Kasera Pay di aplikasi Django, tanpa SDK dan tanpa package tambahan apa pun di luar Django sendiri. Yang dipakai hanya urllib, hmac, hashlib, dan json dari pustaka standar. Tidak ada SDK Python resmi Kasera Pay, dan tidak ada yang perlu dipasang lewat pip untuk mengikuti panduan ini.
Integrasi webhook di Django gagal dengan pola yang berbeda dari framework lain. Di Express penyebabnya hampir selalu urutan middleware; di Django, empat penolakan yang paling sering terjadi berlangsung di lapisan framework sebelum view dipanggil sama sekali, sehingga tidak ada satu pun baris kode webhook yang pernah jalan dan log aplikasinya kosong. Keempatnya diukur pada Django 5.2.17 untuk panduan ini dan ada di bagian 6. Rincian pengiriman beserta tanda tangannya ada di referensi webhook.
Kalau aplikasinya bukan Django, kontraknya sama persis dan yang berbeda hanya perkakasnya: integrasi payment gateway di Node.js dan Express, integrasi payment gateway di Laravel, dan integrasi payment gateway di Next.js App Router mengerjakan hal yang sama.
Alurnya, sebelum menulis kode
Ada empat hal yang bergerak, dan urutannya menentukan apa yang boleh dipercaya. Server membuat permintaan pembayaran. Pembeli dibawa ke checkout_url. Pembeli membayar di sana. Lalu Kasera Pay mengirim payment.paid bertanda tangan ke view webhook, dan hanya event itulah yang menjadi penentu bahwa uangnya masuk.
Kepulangan pembeli ke return_url bukan bukti pembayaran, karena halaman itu bisa dibuka siapa saja termasuk pembeli yang menutup halaman pembayaran tanpa membayar. Penuhi pesanan pada webhook. Kalau webhook bukan pilihan yang tepat untuk aplikasi ini, pertimbangannya dibandingkan utuh di perbandingan menunggu webhook melawan menanyakan status berkala.
1. Kredensial dan versi
API key dibawa sebagai bearer token dan berawalan kp_test_ selama membangun, kp_live_ setelah go-live. Signing secret webhook berbeda per mode dan diambil dari dashboard, menu Developer. Mode live dan mode tes adalah endpoint terpisah dengan secret masing-masing, dan secret tes tidak pernah bisa memverifikasi payload live.
# .env: kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
KASERA_PAY_BASE_URL=https://pay.kasera.idContoh di bawah ditulis untuk Django 5 pada Python 3.11, dan seluruh pengukuran yang disebut di panduan ini dijalankan pada Django 5.2.17. Tidak ada bagian yang bergantung pada versi Django tertentu kecuali satu, yaitu perilaku request.POST di bagian 3, yang justru penting karena banyak nasihat lama menyatakan sebaliknya.
2. Klien kecil yang membungkus dua endpoint
Satu berkas untuk dua endpoint yang benar-benar dipakai. Tidak ada HTTP client yang perlu dipasang, karena urllib sudah menjadi bagian dari Python.
# pembayaran/kasera_pay.py
# Hanya pustaka standar. urllib sudah ada di setiap pemasangan Python, jadi
# tidak ada package yang perlu dipasang untuk mengikuti panduan ini. Kalau
# requests sudah terpasang di proyeknya, requests.post dengan argumen yang
# sama bekerja persis sama.
import json
import os
import urllib.error
import urllib.request
BASE_URL = os.environ.get("KASERA_PAY_BASE_URL", "https://pay.kasera.id")
class KaseraPayError(Exception):
def __init__(self, status, code, message):
super().__init__(f"Kasera Pay {status} {code}")
self.status = status
self.code = code
self.message = message
def _request(method, path, payload=None, idempotency_key=None):
headers = {
"Authorization": "Bearer " + os.environ["KASERA_PAY_KEY"],
"Content-Type": "application/json",
}
if idempotency_key:
# Opsional di API, dan satu-satunya hal yang mencegah satu pesanan
# menjadi dua pembayaran. Dibuat sekali, lalu dipakai apa adanya
# pada setiap percobaan ulang.
headers["Idempotency-Key"] = idempotency_key
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE_URL + path, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=20) as res:
return json.loads(res.read().decode())
except urllib.error.HTTPError as e:
# Badan galat tetap JSON, dan kodenya yang menentukan tindakan.
detail = json.loads(e.read().decode() or "{}").get("error", {})
raise KaseraPayError(e.code, detail.get("code", "unknown"), detail.get("message", "")) from e
def create_transaction(payload, idempotency_key):
return _request("POST", "/v1/transactions", payload, idempotency_key)
def get_transaction(transaction_id):
return _request("GET", "/v1/transactions/" + transaction_id)Hanya Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. Header itu opsional, dan tanpa header itu setiap percobaan menjadi permintaan pembayaran tersendiri. external_id dan merchant_ref hanya label yang disimpan, dikembalikan, dan bisa difilter; keduanya tidak pernah menyatakan bahwa dua permintaan adalah satu pembayaran. Latar belakangnya ada di idempotency untuk pembayaran.
Kunci idempotensi yang benar disimpan pada pesanannya, bukan dibuat ulang setiap kali fungsi pembuat dipanggil. Membuat kunci baru pada percobaan kedua membatalkan seluruh gunanya, dan bentuk itu tetap terlihat benar di kode.
# pembayaran/views.py
import uuid
from django.shortcuts import get_object_or_404, redirect
from .kasera_pay import create_transaction
from .models import Pesanan
def bayar(request, pesanan_id):
pesanan = get_object_or_404(Pesanan, pk=pesanan_id)
# Kunci idempotensi disimpan PADA pesanannya, bukan dibuat ulang tiap
# kali fungsi ini dipanggil. Kunci baru pada percobaan kedua adalah
# persis cara sebuah pesanan menjadi dua permintaan pembayaran.
if not pesanan.idempotency_key:
pesanan.idempotency_key = str(uuid.uuid4())
pesanan.save(update_fields=["idempotency_key"])
hasil = create_transaction(
{
"amount": pesanan.total, # rupiah utuh, bukan sen
"description": pesanan.judul,
"external_id": pesanan.nomor, # label, bukan kunci
"payment_methods": ["qris", "va_bca"],
"return_url": f"https://toko.example.com/pesanan/{pesanan.nomor}",
},
pesanan.idempotency_key,
)
pesanan.transaction_id = hasil["id"]
pesanan.expires_at = hasil["expires_at"]
pesanan.save(update_fields=["transaction_id", "expires_at"])
return redirect(hasil["checkout_url"])3. Body mentah, dan nasihat lama yang keliru
Tanda tangan dihitung atas byte yang benar-benar dikirim, jadi verifikasinya harus memakai byte yang sama persis. Di Django byte itu ada di request.body, yang bertipe bytes dan bukan str.
Nasihat yang banyak beredar untuk view webhook Django berbunyi bahwa request.POST merusak request.body, sehingga body wajib dibaca lebih dulu. Untuk panduan ini nasihat itu diuji, bukan diteruskan, dan hasilnya tidak mendukungnya.
# Diukur pada Django 5.2.17, Python 3.11, payload 37 byte.
request.POST lalu request.body -> 200, body utuh 37 byte
request.body lalu request.POST -> 200, body utuh 37 byte
request.read(10) lalu request.body -> RawPostDataException
for chunk in request: lalu body -> RawPostDataException
# Menyusun ulang payload yang sudah di-parse:
asli 37 byte b'{"id":"evt_1", "type":"payment.paid"}'
disusun 39 byte b'{"id": "evt_1", "type": "payment.paid"}'
hmac(asli) f90ed4ccc60dfdc9181427fb56dc4b85c672adbdd51757a77ab70261fdab1764
hmac(disusun) 219e35d7f09ebc7921ed35b9961df75088219934f333b57bbecd40a46dff7f86Menyentuh request.POST lebih dulu tetap menyisakan request.body utuh, baik pada Content-Type JSON maupun form, karena pemuat form Django sendiri mengambil jalur request.body yang menyimpan hasilnya. Yang benar-benar menghabiskan body adalah membaca stream-nya langsung, lewat request.read() atau dengan mengiterasi request. Sesudah itu request.body melempar RawPostDataException berbunyi bahwa body tidak bisa diakses setelah stream dibaca.
Jadi aturannya bukan membaca body lebih dulu, melainkan tidak membaca stream-nya sama sekali, di view webhook maupun di middleware mana pun yang berjalan sebelumnya. Middleware buatan sendiri yang melakukan pencatatan permintaan adalah tempat paling umum aturan itu dilanggar tanpa disadari.
Sisi kedua dari hal yang sama: payload yang sudah di-parse tidak boleh disusun ulang lalu ditandatangani. Pada contoh terukur di atas, payload 37 byte berubah menjadi 39 byte setelah melewati json.loads dan json.dumps, karena spasi sesudah koma tidak kembali sebagaimana aslinya, dan tanda tangannya berbeda seluruhnya. Yang terlihat oleh developer adalah semua pengiriman yang sah ditolak dengan alasan tanda tangan tidak valid, padahal secret-nya benar dan payload di log terlihat masuk akal.
4. Verifikasi tanda tangan
Setiap pengiriman membawa header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t, satu titik, lalu body mentah. Pengiriman yang timestamp-nya melenceng lebih dari lima menit ditolak, dan itulah yang mencegah pengiriman lama yang tersadap diputar ulang. Sesudah rotasi secret, header membawa dua entri v1 selama 24 jam, satu per secret, jadi pengiriman diterima kalau salah satu entri cocok.
# pembayaran/webhook.py
import hashlib
import hmac
import json
import os
import time
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import Pesanan, WebhookEvent
TOLERANCE_SECONDS = 300
def _verify(raw_body: bytes, header: str, secret: str) -> bool:
"""Kasera-Signature-V1: t=<unix>,v1=<hex>[,v1=<hex>]"""
parts = header.split(",")
if not parts[0].startswith("t="):
return False
try:
t = int(parts[0][2:])
except ValueError:
return False
# Inilah yang mencegah pengiriman lama yang tersadap diputar ulang.
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, bukan ==, supaya lama pembandingan tidak membocorkan
# berapa karakter pertama yang sudah benar. Kedua argumennya WAJIB
# bertipe sama; str melawan bytes melempar TypeError, bukan False.
return any(
hmac.compare_digest(p[3:], expected) for p in parts[1:] if p.startswith("v1=")
)
@csrf_exempt # tanpa baris ini, setiap pengiriman dijawab 403
@require_POST
def kasera_pay_webhook(request):
# request.body adalah bytes, dan byte inilah yang ditandatangani.
# Jangan pernah menandatangani hasil json.dumps dari objek yang sudah
# di-parse: isinya sama, byte-nya tidak.
raw = request.body
header = request.headers.get("Kasera-Signature-V1", "")
secret = os.environ["KASERA_PAY_WEBHOOK_SECRET"]
if not header or not _verify(raw, header, secret):
return HttpResponseBadRequest("invalid signature")
event = json.loads(raw.decode())
# Dedupe sebelum mengerjakan apa pun. Pengiriman bersifat at-least-once:
# event yang sama bisa datang lebih dari sekali dengan id yang sama.
with transaction.atomic():
_, baru = WebhookEvent.objects.get_or_create(event_id=event["id"])
if not baru:
return HttpResponse(status=200) # sudah pernah dikerjakan
if event["type"] == "payment.paid":
data = event["data"]
# payment_request_id, BUKAN id. "id" di tingkat atas adalah id
# event-nya, dan keduanya sama-sama ada di payload yang sama.
Pesanan.objects.filter(
transaction_id=data["payment_request_id"], status="menunggu"
).update(
status="lunas",
dibayar_pada=data["paid_at"],
nama_pembeli=data.get("customer", {}).get("name", ""),
)
return HttpResponse(status=200)Dua hal di badan event yang layak diperiksa sebelum menulis kode pemrosesnya. Pertama, id permintaan pembayaran ada di data.payment_request_id, sementara id di tingkat atas adalah id event-nya; keduanya ada di payload yang sama dan tertukar dengan mudah. Kedua, payment.paid membawa amount tetapi tidak membawa biaya maupun jumlah bersihnya, dan tidak juga metode yang akhirnya dipakai. Integrasi yang hanya mendengarkan webhook tidak akan pernah tahu berapa yang benar-benar diterima; angka itu diambil dengan membaca ulang GET /v1/transactions/{id} dari server.
Satu detail Python yang menjerat lebih sering daripada kriptografinya sendiri: hmac.compare_digest mensyaratkan kedua argumennya bertipe sama. Membandingkan str dengan bytes melempar TypeError berbunyi bahwa objek bertipe bytes yang dibutuhkan, bukan str. Galat itu bukan False, jadi kalau tidak ditangkap ia menjadi 500 dan pengirimnya mengulang pengiriman yang sebenarnya sah. Bentuk teraman adalah menyamakan keduanya sebagai str, seperti pada contoh, karena hexdigest() memang mengembalikan str.
compare_digest dipakai menggantikan == supaya lama pembandingan tidak bergantung pada berapa karakter pertama yang sudah benar.
5. Dedupe id event
Pengiriman bersifat at-least-once: event yang sama bisa terkirim lebih dari sekali dan selalu membawa id yang sama. Pesanan yang terpenuhi dua kali adalah akibat yang paling mahal dari kenyataan itu, dan penutupnya bukan pemeriksaan di dalam kode melainkan unique constraint di basis data.
# pembayaran/models.py
# Pesanan adalah model pesanan milik aplikasi sendiri dan bentuknya bebas;
# yang dipakai panduan ini hanya kolom transaction_id, idempotency_key,
# expires_at, dan status. Satu-satunya model yang benar-benar ditentukan
# integrasinya adalah yang di bawah.
from django.db import models
class WebhookEvent(models.Model):
"""Satu baris per id event yang pernah diterima.
unique=True di sini yang menutup pengiriman ganda, bukan pemeriksaan
di dalam kode. Pemeriksaan membaca dulu lalu menulis, dan dua
pengiriman yang datang bersamaan sama-sama membaca kosong.
"""
event_id = models.CharField(max_length=64, unique=True)
received_at = models.DateTimeField(auto_now_add=True)Pemeriksaan biasa membaca dulu lalu menulis, dan dua pengiriman yang tiba bersamaan sama-sama membaca kosong lalu sama-sama mengerjakan pesanannya. unique=True memindahkan keputusannya ke basis data, yang hanya bisa menerima satu: percobaan kedua menghasilkan IntegrityError dengan pesan bahwa unique constraint gagal, yang pada contoh di bagian 4 sudah diserap oleh get_or_create menjadi penanda bahwa barisnya tidak baru.
Perhatikan bahwa jawaban untuk event yang sudah pernah dikerjakan tetap 200, bukan galat. Pengiriman ulang adalah perilaku yang wajar dan sudah dijanjikan, bukan kegagalan.
6. Empat penolakan yang terjadi sebelum view dipanggil
Inilah bagian yang membedakan Django dari framework lain. Keempat keadaan di bawah ini menghasilkan jawaban yang bukan 2xx tanpa satu pun baris kode webhook pernah jalan, sehingga log aplikasinya kosong dan penyebabnya tidak terlihat dari sisi mana pun yang biasa diperiksa. Semuanya diukur pada Django 5.2.17.
# Diukur pada Django 5.2.17. Tidak satu pun sampai ke view.
tanpa @csrf_exempt -> 403
POST ke /webhooks/kasera-pay
saat urls.py menulis .../kasera-pay/ -> 301 ke path bergaris miring
Host di luar ALLOWED_HOSTS -> 400
dengan @csrf_exempt, path tepat -> 200CSRF. Django memeriksa token CSRF pada setiap POST, dan pengirim webhook tentu tidak membawanya. Tanpa @csrf_exempt, view webhook dijawab 403 oleh CsrfViewMiddleware. Dekorator itu aman di sini justru karena tanda tangan HMAC yang menggantikan perannya: yang membuktikan pengiriman sah bukan token dari sesi, melainkan secret yang hanya dipegang dua pihak.
Garis miring di akhir. APPEND_SLASH menyala secara bawaan. Kalau urls.py mendaftarkan webhooks/kasera-pay/ sementara URL di dashboard ditulis tanpa garis miring, CommonMiddleware menjawab 301 ke alamat bergaris miring dan view tidak pernah dipanggil. Samakan persis salah satunya.
# pembayaran/urls.py
from django.urls import path
from . import views, webhook
urlpatterns = [
path("pesanan/<int:pesanan_id>/bayar", views.bayar),
# TANPA garis miring di akhir, dan URL yang didaftarkan di dashboard
# harus sama persis dengan baris ini. Lihat bagian 6.
path("webhooks/kasera-pay", webhook.kasera_pay_webhook),
]ALLOWED_HOSTS. Host yang tidak terdaftar dijawab 400 sebelum routing berjalan. Ini penyebab yang paling sering muncul tepat pada penerapan pertama ke server sungguhan, karena selama membangun host-nya localhost dan lolos sendiri saat DEBUG menyala.
Stream yang sudah terbaca. Middleware pencatat permintaan yang membaca request.read() membuat request.body di view melempar RawPostDataException, yang tanpa penanganan menjadi 500.
Yang menyatukan keempatnya: tidak satu pun menghasilkan 2xx, dan pengirimnya memperlakukan semuanya sebagai kegagalan. Percobaan ulang berjalan dengan exponential backoff sampai maksimal 7 percobaan dalam sekitar 33 jam, lalu event itu tidak datang lagi. Sebuah 403 yang tidak diperhatikan selama dua hari berarti pembayaran yang benar-benar masuk tetapi tidak pernah tercatat di sisi aplikasi.
7. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, jadi localhost tidak bisa didaftarkan langsung dan selama membangun perlu terowongan yang menerbitkannya ke alamat publik. Satu mode boleh memegang sampai lima endpoint, masing-masing dengan URL, nama, dan signing secret sendiri. Menonaktifkan sebuah endpoint menahan event-nya tanpa membakar percobaan ulang.
Empat hal yang layak dicoba di mode tes. Pertama, pengiriman yang sah harus diterima; kalau seluruhnya ditolak, periksa bagian 6 lebih dulu dan bukan kode tanda tangannya, karena penolakan di lapisan Django jauh lebih sering menjadi penyebabnya. Kedua, event yang sama dikirim dua kali harus menghasilkan satu pesanan lunas dan dua jawaban 200. Ketiga, tanda tangan yang sengaja dirusak harus ditolak, dan pastikan penolakannya berbentuk 400 dan bukan TypeError yang menjadi 500. Keempat, kegagalan basis data di tengah harus menghasilkan 500 supaya pengirimannya diulang, bukan 200 yang menghapus kesempatan itu.
Perlu diketahui juga bahwa masa berlaku permintaan pembayaran default 60 menit dengan plafon akun 24 jam, dan kedaluwarsa tidak pernah dikirim sebagai webhook. Hanya dua jenis event yang ada, payment.paid dan test.ping dari dashboard, jadi pelepasan stok dijalankan dari expires_at yang disimpan sendiri. Urutan pemeriksaan sebelum menyalakan mode live ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Apakah benar request.POST merusak request.body di Django?
Tidak, dan ini nasihat lama yang banyak beredar untuk view webhook. Diukur pada Django 5.2.17 dengan payload 37 byte: menyentuh request.POST lebih dulu, baik pada Content-Type application/json maupun form-urlencoded, tetap menyisakan request.body utuh 37 byte. Penyebabnya, pemuat form Django sendiri mengambil jalur request.body yang menyimpan hasilnya, jadi byte aslinya tidak hilang. Yang benar-benar merusaknya adalah membaca STREAM-nya langsung, misalnya request.read() atau mengiterasi request; setelah itu request.body melempar RawPostDataException dengan pesan bahwa body tidak bisa diakses setelah stream dibaca. Jadi aturan yang benar bukan membaca body lebih dulu, melainkan tidak membaca stream-nya sama sekali di view webhook dan di middleware mana pun yang berjalan sebelumnya.
Kenapa seluruh pengiriman webhook dijawab 403 padahal kodenya belum sempat jalan?
Karena CsrfViewMiddleware menolaknya sebelum view dipanggil. Django memeriksa token CSRF pada setiap POST, dan pengirim webhook tentu tidak punya token itu. Diukur pada Django 5.2.17: view tanpa @csrf_exempt menjawab 403, view yang sama dengan @csrf_exempt menjawab 200. Yang perlu diperhatikan, 403 bukan 2xx, jadi pengirimnya menganggap pengiriman gagal dan mengulanginya sampai jatah percobaan habis, yaitu maksimal 7 percobaan dalam sekitar 33 jam. Setelah itu event tersebut tidak datang lagi.
Apakah garis miring di akhir URL webhook benar-benar berpengaruh?
Berpengaruh, dan ini jebakan khas Django karena APPEND_SLASH menyala secara bawaan. Kalau urls.py mendaftarkan path bergaris miring sementara URL di dashboard ditulis tanpa garis miring, CommonMiddleware menjawab 301 ke alamat bergaris miring, bukan meneruskannya ke view. Diukur pada Django 5.2.17: status 301 dengan header Location, view tidak pernah dipanggil. Sebuah 301 bukan 2xx, jadi hasilnya sama dengan kegagalan dan jatah percobaan ulang tetap terbakar. Samakan persis salah satunya, dan paling aman menulis path webhook tanpa garis miring lalu mendaftarkan alamat yang sama di dashboard.
Kenapa hmac.compare_digest melempar TypeError, bukan mengembalikan False?
Karena kedua argumennya harus bertipe sama. Membandingkan str dengan bytes menghasilkan TypeError dengan pesan bahwa objek bertipe bytes yang dibutuhkan, bukan str. Ini sering terjadi karena hexdigest() mengembalikan str sementara potongan header yang diambil dari request bisa saja sudah berupa bytes, atau sebaliknya karena digest() dipakai menggantikan hexdigest(). Samakan keduanya sebagai str seperti pada contoh di panduan ini, dan bungkus pemanggilannya supaya galat tipe tidak berubah menjadi 500 yang membuat pengiriman diulang tanpa guna.
Apakah Kasera Pay punya SDK Python atau package PyPI resmi?
Tidak ada, dan tidak diperlukan. Seluruh integrasi di panduan ini memakai urllib, hmac, hashlib, dan json dari pustaka standar Python, ditambah Django sendiri. Tidak ada package yang perlu dipasang, dan tidak ada rilis package yang perlu ditunggu setiap kali API bertambah. Kalau requests sudah terpasang di proyeknya, pemanggilannya bisa diganti tanpa mengubah apa pun yang lain.
Apakah kepulangan pembeli ke return_url boleh dipakai sebagai tanda lunas?
Tidak. Halaman itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar. Penanda lunas hanya event payment.paid yang bertanda tangan, atau pembacaan langsung ke GET /v1/transactions/{id} dari server. View kepulangan sebaiknya membaca ulang status dari server, bukan menyimpulkannya dari fakta bahwa halamannya terbuka.
Kenapa dedupe dilakukan lewat unique constraint dan bukan pemeriksaan biasa?
Karena pemeriksaan biasa membaca dulu lalu menulis, dan dua pengiriman event yang sama bisa tiba bersamaan sehingga keduanya membaca kosong lalu keduanya mengerjakan pesanannya. unique=True pada kolom event_id memindahkan keputusannya ke basis data, yang hanya bisa menerima satu. get_or_create memanfaatkannya dan mengembalikan penanda apakah barisnya baru dibuat; kalau tidak baru, event itu sudah pernah dikerjakan dan jawabannya cukup 200 tanpa kerja tambahan. Menulis langsung tanpa get_or_create juga sah, asal IntegrityError ditangkap dan diperlakukan sebagai pengiriman ulang yang wajar, bukan sebagai kegagalan.