Repository: django/asgiref (Python)

Issue:
AttributeError: asgiref.local._CVar object has no attribute 'value'
Running the latest `main` (`abc69a054aa440ef42c939b5a197df05c3ad48d2`) in production to test a fix for https://github.com/django/asgiref/issues/467, I see this error somewhat frequently across a wide spread of our requests/endpoints. This error was frequent enough that I reverted back to the old version which errors in a different way less frequently like https://github.com/django/asgiref/issues/467.

Not really sure why `__delattr__` in `_CVar` needs to throw if we try to delete an attribute that doesn't exist. Does this indicate a programming/synchronization bug somewhere or is this just a bit over-restricting? Not sure if this is bug prevention or its just fine.

Happy to test any further improvements/fixes here to try and get a release which doesn't have errors. Can also provide any additional info that might be helpful here if need be.

Relevant versions:
```
    "ddtrace==2.9.2",
    "django==4.2.13",
```

Stack trace (all failures are basically the same):

```
AttributeError: <asgiref.local._CVar object at 0x7f05030333a0> object has no attribute 'value'
  File "django/core/handlers/asgi.py", line 160, in __call__
    await self.handle(scope, receive, send)
  File "django/core/handlers/asgi.py", line 190, in handle
    await self.send_response(response, send)
  File "django/core/handlers/asgi.py", line 296, in send_response
    await sync_to_async(response.close, thread_sensitive=True)()
  File "concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
  File "ddtrace/contrib/futures/threading.py", line 36, in _wrap_execution
    return fn(*args, **kwargs)
  File "django/http/response.py", line 335, in close
    signals.request_finished.send(sender=self._handler_class)
  File "django/dispatch/dispatcher.py", line 176, in send
    return [
  File "django/dispatch/dispatcher.py", line 177, in <listcomp>
    (receiver, receiver(signal=self, sender=sender, **named))
  File "django/core/handlers/base.py", line 370, in reset_urlconf
    set_urlconf(None)
  File "django/urls/base.py", line 137, in set_urlconf
    del _urlconfs.value
  File "asgiref/local.py", line 129, in __delattr__
    delattr(storage, key)
  File "asgiref/local.py", line 38, in __delattr__
    raise AttributeError(f"{self!r} object has no attribute {key!r}")
```
asgiref 3.9.0 breaks django-hosts
asgiref 3.9.0 in conjunction with django-hosts 7.0.0 (their latest version) makes between 10% and 20% of requests to our admin host fail with

> NoReverseMatch:  'admin' is not a registered namespace

These requests are calling something like `reverse('admin:login')`, but the django-hosts configuration is the fallback one for their thread.

Reverting to asgiref 3.8.1 fixes the problem.

I suspect this code change, which uses a normal dictionary for thread-local access: https://github.com/django/asgiref/commit/85d2445021ff8e588fb2883a865d768b60bd0902#r161477713


Discussion:
@carltongibson -- do you have any opinions about a potential fix + validation of that fix here? 

Basically related to:

> Not really sure why `__delattr__` in `_CVar` needs to throw if we try to delete an attribute that doesn't exist. Does this indicate a programming/synchronization bug somewhere or is this just a bit over-restricting? Not sure if this is bug prevention or its just fine.

From reading the docstring on `Local`

>     Unlike plain `contextvars` objects, this utility is threadsafe.

If this is true, to me this implies multiple threads? (actually confused here about what scenario threadsafety is referring to here, multiple threads sharing the context of an async task?) can call `del`. As defined `__delattr__` is:
```
    def __delattr__(self, key):
        with self._lock_storage() as storage:
            delattr(storage, key)
```

And storage's `__delattr__` is:

```
    def __delattr__(self, key: str) -> None:
        if key in self._data:
            del self._data[key]
        else:
            raise AttributeError(f"{self!r} object has no attribute {key!r}")
```

So if two threads? try to delete the same attribute, one is going to win and one is going to error, even though the operation is properly locked. It seems like it should be pretty harmless to just let a second delete go through here without error?

