Repository: falconry/falcon

Issue:
Improve docs and checks for sink when using ASGI
Currently there is no documentation or checks in `add_sink` that require an async function when using the asgi app.

This is a follow up of https://github.com/falconry/falcon/pull/1769#discussion_r531449969

cc @vytas7 


Discussion:
Not checking sinks and static routes for "asyncness" sounds like a release (**edit**: here I meant the final 3.0 release, not the impending `a3`) blocker tbh.
@kgriffs thoughts?
static routes are managed, since we use a different class in the async app (that just wraps the sync one, but it's managed)

https://github.com/falconry/falcon/blob/2f1109a476420e0c74e551b53028085c4c2fc5f2/falcon/routing/static.py#L131-L138

In the sink instead it's missing, unless I'm forgetting something
https://github.com/falconry/falcon/blob/2f1109a476420e0c74e551b53028085c4c2fc5f2/falcon/app.py#L612-L619

I'll change the milestone to 3.0. I don't think it's required for the alpha/beta, but I agree that's best if it's done for the stable
Thinking it's actually required for `b1` because we want to be feature complete by then. But it is still the same **Version 3.0** milestone.

Resolving patch:
diff --git a/falcon/app.py b/falcon/app.py
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -21,7 +21,7 @@
 
 from falcon import app_helpers as helpers, routing
 import falcon.constants
-from falcon.errors import HTTPBadRequest
+from falcon.errors import CompatibilityError, HTTPBadRequest
 from falcon.http_error import HTTPError
 from falcon.http_status import HTTPStatus
 from falcon.middleware import CORSMiddleware
@@ -505,6 +505,10 @@ def add_route(self, uri_template, resource, **kwargs):
                 corresponding request handlers, and Falcon will do the right
                 thing.
 
+                Note:
+                    When using an async version of the ``App``, all request
+                    handlers must be awaitable coroutine functions.
+
         Keyword Args:
             suffix (str): Optional responder name suffix for this route. If
                 a suffix is provided, Falcon will map GET requests to
@@ -637,6 +641,12 @@ def add_sink(self, sink, prefix=r'/'):
 
         """
 
+        if not self._ASGI and iscoroutinefunction(sink):
+            raise CompatibilityError(
+                'The sink method must be a regular synchronous function '
+                'in order to be used with a WSGI app.'
+            )
+
         if not hasattr(prefix, 'match'):
             # Assume it is a string
             prefix = re.compile(prefix)
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -34,7 +34,12 @@
 from falcon.media.multipart import MultipartFormHandler
 import falcon.routing
 from falcon.util.misc import http_status_to_code, is_python_func
-from falcon.util.sync import _wrap_non_coroutine_unsafe, get_running_loop
+from falcon.util.sync import (
+    _should_wrap_non_coroutines,
+    _wrap_non_coroutine_unsafe,
+    get_running_loop,
+    wrap_sync_to_async,
+)
 from .multipart import MultipartForm
 from .request import Request
 from .response import Response
@@ -682,6 +687,20 @@ def add_route(self, uri_template, resource, **kwargs):
 
     add_route.__doc__ = falcon.app.App.add_route.__doc__
 
+    def add_sink(self, sink, prefix=r'/'):
+        if not iscoroutinefunction(sink) and is_python_func(sink):
+            if _should_wrap_non_coroutines():
+                sink = wrap_sync_to_async(sink)
+            else:
+                raise CompatibilityError(
+                    'The sink method must be an awaitable coroutine function '
+                    'in order to be used safely with an ASGI app.'
+                )
+
+        super().add_sink(sink, prefix=prefix)
+
+    add_sink.__doc__ = falcon.app.App.add_sink.__doc__
+
     def add_error_handler(self, exception, handler=None):
         """Register a handler for one or more exception types.
 


Test patch:
diff --git a/tests/test_inspect.py b/tests/test_inspect.py
--- a/tests/test_inspect.py
+++ b/tests/test_inspect.py
@@ -146,10 +146,12 @@ def test_sink(self, asgi):
         assert all(isinstance(s, inspect.SinkInfo) for s in sinks)
         assert sinks[-1].prefix == '/sink_fn'
         assert sinks[-1].name == 'sinkFn'
-        assert '_inspect_fixture.py' in sinks[-1].source_info
+        if not asgi:
+            assert '_inspect_fixture.py' in sinks[-1].source_info
         assert sinks[-2].prefix == '/sink_cls'
         assert sinks[-2].name == 'SinkClass'
-        assert '_inspect_fixture.py' in sinks[-2].source_info
+        if not asgi:
+            assert '_inspect_fixture.py' in sinks[-2].source_info
 
     @pytest.mark.skipif(sys.version_info < (3, 6), reason='dict order is not stable')
     def test_error_handler(self, asgi):
diff --git a/tests/test_sinks.py b/tests/test_sinks.py
--- a/tests/test_sinks.py
+++ b/tests/test_sinks.py
@@ -5,7 +5,7 @@
 import falcon
 import falcon.testing as testing
 
-from _util import create_app  # NOQA
+from _util import create_app, disable_asgi_non_coroutine_wrapping  # NOQA
 
 
 class Proxy:
@@ -27,6 +27,14 @@ async def __call__(self, req, resp, **kwargs):
         super().__call__(req, resp, **kwargs)
 
 
+def kitchen_sink(req, resp, **kwargs):
+    resp.set_header('X-Missing-Feature', 'kitchen-sink')
+
+
+async def async_kitchen_sink(req, resp, **kwargs):
+    kitchen_sink(req, resp, **kwargs)
+
+
 class BookCollection(testing.SimpleTestResource):
     pass
 
@@ -137,3 +145,32 @@ def test_route_precedence_with_both_id(self, client, sink, resource):
         response = client.simulate_request(path='/books/123')
         assert resource.called
         assert response.status == falcon.HTTP_200
+
+
+class TestSinkMethodCompatibility:
+
+    def _verify_kitchen_sink(self, client):
+        resp = client.simulate_request('BREW', '/features')
+        assert resp.status_code == 200
+        assert resp.headers.get('X-Missing-Feature') == 'kitchen-sink'
+
+    def test_add_async_sink(self, client, asgi):
+        if not asgi:
+            with pytest.raises(falcon.CompatibilityError):
+                client.app.add_sink(async_kitchen_sink)
+        else:
+            client.app.add_sink(async_kitchen_sink, '/features')
+            self._verify_kitchen_sink(client)
+
+    def test_add_sync_sink(self, client, asgi):
+        if asgi:
+            with disable_asgi_non_coroutine_wrapping():
+                with pytest.raises(falcon.CompatibilityError):
+                    client.app.add_sink(kitchen_sink)
+        else:
+            client.app.add_sink(kitchen_sink, '/features')
+            self._verify_kitchen_sink(client)
+
+    def test_add_sync_sink_with_wrapping(self, client, asgi):
+        client.app.add_sink(kitchen_sink, '/features')
+        self._verify_kitchen_sink(client)