Fixing Django's APPEND_SLASH behavior with Kamal

I spent two hours figuring this out so you don't have to.

Yesterday, I noticed some strange behavior on my just-launched Life in Weeks app. When I clicked around on the website, everything worked great. But whenever I tried to load my own timeline on my phone, it wouldn’t work. But then I’d go to the landing page and click on the link to my timeline and it would work perfectly.

I thought that it was just hanging for some weird reason, but a friend ran into a similar issue and tipped me off to what was happening. If you loaded the url without a trailing slash (https://lifeweeks.app/czue) it failed. But if you added the trailing slash (https://lifeweeks.app/czue/) it worked fine.

This is odd, because Django has built-in functionality to automatically handle this. In particular, if you have the APPEND_SLASH setting enabled (which I always do), then URL without a trailing slash that doesn’t resolve gets automatically redirected to the equivalent URL with the trailing slash added. This has always worked perfectly for me, so I was quite surprised it not working on my new app! But, I tested a few other URLs and confirmed that every URL without a slash was broken.

I’ll save you the gory details of my debugging session, but the conversation I had with Claude ended up being more than 11,000 words long before we figured out where in the process things were going wrong.

Turns out that it appears to be a bug in kamal’s proxy server. Specifically, if you use chunked encoding (which Django’s gunicorn does by default for redirects) then kamal proxy turns your 301 redirect into a 200 OK, and the browser happily renders an empty blank page.

To workaround this you can replace Django’s CommonMiddleware with a subclass that explicitly forces un-chunked encoding:

from django.middleware.common import CommonMiddleware

class NoChunkedRedirectCommonMiddleware(CommonMiddleware):
    """
    Forces redirects to not use chunked encoding (which gunicorn does by default)
    to workaround this bug: https://github.com/basecamp/kamal-proxy/discussions/115
    """

    def process_response(self, request, response):
        # Get the response from parent first (this creates redirects if needed)
        response = super().process_response(request, response)
        # Then make sure it's not using chunked encoding if it's a redirect
        if response.status_code == 301:
            response.streaming = False
            if "Transfer-Encoding" in response:
                del response["Transfer-Encoding"]
            if not response.content:
                response["Content-Length"] = "0"

        return response

You also have to fully replace CommonMiddleware with NoChunkedRedirectCommonMiddleware in your settings:

MIDDLEWARE = [
    # other stuff here
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    # "django.middleware.common.CommonMiddleware",
    "myapp.middleware.redirects.NoChunkedRedirectMiddleware",

After these changes, append-slash redirects are working again!

I hope this saves someone some time in the future—at least until Basecamp fixes the bug!