1

I have a question that might have a very simple answer.

Everywhere I look it says that the Django development server (manage.py runserver) is multithreaded (https://docs.djangoproject.com/en/3.2/ref/django-admin/) but this is not what I am experiencing.

DISCLAIMER: I know there are other ways to achieve this but I find this solution to be interesting and I cannot understand why it does not work.

I want to create one endpoint in my API that uses another endpoint's response to generate a report, the Views are set up as follows:

from rest_framework.views import APIView
from rest_framework.response import Response

from asgiref.sync import async_to_sync


class View1(APIView):
    def get(self, request, *args, **kwargs):
        response_dict = {"message": "Success!"}
        return Response(response_dict)


class View2(APIView):
    def get(self, request, *args, **kwargs):
        client = Session()
        response = self.get_response(client)
        if response.get("Message") == "Success!":
            return Response("Success!")
        return Response("Failed!")
   
    @async_to_sync
    async def get_response(self, client):
        return await client.get("http://localhost:8000/api/view1"#).json()

Now in my eyes this code looks like it should work because the request to View2 should be picked up by a first worker and the request that View2 is making to View1 should be picked up by a different worker, so that when the request to View1 is completed the request to View2 can be completed.

What I am seeing, using asgiref==3.4.1, Django==3.2.8, and djangorestframework==3.12.4 is that the request for View2 gets stuck just at the line where it makes the request to View1 and I would love to understand why that is the case.

Luiz F. Bianchi
  • 79
  • 1
  • 10

3 Answers3

2

Everywhere I look it says that the Django development server (manage.py runserver) is multithreaded

If you are talking about:

--nothreading

Disables use of threading in the development server. The server is multithreaded by default.

This option prevents the addition of socketserver.ThreadingMixIn to the wsgiref.simple_server and affects how the server handles network connections. The simple_server is, as the name suggests, a simple WSGI server that comes with Python and is used by Django to run its development server (via python manage.py runserver). On top of that, Django uses a separate thread for its async_to_sync and sync_to_async magic.

Now, to answer your question:

In order to handle multiple blocking requests at the same time, you need multiple workers (think of it as multiple servers plus a load balancer). And while running multiple workers usually implies multithreading or multiprocessing, a webserver being multithreaded doesn't automatically imply multiple workers processing requests.

For your specific code example, I would recommend converting everything to use async. While you should be able to run it fine with the vanilla runserver. If you pip install channels["daphne"] and add "channels" to the INSTALLED_APPS it will replace runserver command with its own that uses Daphne (ASGI) instead of simple WSGI server.

Igonato
  • 10,175
  • 3
  • 35
  • 64
  • 1
    You did answer why my approach didn't work but you suggestion on how to make it work didn't actually work, (or, more likely, I wasn't able to make it work). Will add it to my question how I got around it. – Luiz F. Bianchi Mar 28 '23 at 15:32
1

This is the wrong approach... instead modify your code as follows:

from rest_framework.views import APIView
from rest_framework.response import Response

from asgiref.sync import async_to_sync
        
# Added new import    
import requests

class View1(APIView):
    def get(self, request, *args, **kwargs):
        response_dict = {"message": "Success!"}
        return Response(response_dict)


class View2(APIView):
    def get(self, request, *args, **kwargs):
        # client = Session()
        response = self.get_response() # removed client session
        if response.get("Message") == "Success!":
            return Response("Success!")
        return Response("Failed!")
   
    @async_to_sync
    async def get_response(self):
        # Changed to requests instead of client session
        return await requests.get("http://localhost:8000/api/view1"#).json()

The API entry point cares about the output, we don't need to create a session for this, instead we want to use the requests library. All we want is immutable data to avoid race conditions, it is best to do a simple requests this is safe and scales. You are treating your API like a microservice in this way.

Hope this helps!

Alessandro
  • 81
  • 5
0

So the answer from igonotato explained very well the reason why I was not able to make a blocking request from my API to itself but I was not able to make his suggestion of using an async view work (I've tried using daphne and uvicorn); the result was always the same problem.

Essentially the issue is that although runserver is multithreaded it only has one worker; when that one worker is "busy" waiting for the response to the blocking request there is no other worker to actually fulfil that blocking request, so the lone worker is stuck waiting for the response forever.

Interestingly enough, as my production deployment uses Gunicorn with 17 workers, when a worker makes a blocking request to the API there is another worker to fulfil that request, i.e. the problem did not exist on production only on development.

The way I got around it is a little hacky but functional enough on a development context:

I am running my servers using a docker container on development and my docker-entrypoint.sh was looking like this before the fix:

#!/bin/bash
echo "Apply database migrations"
/flock-web-api/manage.py migrate

echo "Starting Django development server"
/flock-web-api/manage.py runserver 0.0.0.0:8000

So one server running on port 8000, pretty normal stuff.

After the fix this is what it looks like:

#!/bin/bash
echo "Apply database migrations"
/flock-web-api/manage.py migrate

echo "Starting Django development servers"
/flock-web-api/manage.py runserver 0.0.0.0:8001 & disown
/flock-web-api/manage.py runserver 0.0.0.0:8000

And this is the code for the views.py file:

from rest_framework.views import APIView
from rest_framework.response import Response

from asgiref.sync import async_to_sync


class View1(APIView):
    def get(self, request, *args, **kwargs):
        response_dict = {"message": "Success!"}
        return Response(response_dict)


class View2(APIView):
    def get(self, request, *args, **kwargs):
        client = Session()
        response = self.get_response(client)
        if response.get("Message") == "Success!":
            return Response("Success!")
        return Response("Failed!")
   
    @async_to_sync
    async def get_response(self, client):
        return await client.get("http://localhost:8001/api/view1"#).json()

As the request is made to a different server running on a different process, availability-wise it simulates the situation where there are multiple workers on the same server although at the cost of more resources.

Yes its dirty but it will get the job done and it is not going to be deployed so I was happy to proceed with that solution until this bit of the project becomes its own microservice in the future.

Luiz F. Bianchi
  • 79
  • 1
  • 10