What is the simplest way to retry a Django view upon an exception being raised?

Question:

I want to rerun a Django view function if a certain exception is raised (in my case a serialization error in the underlying database). I want it to run with exactly the same parameters, including the same request object – as if the client had re-requested the URL.

There are many database queries in the view, and the exception could be raised on any one of them – and it won’t work to rerun just one of the queries in isolation, so I think I need to wrap the entire view function in a try/except block and loop until success.

But, I have several view functions which might raise such exceptions, so I want a generic solution. I also want to be able to retry up to a certain number of times and then fail.

Is there any simple solution?

Asked By: jl6

||

Answers:

You could achieve this by writing a decorator:

def retry_on_exception(view):
    def wrapper(*args, **kwargs):
        while True:
            try:
                return view(*args, **kwargs):
            except (TheExceptions, IWant, ToCatch):
                pass
    return wrapper

And use this on the view:

@retry_on_exception
def my_view(request, foo, bar):
    return HttpResponse("My stuff")

Obviously, this will retry indefinitely, so a lot of logic could be improved there. You could also write the decorator to accept the exceptions it wants to look out for, so you can customise it per view.

Answered By: Ben

You can use retry() below to retry a view 0 or more times with 0 or more interval seconds:

# "store/views.py"

from django.db import DatabaseError, transaction, IntegrityError 
from time import sleep
from .models import Person
from django.http import HttpResponse

def retry(count, interval=1):
    def _retry(func):
        def core(*args, **kwargs):
            nonlocal count
            if callable(count):
                count = 2
            for _ in range(count+1):
                try:
                    return func(*args, **kwargs)
                except DatabaseError as e:
                    print(e)
                    sleep(interval)
        return core
    
    if callable(count):
        return _retry(count)
    return _retry

# ...

For example, I have Person model below. *I use PostgreSQL:

# "store/models.py"

class Person(models.Model):
    name = models.CharField(max_length=30)

Then, test() view with @retry(4, 2) below can retry 4 times with 2 interval seconds in addition to a normal try each time IntegrityError exception occurs as shown below:

# "store/views.py"

from django.db import DatabaseError, transaction, IntegrityError
from time import sleep
from .models import Person
from django.http import HttpResponse

def retry(count, interval=1):
    # ...

@retry(4, 2) # Here
@transaction.atomic
def test(request):
    qs = Person.objects.get(name="John")
    qs.name = "David"
    qs.save()

    raise IntegrityError("Exception occurs")

    return HttpResponse("Test")

As you can see, test view is retried 4 times in addition to a normal try, then finally ValueError exception occurs because None is returned:

Exception occurs # Normal try
Exception occurs # 1st retry
Exception occurs # 2nd retry
Exception occurs # 3rd retry
Exception occurs # 4th retry
Internal Server Error: /store/test/
Traceback (most recent call last):
  File "C:UserskaiAppDataLocalProgramsPythonPython39libsite-packagesdjangocorehandlersexception.py", line 47, in inner
    response = get_response(request)
  File "C:UserskaiAppDataLocalProgramsPythonPython39libsite-packagesdjangocorehandlersbase.py", line 188, in _get_response
    self.check_response(response, callback)
  File "C:UserskaiAppDataLocalProgramsPythonPython39libsite-packagesdjangocorehandlersbase.py", line 309, in check_response
    raise ValueError(
ValueError: The view store.views.core didn't return an HttpResponse object. It returned None instead.
[28/Dec/2022 16:42:41] "GET /store/test/ HTTP/1.1" 500 88950

And, transaction is run 5 times (The light blue one is a normal try and the yellow ones are 4 retries) according to the PostgreSQL query logs below. *You can check how to log PostgreSQL queries:

enter image description here

Be careful, if the order of the decorators is @transaction.atomic then @retry(4, 2) as shown below:

# "store/views.py"

# ...

@transaction.atomic # Here
@retry(4, 2) # Here
def test(request):
    # ...

The error below occurs:

store.models.Person.DoesNotExist: Person matching query does not exist.

So, the order of the decorators must be @retry(4, 2) then @transaction.atomic as shown below:

# "store/views.py"

# ...

@retry(4, 2) # Here
@transaction.atomic # Here
def test(request):
    # ...

And by default, @retry retries test() view 2 times with 1 interval second in addition to a normal try each time IntegrityError exception occurs as shown below:

# "store/views.py"

# ...

@retry # Here
@transaction.atomic
def test(request):
    # ...

As you can see, test view is retried 2 times in addition to a normal try, then finally ValueError exception occurs because None is returned:

Exception occurs # Normal try
Exception occurs # 1st retry
Exception occurs # 2nd retry
Internal Server Error: /store/test/
Traceback (most recent call last):
  File "C:UserskaiAppDataLocalProgramsPythonPython39libsite-packagesdjangocorehandlersexception.py", line 47, in inner
    response = get_response(request)
  File "C:UserskaiAppDataLocalProgramsPythonPython39libsite-packagesdjangocorehandlersbase.py", line 188, in _get_response
    self.check_response(response, callback)
  File "C:UserskaiAppDataLocalProgramsPythonPython39libsite-packagesdjangocorehandlersbase.py", line 309, in check_response
    raise ValueError(
ValueError: The view store.views.core didn't return an HttpResponse object. It returned None instead.
[28/Dec/2022 19:08:07] "GET /store/test/ HTTP/1.1" 500 88950

And, transaction is run 3 times (The light blue one is a normal try and the yellow ones are 2 retries) according to the PostgreSQL query logs below:

enter image description here

In addition, if you want to retry test() view 3 times with 0.5 interval seconds by default with @retry, you need to change count = 2 to count = 3 and interval=1 to interval=0.5 respectively as shown below:

# "store/views.py"

# ...
                 # ↓ Here
def retry(count, interval=0.5):
    def _retry(func):
        def core(*args, **kwargs):
            nonlocal count
            if callable(count):
                count = 3 # <= Here
            for _ in range(count+1):
                try:
                    return func(*args, **kwargs)
                except DatabaseError as e:
                    print(e)
                    sleep(interval)
        return core
    
    if callable(count):
        return _retry(count)
    return _retry
Answered By: Kai – Kazuya Ito