Repository: encode/uvicorn

Issue:
uvirorn.workers.UvicornWorker fails with certain ASGI middleware
### Description
Certain middleware is fails, either when launcing Uvicornworker, or when calling the ASGI handler.
A concrete case is the SentryAsgiMiddleware.
See https://github.com/getsentry/sentry-python/issues/947 for info.
Not sure if the problem is in Uvicorn or Sentry.

### To reproduce

Install the Sentry lib in a ASGI application, run with gunicorn/uviworker
```
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
app = SentryAsgiMiddleware(app)
```
and 
```
gunicorn api:app -w 1 -k uvicorn.workers.UvicornWorker
```

### Expected behavior

Application launches and runs

### Actual behavior

As set up above, the following error is produced when launcing gunicorn:
```
File "/usr/lib/python3.7/inspect.py", line 2208, in _signature_from_callable
    raise TypeError('{!r} is not a callable object'.format(obj))
```
If I forcefully expose either a **asgi2** or **asgi3** _interface_ from the middleware, using either the `_run_asgi2()` or `_run_asgi3()` **methods** on the middleware object, the servers **starts up**, but then fails when handling a request, with e.g.:
```
TypeError: _run_asgi3() missing 2 required positional arguments: 'receive' and 'send'
```

Apparently, the introspection of the callable fails to recognize _method_ objects.

### Additional context

Using the **uvicorn** webserver it is possible to specify asgi2/3 interface using a commandline argument, and thus override the detection mechanism, which seems to be failing on this particular middleware.  Using the Uvicorn worker this appears to be not possible.

again, see issue here. See https://github.com/getsentry/sentry-python/issues/947 for info.


Discussion:
Perhaps having a subclass of UvicornWorker that just assumes asgi3 and doesn't try to second-guess the protocol from the callable.  Or alternatively, make UvicornWorker asgi3 **only** and have a UvicornWorkerAsgi2 class for legacy apps?
There is already something called UnvicornH11Worker..
this seems more like an issue introduced in #875 
Welp, indeed, `SentryAsgiMiddleware` is doing a weird thing by assigning a `__call__` method dynamically, and so Python doesn't view that as a "callable" (which is weird considering that "callable" _should_ be a synonym for "has a `__call__` method"?).

https://github.com/getsentry/sentry-python/blob/9f1aeb387410ead07430becb7f6e6aa8cd80b205/sentry_sdk/integrations/asgi.py#L91-L94

```python
>>> class Middleware:
...     def __init__(self):
...         self.__call__ = self._run
...     def _run(self): pass
...
>>> m = Middleware()
>>> import inspect
>>> inspect.signature(m)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/florimond/.pyenv/versions/3.9.0/lib/python3.9/inspect.py", line 3118, in signature
    return Signature.from_callable(obj, follow_wrapped=follow_wrapped)
  File "/Users/florimond/.pyenv/versions/3.9.0/lib/python3.9/inspect.py", line 2867, in from_callable
    return _signature_from_callable(obj, sigcls=cls,
  File "/Users/florimond/.pyenv/versions/3.9.0/lib/python3.9/inspect.py", line 2242, in _signature_from_callable
    raise TypeError('{!r} is not a callable object'.format(obj))
TypeError: <__main__.Middleware object at 0x10f4dbb20> is not a callable object
```
Well, I believe in duck typing more than anything. Why not simply try to call the target and see what happens, rather than to try to rely on the always lying metadata? 
Anyway, the momentum is towards abandoning asgi2. It would be great to be able to instruct uvicornworker to just assume V3 and get on with it 
To be clear AFAICT the bug here is not related to determining ASGI 2 vs ASGI 3, but related to "app instances" vs "app factories". The latter case was introduced recently via a `--factory` flag, and the code at fault was meant to try and detect when a factory is passed without the flag (so we don't just crash with an odd "app expected 0 arguments but got 3" error message).
Hm, being an old school Python guy, I probably would have opted for just calling the object, without an argument, and if that worked, assume that it was a factory.  Otherwise, handle the exception and assume it was an app instance.  In my experience type inspection only ever caused grief.

Resolving patch:
diff --git a/uvicorn/config.py b/uvicorn/config.py
--- a/uvicorn/config.py
+++ b/uvicorn/config.py
@@ -310,18 +310,18 @@ def load(self):
             logger.error("Error loading ASGI app. %s" % exc)
             sys.exit(1)
 
-        if self.factory:
-            try:
-                self.loaded_app = self.loaded_app()
-            except TypeError as exc:
+        try:
+            self.loaded_app = self.loaded_app()
+        except TypeError as exc:
+            if self.factory:
                 logger.error("Error loading ASGI app factory: %s", exc)
                 sys.exit(1)
-        elif not inspect.signature(self.loaded_app).parameters:
-            logger.error(
-                "APP seems to be an application factory. "
-                "Run uvicorn with the --factory flag."
-            )
-            sys.exit(1)
+        else:
+            if not self.factory:
+                logger.warning(
+                    "ASGI app factory detected. Using it, "
+                    "but please consider setting the --factory flag explicitly."
+                )
 
         if self.interface == "auto":
             if inspect.isclass(self.loaded_app):


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,4 +1,5 @@
 import json
+import logging
 import socket
 from copy import deepcopy
 
@@ -100,7 +101,7 @@ def test_app_unimportable_other(caplog):
     )
 
 
-def test_app_factory():
+def test_app_factory(caplog):
     def create_app():
         return asgi_app
 
@@ -108,10 +109,15 @@ def create_app():
     config.load()
     assert config.loaded_app is asgi_app
 
-    # Flag missing.
-    config = Config(app=create_app)
-    with pytest.raises(SystemExit):
+    # Flag not passed. In this case, successfully load the app, but issue a warning
+    # to indicate that an explicit flag is preferred.
+    caplog.clear()
+    config = Config(app=create_app, proxy_headers=False)
+    with caplog.at_level(logging.WARNING):
         config.load()
+    assert config.loaded_app is asgi_app
+    assert len(caplog.records) == 1
+    assert "--factory" in caplog.records[0].message
 
     # App not a no-arguments callable.
     config = Config(app=asgi_app, factory=True)