Though seemingly the standard behavior of `del` in python is to throw. Looking again at my stack trace a bit closer, this could be something upstream in Django interacting poorly here, but that might be a larger challenge to land/validate vs. just allowing repeated `del` here, especially if the issue stems from async task + thread coordination. What do you think?


@natdempk I haven't had space to look into it yet, I'm afraid. This issue is on my list, along with the cluster around #493. 

Alas WORK has left me little time of late. 🤹
Having the same issue with a Wagtail project (similar stacktrace as in https://github.com/wagtail/wagtail/issues/13208) running with ASGI, which I've just upgraded from Django 4.2 to 5.2.
I have a Wagtail screen with multiple autocomplete-fields which (fortunately in this case) start multiple parallel requests on load. In my dev env with `asgiref` 3.9, from those 9 parallel requests I always get at least 1 failure, most of the time 3-5 fails...

Downgrading `asgiref` from 3.9 to 3.8.1 seems to fix the issue for me, no more errors.
@natdempk @th3hamm0r I think we have a fix in #523. Could you give that a run to confirm? 

For reference the requirements line needed would be: 

```
asgiref @ git+https://github.com/django/asgiref.git@refs/pull/523/head
```

and the `pip` command: 

```
pip install 'asgiref @ git+https://github.com/django/asgiref.git@refs/pull/523/head'
```

[VCS Support - Pip Documentation v25.1.1](https://pip.pypa.io/en/stable/topics/vcs-support/)
@carltongibson Just tested the fix, it works! 🚀 
Thanks for testing @th3hamm0r — more helpful than you know. 🎁 
OK, please upgrade to 3.9.1

https://pypi.org/project/asgiref/3.9.1/

@phihag Can you reduce this to some kind of reproduction? (A failing test case would be perfect) 
I will try. It's a bit tricky since it only occurs if there is other traffic. The actual failures are on every page that calls `reverse`, including e.g. the login page.

The change I linked is definitely wrong though, right? It sets in a normal `dict` from multiple threads, in a construct that's explicitly designed to use thread-local storage.
OK, I'll look at it this week. 👀 //cc @bellini666 @spanezz 
I can see why this can happen. The current solution allows the `ContextVar` to [be deleted](https://github.com/django/asgiref/blob/main/asgiref/local.py#L36), meaning one thread/ctx can remove the context from all others, which is probably what is happening here, as `django-hosts` seems to be triggering this

I just opened [this PR](https://github.com/django/asgiref/pull/523) which reviews the solution with a `.copy()`, as [discussed in here](https://github.com/django/asgiref/issues/475#issuecomment-3043620092), and added a regression test for this case, while ensuring the existing tests that got added during that PR and related one still passes
A silent upgrade to 3.9.0 also impacted us in production with similar results - a percentage of requests threw errors resolving Django URLs.

In our case, a middleware was setting `request.urlconf` to a different value, depending on the request host. Concurrent requests somehow invalidated setting this custom urlconf. I assume this is similar to what django-hosts does.
@phihag @sttwister Can you try the branch from #523 please, to see if it resolved your issue?
It **looks** like the diagnosis is correct. Django resets the urlconf after each request...

```
def reset_urlconf(sender, **kwargs):
    """Reset the URLconf after each request is finished."""
    set_urlconf(None)


request_finished.connect(reset_urlconf)
```

... which would trigger the case here. 
@carltongibson I was curious on why this hasn't affected us at all. We've been running 3.9.0 since its release, in an app that gets ~3 million requests/day, and no issues at all.

After checking django's code in detail, this only affects situations where urlconf is modified in the request by setting `request.urlconf`, which is then set in the current thread/context by [this condition](https://github.com/django/django/blob/main/django/core/handlers/base.py#L306), and used by the handler to resolve the view and/or a `reverse` call inside the view itself.

For anyone not setting `requests.urlconf`, it doesn't cause any issues, as the [get_resolver](https://github.com/django/django/blob/main/django/urls/resolvers.py#L108) will fallback to the base one, only downside being that it might have lost its cache in the process.

So, at least for `urlconf`, this requires a very specific situation, like what `django-hosts` does, as the default implementation is not affected. Changing the solution to `.copy()` should fix the issue, as setting any value in there would create a copy of the dict during that request, meaning the `del _urlconfs.value` would only delete the key from that dict, not affecting other requests
Hey @bellini666 — yes, exactly. I think we're in agreement. 

I would still prefer if either @phihag or @sttwister could try the PR branch to confirm the fix… — is that possible folks? (A "No" is more helpful than silence. 🙏) 
I've been looking into it, but it's not really feasible for us to deploy a custom asgiref, sorry. We don't even use it directly, it's just a transitive dependency of ours.

Of course, we could in principle do it, but it's a lot of engineering effort that we cannot spend in the short term. Apologies.
@phihag OK, thanks. 


For reference the requirements line needed would be: 

```
asgiref @ git+https://github.com/django/asgiref.git@refs/pull/523/head
```

and the `pip` command: 

```
pip install 'asgiref @ git+https://github.com/django/asgiref.git@refs/pull/523/head'
```

[VCS Support - Pip Documentation v25.1.1](https://pip.pypa.io/en/stable/topics/vcs-support/)
OK, please upgrade to 3.9.1

https://pypi.org/project/asgiref/3.9.1/
Thank you very much for the fast fix!

I can confirm that asgiref 3.9.1 fixes the problem.
Great. Thanks for confirming. 🎁 



Patch:
diff --git a/asgiref/local.py b/asgiref/local.py
index 19b395f1..845313a8 100644
--- a/asgiref/local.py
+++ b/asgiref/local.py
@@ -2,38 +2,37 @@
 import contextlib
 import contextvars
 import threading
-from typing import Any, Union
+from typing import Any, Dict, Union
 
 
 class _CVar:
     """Storage utility for Local."""
 
     def __init__(self) -> None:
-        self._data: dict[str, contextvars.ContextVar[Any]] = {}
+        self._data: "contextvars.ContextVar[Dict[str, Any]]" = contextvars.ContextVar(
+            "asgiref.local"
+        )
 
-    def __getattr__(self, key: str) -> Any:
+    def __getattr__(self, key):
+        storage_object = self._data.get({})
         try:
-            var = self._data[key]
+            return storage_object[key]
         except KeyError:
             raise AttributeError(f"{self!r} object has no attribute {key!r}")
 
-        try:
-            return var.get()
-        except LookupError:
-            raise AttributeError(f"{self!r} object has no attribute {key!r}")
-
     def __setattr__(self, key: str, value: Any) -> None:
         if key == "_data":
             return super().__setattr__(key, value)
 
-        var = self._data.get(key)
-        if var is None:
-            self._data[key] = var = contextvars.ContextVar(key)
-        var.set(value)
+        storage_object = self._data.get({}).copy()
+        storage_object[key] = value
+        self._data.set(storage_object)
 
     def __delattr__(self, key: str) -> None:
-        if key in self._data:
-            del self._data[key]
+        storage_object = self._data.get({}).copy()
+        if key in storage_object:
+            del storage_object[key]
+            self._data.set(storage_object)
         else:
             raise AttributeError(f"{self!r} object has no attribute {key!r}")
 


Test patch:
diff --git a/tests/test_local.py b/tests/test_local.py
index cdcbd280..77e57e89 100644
--- a/tests/test_local.py
+++ b/tests/test_local.py
@@ -375,3 +375,19 @@ async def _test() -> None:
 
     # Changes should not leak to the caller
     assert test_local.value == 0
+
+
+@pytest.mark.asyncio
+async def test_deletion() -> None:
+    """Check visibility with asyncio tasks."""
+    test_local = Local()
+    test_local.value = 123
+
+    async def _test() -> None:
+        # Local is inherited when changing task
+        assert test_local.value == 123
+        del test_local.value
+        assert not hasattr(test_local, "value")
+
+    await asyncio.create_task(_test())
+    assert test_local.value == 123