Repository: falconry/falcon

Issue:
deprecated() utility raises AttributeError under Meinheld
The inner function of our [`deprecated()`](https://falcon.readthedocs.io/en/latest/api/util.html#falcon.deprecated) utility generator grabs the current stack frame object via [`inspect.getcurrentframe()`](https://docs.python.org/3/library/inspect.html#inspect.currentframe), and then uses its attributes to provide a more informative deprecation warning.

However, as warned in the latter's docs, this function is not guaranteed to return a valid stack frame object on all Python implementations; it may also return `None`. It seems that running Gunicorn+Meinheld workers can trigger this situation even under CPython.

Discovered using the following command line under CPython 3.7 and 3.8:
```
gunicorn --workers=8 --worker-class="egg:meinheld#gunicorn_worker" test:app
```

For instance, assigning a value to the deprecated [`Response.body`](https://falcon.readthedocs.io/en/latest/api/request_and_response_wsgi.html#falcon.Response.body) yields
```
2021-03-11 23:31:42 [FALCON] [ERROR] GET /things => Traceback (most recent call last):
  File "falcon/app.py", line 361, in falcon.app.App.__call__
  File "/tmp/benchmark/test3.py", line 13, in on_get
    resp.body = ('\nTwo things awe me most, the starry sky '
  File "falcon/util/deprecation.py", line 67, in falcon.util.deprecation.deprecated.decorator.wrapper
AttributeError: 'NoneType' object has no attribute 'f_code'
```


Discussion:
Good find. I think we should also consider reducing the frequency of these warnings (perhaps only emitting them the first time or every N times?) to further improve performance.
For 3.0 we should at least add a check for `None`.
While at it, it would be good to reason about another potential caveat of referencing a frame object: it is easy to unintentionally create reference cycles this way; see also [this warning in CPython docs](https://docs.python.org/3/library/inspect.html#the-interpreter-stack), which suggests explicit disposal in the `finally` clause:
```python
def handle_stackframe_without_leak():
    frame = inspect.currentframe()
    try:
        # do something with the frame
    finally:
        del frame
```
why do we need to get the frame?
It seems we don't need to care of the number of times the deprecation is invoked. The interpreter does the job itself by warning once per process without doing anything special.

The issue under Meinheld was that we used to explode before even reaching that warning invocation, resulting in handling generic `Exception` upon every request.

Resolving patch:
diff --git a/falcon/util/deprecation.py b/falcon/util/deprecation.py
--- a/falcon/util/deprecation.py
+++ b/falcon/util/deprecation.py
@@ -18,7 +18,6 @@
 """
 
 import functools
-import inspect
 import warnings
 
 
@@ -60,12 +59,7 @@ def decorator(func):
 
         @functools.wraps(func)
         def wrapper(*args, **kwargs):
-            frame = inspect.currentframe().f_back
-
-            warnings.warn_explicit(message,
-                                   category=DeprecatedWarning,
-                                   filename=inspect.getfile(frame.f_code),
-                                   lineno=frame.f_lineno)
+            warnings.warn(message, category=DeprecatedWarning, stacklevel=2)
 
             return func(*args, **kwargs)
 


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -42,6 +42,7 @@ jobs:
           - "e2e_chrome"
           - "e2e_firefox"
           - "no_optional_packages"
+          - "wsgi_servers"
           # TODO(kgriffs): Re-enable once hug has a chance to address
           # breaking changes in Falcon 3.0
           # - "hug"
diff --git a/tests/_wsgi_test_app.py b/tests/_wsgi_test_app.py
new file mode 100644
--- /dev/null
+++ b/tests/_wsgi_test_app.py
@@ -0,0 +1,52 @@
+import hashlib
+import io
+import os.path
+
+import falcon
+
+
+HERE = os.path.abspath(os.path.dirname(__file__))
+
+
+class Forms:
+    def on_post(self, req, resp):
+        parts = {}
+
+        for part in req.media:
+            # NOTE(vytas): SHA1 is no longer recommended for cryptographic
+            #   purposes, but here we are only using it for integrity checking.
+            sha1 = hashlib.sha1()
+            while True:
+                chunk = part.stream.read(io.DEFAULT_BUFFER_SIZE)
+                if not chunk:
+                    break
+
+                sha1.update(chunk)
+
+            parts[part.name] = {
+                'filename': part.filename,
+                'sha1': sha1.hexdigest(),
+            }
+
+        resp.media = parts
+
+
+class Hello:
+    def on_get(self, req, resp):
+        resp.set_header('X-Falcon', 'peregrine')
+
+        resp.content_type = falcon.MEDIA_TEXT
+        resp.text = 'Hello, World!\n'
+
+    def on_get_deprecated(self, req, resp):
+        resp.set_header('X-Falcon', 'deprecated')
+
+        resp.content_type = falcon.MEDIA_TEXT
+        resp.body = 'Hello, World!\n'
+
+
+app = application = falcon.App()
+app.add_route('/forms', Forms())
+app.add_route('/hello', Hello())
+app.add_route('/deprecated', Hello(), suffix='deprecated')
+app.add_static_route('/tests', HERE, downloadable=True)
diff --git a/tests/test_wsgi_servers.py b/tests/test_wsgi_servers.py
new file mode 100644
--- /dev/null
+++ b/tests/test_wsgi_servers.py
@@ -0,0 +1,211 @@
+import hashlib
+import os
+import random
+import subprocess
+import sys
+import time
+
+import pytest
+import requests
+
+from falcon import testing
+
+_HERE = os.path.abspath(os.path.dirname(__file__))
+_SERVER_HOST = '127.0.0.1'
+_SIZE_1_KB = 1024
+_SIZE_1_MB = _SIZE_1_KB ** 2
+
+_REQUEST_TIMEOUT = 10
+
+
+def _gunicorn_args(host, port):
+    """Gunicorn"""
+    try:
+        import gunicorn  # noqa: F401
+    except ImportError:
+        pytest.skip('gunicorn not installed')
+
+    return (
+        'gunicorn',
+        '--access-logfile', '-',
+        '--bind', '{}:{}'.format(host, port),
+        '_wsgi_test_app:app',
+    )
+
+
+def _meinheld_args(host, port):
+    """Gunicorn + Meinheld"""
+    try:
+        import gunicorn  # noqa: F401
+    except ImportError:
+        pytest.skip('gunicorn not installed')
+
+    try:
+        import meinheld  # noqa: F401
+    except ImportError:
+        pytest.skip('meinheld not installed')
+
+    return (
+        'gunicorn',
+        '--access-logfile', '-',
+        '--bind', '{}:{}'.format(host, port),
+        '--workers', '2',
+        '--worker-class', 'egg:meinheld#gunicorn_worker',
+        '_wsgi_test_app:app',
+    )
+
+
+def _uvicorn_args(host, port):
+    """Uvicorn (WSGI interface)"""
+    try:
+        import uvicorn  # noqa: F401
+    except ImportError:
+        pytest.skip('uvicorn not installed')
+
+    return (
+        'uvicorn',
+        '--host', host,
+        '--port', str(port),
+        '--interface', 'wsgi',
+        '_wsgi_test_app:app',
+    )
+
+
+def _uwsgi_args(host, port):
+    """uWSGI"""
+    return (
+        'uwsgi',
+        '--http', '{}:{}'.format(host, port),
+        '--wsgi-file', '_wsgi_test_app.py',
+    )
+
+
+def _waitress_args(host, port):
+    """Waitress"""
+    try:
+        import waitress  # noqa: F401
+    except ImportError:
+        pytest.skip('waitress not installed')
+
+    return (
+        'waitress-serve',
+        '--listen', '{}:{}'.format(host, port),
+        '_wsgi_test_app:app',
+    )
+
+
+@pytest.fixture(params=[
+    _gunicorn_args,
+    _meinheld_args,
+    _uvicorn_args,
+    _uwsgi_args,
+    _waitress_args,
+])
+def server_url(request):
+    if sys.platform.startswith('win'):
+        pytest.skip('WSGI server tests are currently unsupported on Windows')
+
+    server_args = request.param
+
+    for attempt in range(3):
+        server_port = testing.get_unused_port()
+        base_url = 'http://{}:{}'.format(_SERVER_HOST, server_port)
+
+        args = server_args(_SERVER_HOST, server_port)
+        print('Starting {}...'.format(server_args.__doc__))
+        print(' '.join(args))
+        try:
+            server = subprocess.Popen(args, cwd=_HERE)
+        except FileNotFoundError:
+            pytest.skip('{} executable is not installed'.format(args[0]))
+
+        # NOTE(vytas): give the app server some time to start.
+        for attempt in range(3):
+            try:
+                requests.get(base_url + '/things', timeout=3.05)
+                break
+            except requests.exceptions.RequestException:
+                pass
+            time.sleep(attempt + 0.2)
+        else:
+            if server.poll() is None:
+                pytest.fail('Server is not responding to requests')
+            else:
+                # NOTE(kgriffs): The server did not start up; probably due to
+                #   the port being in use. We could check the output but
+                #   capsys fixture may not have buffered the error output
+                #   yet, so we just retry.
+                continue
+
+        yield base_url
+        break
+
+    else:
+        pytest.fail('Could not start server')
+
+    print('\n[Sending SIGTERM to server process...]')
+    server.terminate()
+
+    try:
+        server.communicate(timeout=10)
+    except subprocess.TimeoutExpired:
+        print('\n[Killing stubborn server process...]')
+
+        server.kill()
+        server.communicate()
+
+        pytest.fail('Server process did not exit in a timely manner and had to be killed.')
+
+
+class TestWSGIServer:
+
+    def test_get(self, server_url):
+        resp = requests.get(server_url + '/hello', timeout=_REQUEST_TIMEOUT)
+        assert resp.status_code == 200
+        assert resp.text == 'Hello, World!\n'
+        assert resp.headers.get('Content-Type') == 'text/plain; charset=utf-8'
+        assert resp.headers.get('X-Falcon') == 'peregrine'
+
+    def test_get_deprecated(self, server_url):
+        resp = requests.get(server_url + '/deprecated', timeout=_REQUEST_TIMEOUT)
+        assert resp.status_code == 200
+        assert resp.text == 'Hello, World!\n'
+        assert resp.headers.get('Content-Type') == 'text/plain; charset=utf-8'
+        assert resp.headers.get('X-Falcon') == 'deprecated'
+
+    def test_post_multipart_form(self, server_url):
+        size = random.randint(8 * _SIZE_1_MB, 15 * _SIZE_1_MB)
+        data = os.urandom(size)
+        digest = hashlib.sha1(data).hexdigest()
+        files = {
+            'random': ('random.dat', data),
+            'message': ('hello.txt', b'Hello, World!\n'),
+        }
+
+        resp = requests.post(
+            server_url + '/forms', files=files, timeout=_REQUEST_TIMEOUT)
+        assert resp.status_code == 200
+        assert resp.json() == {
+            'message': {
+                'filename': 'hello.txt',
+                'sha1': '60fde9c2310b0d4cad4dab8d126b04387efba289',
+            },
+            'random': {
+                'filename': 'random.dat',
+                'sha1': digest,
+            },
+        }
+
+    def test_static_file(self, server_url):
+        resp = requests.get(server_url + '/tests/test_wsgi_servers.py', timeout=_REQUEST_TIMEOUT)
+        assert resp.status_code == 200
+        assert resp.text.startswith(
+            'import hashlib\n'
+            'import os\n'
+            'import random\n'
+            'import subprocess\n'
+            'import sys\n'
+            'import time\n'
+        )
+        assert resp.headers.get('Content-Disposition') == (
+            'attachment; filename="test_wsgi_servers.py"')