Pemanggilan instance.save() di dalam receiver sinyal post_save merupakan penyebab umum terjadinya RecursionError pada Django. Kesalahan konfigurasi ini memicu infinite loop yang menghabiskan limit call stack Python, berujung pada crash HTTP 500 dan kegagalan proses transaksi pada level database.

Gejala Runtime dan Traceback

Ketika endpoint dieksekusi, web server (Gunicorn, uWSGI, atau runserver) mendadak berhenti memproses request dan melempar exception berikut:

Internal Server Error: /api/orders/1/pay/
Traceback (most recent call last):
  File "django/core/handlers/exception.py", line 55, in inner
  ...
  File "myapp/signals.py", line 14, in update_order_code
    instance.save()
  File "django/db/models/base.py", line 814, in save
  ...
RecursionError: maximum recursion depth exceeded while calling a Python object

Gejala ini ditandai dengan lonjakan tajam CPU, pemakaian memori sementara akibat penumpukan frame fungsi di call stack, dan kegagalan eksekusi tanpa ada log database commit.

Root Cause: Siklus Dispatcher Signal post_save

Akar masalah RecursionError Django ini berasal dari lifecycle internal ORM Django. Alur kerja metode save() melibatkan pemanggilan dispatcher sinyal:

  1. Aplikasi memanggil order.save().
  2. Django mengeksekusi query INSERT atau UPDATE ke database.
  3. Django mengeksekusi post_save.send(sender=Order, instance=order, created=...).
  4. Receiver menangkap sinyal lalu memanggil instance.save() kembali.
  5. Tahap 1 berulang kembali tanpa terminasi hingga kedalaman rekursi Python (umumnya 1000 frame) terlewati.

Kode Bermasalah (Sebelum Perbaikan)

from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order

@receiver(post_save, sender=Order)
def generate_order_code(sender, instance, created, **kwargs):
    if created and not instance.order_code:
        instance.order_code = f"ORD-{instance.id:06d}"
        # BUG: Memanggil save() di sini akan memicu kembali sinyal post_save
        instance.save()

3 Strategi Perbaikan Teknis

1. Bypass ORM Signal Menggunakan QuerySet.update()

Pendekatan paling bersih adalah melakukan eksekusi SQL UPDATE langsung tanpa melalui lifecycle Model.save().

@receiver(post_save, sender=Order)
def generate_order_code(sender, instance, created, **kwargs):
    if created and not instance.order_code:
        new_code = f"ORD-{instance.id:06d}"
        Order.objects.filter(pk=instance.pk).update(order_code=new_code)
        instance.order_code = new_code

Analisis: Metode QuerySet.update() dieksekusi langsung pada level database SQL. Metode ini tidak mengeksekusi instance.save(), sehingga post_save tidak ditembakkan ulang.

2. Guard Clause Menggunakan update_fields

Jika logika bisnis mewajibkan eksekusi override save() pada model, gunakan parameter update_fields sebagai filter pembatas.

@receiver(post_save, sender=Order)
def generate_order_code(sender, instance, created, **kwargs):
    update_fields = kwargs.get("update_fields")
    
    # Hentikan rekursi jika pemanggilan save() hanya menargetkan order_code
    if update_fields and "order_code" in update_fields:
        return

    if created and not instance.order_code:
        instance.order_code = f"ORD-{instance.id:06d}"
        instance.save(update_fields=["order_code"])

3. Custom State Flag pada Instance

Untuk kasus kompleks di mana update_fields tidak memungkinkan, gunakan atribut in-memory sementara pada instance Python.

@receiver(post_save, sender=Order)
def generate_order_code(sender, instance, created, **kwargs):
    if getattr(instance, "_skip_signal", False):
        return

    if created and not instance.order_code:
        instance.order_code = f"ORD-{instance.id:06d}"
        instance._skip_signal = True
        try:
            instance.save()
        finally:
            instance._skip_signal = False

Perbandingan Dampak Query Database

StrategiJumlah Query SQLOverhead SinyalEfek Samping
QuerySet.update()1 direct UPDATENol (Sinyal ter-bypass)Field auto_now tidak terupdate otomatis
update_fields1 targeted UPDATE1 extra loop (terhenti di guard clause)Aman, memicu sinyal sekali lagi dengan filter
_skip_signal flag1 full UPDATE1 extra loop (terhenti di guard clause)State hanya bertahan di memory instance aktif

Rekomendasi: Gunakan QuerySet.update() jika tidak ada sinyal lanjutan yang dibutuhkan. Gunakan update_fields jika sistem Anda memiliki listener downstream lain yang memantau perubahan atribut tersebut.

Unit Test Pencegah Regresi

Tulis unit test untuk memastikan alur penyimpanan data tidak memicu rekursi tak terbatas saat instance dibuat.

from django.test import TestCase
from .models import Order

class OrderSignalTest(TestCase):
    def test_post_save_does_not_raise_recursion_error(self):
        """Memastikan create() selesai dan order_code terisi tanpa RecursionError."""
        try:
            order = Order.objects.create(total_amount=150000)
        except RecursionError:
            self.fail("post_save signal memicu RecursionError tak terbatas.")
        
        order.refresh_from_db()
        self.assertEqual(order.order_code, f"ORD-{order.id:06d}")