Repository: tornadoweb/tornado

Issue:
TwitterMixin.authorize_redirect hangs on network failure
When TwitterMixin.authorize_redirect fails to connect to Twitter, the future returned by this method is never resolved. (the network error is raised in some callback, but never reaches the future).

Code example (to receive a network error, you can switch your network off, but the issue is demonstrated even without connection problems due to the bogus twitter consumer data):

```
import tornado
import tornado.web
import tornado.auth


class TwitterLoginHandler(
    tornado.web.RequestHandler,
    tornado.auth.TwitterMixin
):
    async def get(self):
        if self.get_argument("oauth_token", None):
            user = await self.get_authenticated_user()
        else:
            try:
                await self.authorize_redirect()
                print("This line is never reached")
            except Exception:
                print("Nor is this line")
                

def make_app():
    return tornado.web.Application(
        [(r"/", TwitterLoginHandler)],
        twitter_consumer_key='12341234',
        twitter_consumer_secret='secret'
    )


if __name__ == "__main__":
    app = make_app()
    app.listen(8888)
    tornado.ioloop.IOLoop.current().start()
```


Discussion:
Which version do you use?

I tested in the version `5.1.0` and found the same problem. This is really caused by `tornado`'s bug: when an exception raised, the `authorize_redirect` future will never be resolved.

I also tested in the version `6.0.dev1` (not release yet, in the master branch). This problem has been solved.

So you can wait for the latest version to be released. Or you still use the old version and I can give you a temporary patch to solve this problem.

I use version 5.1. Is there any estimation when 6.0 will be released?

Currently I use some monkey-patched hack to solve this issue (and #2482).
Unless Tornado 6.0 is released soon, a fix of #2482 in version 5.x would be very appreciated.

Resolving patch:
diff --git a/tornado/auth.py b/tornado/auth.py
--- a/tornado/auth.py
+++ b/tornado/auth.py
@@ -82,7 +82,7 @@ async def get(self):
 from tornado import escape
 from tornado.httputil import url_concat
 from tornado.log import gen_log
-from tornado.stack_context import ExceptionStackContext
+from tornado.stack_context import ExceptionStackContext, wrap
 from tornado.util import unicode_type, ArgReplacer, PY3
 
 if PY3:
@@ -127,7 +127,7 @@ def wrapper(*args, **kwargs):
             warnings.warn("callback arguments are deprecated, use the returned Future instead",
                           DeprecationWarning)
             future.add_done_callback(
-                functools.partial(_auth_future_to_callback, callback))
+                wrap(functools.partial(_auth_future_to_callback, callback)))
 
         def handle_exception(typ, value, tb):
             if future.done():
@@ -202,8 +202,8 @@ def get_authenticated_user(self, callback, http_client=None):
         if http_client is None:
             http_client = self.get_auth_http_client()
         fut = http_client.fetch(url, method="POST", body=urllib_parse.urlencode(args))
-        fut.add_done_callback(functools.partial(
-            self._on_authentication_verified, callback))
+        fut.add_done_callback(wrap(functools.partial(
+            self._on_authentication_verified, callback)))
 
     def _openid_args(self, callback_uri, ax_attrs=[], oauth_scope=None):
         url = urlparse.urljoin(self.request.full_url(), callback_uri)
@@ -381,18 +381,18 @@ def authorize_redirect(self, callback_uri=None, extra_params=None,
             fut = http_client.fetch(
                 self._oauth_request_token_url(callback_uri=callback_uri,
                                               extra_params=extra_params))
-            fut.add_done_callback(functools.partial(
+            fut.add_done_callback(wrap(functools.partial(
                 self._on_request_token,
                 self._OAUTH_AUTHORIZE_URL,
                 callback_uri,
-                callback))
+                callback)))
         else:
             fut = http_client.fetch(self._oauth_request_token_url())
             fut.add_done_callback(
-                functools.partial(
+                wrap(functools.partial(
                     self._on_request_token, self._OAUTH_AUTHORIZE_URL,
                     callback_uri,
-                    callback))
+                    callback)))
 
     @_auth_return_future
     def get_authenticated_user(self, callback, http_client=None):
@@ -432,7 +432,7 @@ def get_authenticated_user(self, callback, http_client=None):
         if http_client is None:
             http_client = self.get_auth_http_client()
         fut = http_client.fetch(self._oauth_access_token_url(token))
-        fut.add_done_callback(functools.partial(self._on_access_token, callback))
+        fut.add_done_callback(wrap(functools.partial(self._on_access_token, callback)))
 
     def _oauth_request_token_url(self, callback_uri=None, extra_params=None):
         consumer_token = self._oauth_consumer_token()
@@ -515,7 +515,7 @@ def _on_access_token(self, future, response_fut):
         fut = self._oauth_get_user_future(access_token)
         fut = gen.convert_yielded(fut)
         fut.add_done_callback(
-            functools.partial(self._on_oauth_get_user, access_token, future))
+            wrap(functools.partial(self._on_oauth_get_user, access_token, future)))
 
     def _oauth_consumer_token(self):
         """Subclasses must override this to return their OAuth consumer keys.
@@ -711,7 +711,7 @@ async def get(self):
 
         if all_args:
             url += "?" + urllib_parse.urlencode(all_args)
-        callback = functools.partial(self._on_oauth2_request, callback)
+        callback = wrap(functools.partial(self._on_oauth2_request, callback))
         http = self.get_auth_http_client()
         if post_args is not None:
             fut = http.fetch(url, method="POST", body=urllib_parse.urlencode(post_args))
@@ -796,10 +796,10 @@ def authenticate_redirect(self, callback_uri=None, callback=None):
            Use the returned awaitable object instead.
         """
         http = self.get_auth_http_client()
-        http.fetch(self._oauth_request_token_url(callback_uri=callback_uri),
-                   functools.partial(
-                       self._on_request_token, self._OAUTH_AUTHENTICATE_URL,
-                       None, callback))
+        fut = http.fetch(self._oauth_request_token_url(callback_uri=callback_uri))
+        fut.add_done_callback(wrap(functools.partial(
+            self._on_request_token, self._OAUTH_AUTHENTICATE_URL,
+            None, callback)))
 
     @_auth_return_future
     def twitter_request(self, path, callback=None, access_token=None,
@@ -863,7 +863,7 @@ async def get(self):
         if args:
             url += "?" + urllib_parse.urlencode(args)
         http = self.get_auth_http_client()
-        http_callback = functools.partial(self._on_twitter_request, callback, url)
+        http_callback = wrap(functools.partial(self._on_twitter_request, callback, url))
         if post_args is not None:
             fut = http.fetch(url, method="POST", body=urllib_parse.urlencode(post_args))
         else:
@@ -977,7 +977,7 @@ async def get(self):
                          method="POST",
                          headers={'Content-Type': 'application/x-www-form-urlencoded'},
                          body=body)
-        fut.add_done_callback(functools.partial(self._on_access_token, callback))
+        fut.add_done_callback(wrap(functools.partial(self._on_access_token, callback)))
 
     def _on_access_token(self, future, response_fut):
         """Callback function for the exchange to the access token."""
@@ -1061,8 +1061,8 @@ async def get(self):
             fields.update(extra_fields)
 
         fut = http.fetch(self._oauth_request_token_url(**args))
-        fut.add_done_callback(functools.partial(self._on_access_token, redirect_uri, client_id,
-                                                client_secret, callback, fields))
+        fut.add_done_callback(wrap(functools.partial(self._on_access_token, redirect_uri, client_id,
+                                                     client_secret, callback, fields)))
 
     @gen.coroutine
     def _on_access_token(self, redirect_uri, client_id, client_secret,
diff --git a/tornado/concurrent.py b/tornado/concurrent.py
--- a/tornado/concurrent.py
+++ b/tornado/concurrent.py
@@ -519,10 +519,10 @@ async def caller():
     """
     warnings.warn("@return_future is deprecated, use coroutines instead",
                   DeprecationWarning)
-    return _non_deprecated_return_future(f)
+    return _non_deprecated_return_future(f, warn=True)
 
 
-def _non_deprecated_return_future(f):
+def _non_deprecated_return_future(f, warn=False):
     # Allow auth.py to use this decorator without triggering
     # deprecation warnings. This will go away once auth.py has removed
     # its legacy interfaces in 6.0.
@@ -539,7 +539,15 @@ def handle_error(typ, value, tb):
             future_set_exc_info(future, (typ, value, tb))
             return True
         exc_info = None
-        with ExceptionStackContext(handle_error, delay_warning=True):
+        esc = ExceptionStackContext(handle_error, delay_warning=True)
+        with esc:
+            if not warn:
+                # HACK: In non-deprecated mode (only used in auth.py),
+                # suppress the warning entirely. Since this is added
+                # in a 5.1 patch release and already removed in 6.0
+                # I'm prioritizing a minimial change instead of a
+                # clean solution.
+                esc.delay_warning = False
             try:
                 result = f(*args, **kwargs)
                 if result is not None:


Test patch:
diff --git a/tornado/test/auth_test.py b/tornado/test/auth_test.py
--- a/tornado/test/auth_test.py
+++ b/tornado/test/auth_test.py
@@ -6,6 +6,7 @@
 
 from __future__ import absolute_import, division, print_function
 
+import unittest
 import warnings
 
 from tornado.auth import (
@@ -16,11 +17,16 @@
 from tornado.escape import json_decode
 from tornado import gen
 from tornado.httputil import url_concat
-from tornado.log import gen_log
+from tornado.log import gen_log, app_log
 from tornado.testing import AsyncHTTPTestCase, ExpectLog
 from tornado.test.util import ignore_deprecation
 from tornado.web import RequestHandler, Application, asynchronous, HTTPError
 
+try:
+    from unittest import mock
+except ImportError:
+    mock = None
+
 
 class OpenIdClientLoginHandlerLegacy(RequestHandler, OpenIdMixin):
     def initialize(self, test):
@@ -221,6 +227,7 @@ def initialize(self, test):
         self._OAUTH_REQUEST_TOKEN_URL = test.get_url('/oauth1/server/request_token')
         self._OAUTH_ACCESS_TOKEN_URL = test.get_url('/twitter/server/access_token')
         self._OAUTH_AUTHORIZE_URL = test.get_url('/oauth1/server/authorize')
+        self._OAUTH_AUTHENTICATE_URL = test.get_url('/twitter/server/authenticate')
         self._TWITTER_BASE_URL = test.get_url('/twitter/api')
 
     def get_auth_http_client(self):
@@ -254,6 +261,20 @@ def get(self):
         yield self.authorize_redirect()
 
 
+class TwitterClientAuthenticateHandler(TwitterClientHandler):
+    # Like TwitterClientLoginHandler, but uses authenticate_redirect
+    # instead of authorize_redirect.
+    @gen.coroutine
+    def get(self):
+        if self.get_argument("oauth_token", None):
+            user = yield self.get_authenticated_user()
+            if user is None:
+                raise Exception("user is None")
+            self.finish(user)
+            return
+        yield self.authenticate_redirect()
+
+
 class TwitterClientLoginGenEngineHandler(TwitterClientHandler):
     with ignore_deprecation():
         @asynchronous
@@ -376,6 +397,7 @@ def get_app(self):
 
                 ('/legacy/twitter/client/login', TwitterClientLoginHandlerLegacy, dict(test=self)),
                 ('/twitter/client/login', TwitterClientLoginHandler, dict(test=self)),
+                ('/twitter/client/authenticate', TwitterClientAuthenticateHandler, dict(test=self)),
                 ('/twitter/client/login_gen_engine',
                  TwitterClientLoginGenEngineHandler, dict(test=self)),
                 ('/twitter/client/login_gen_coroutine',
@@ -511,6 +533,14 @@ def test_oauth10a_redirect(self):
             '_oauth_request_token="enhjdg==|MTIzNA=="' in response.headers['Set-Cookie'],
             response.headers['Set-Cookie'])
 
+    @unittest.skipIf(mock is None, 'mock package not present')
+    def test_oauth10a_redirect_error(self):
+        with mock.patch.object(OAuth1ServerRequestTokenHandler, 'get') as get:
+            get.side_effect = Exception("boom")
+            with ExpectLog(app_log, "Uncaught exception"):
+                response = self.fetch('/oauth10a/client/login', follow_redirects=False)
+            self.assertEqual(response.code, 500)
+
     def test_oauth10a_get_user(self):
         response = self.fetch(
             '/oauth10a/client/login?oauth_token=zxcv',
@@ -573,6 +603,16 @@ def test_twitter_redirect_gen_engine(self):
     def test_twitter_redirect_gen_coroutine(self):
         self.base_twitter_redirect('/twitter/client/login_gen_coroutine')
 
+    def test_twitter_authenticate_redirect(self):
+        response = self.fetch('/twitter/client/authenticate', follow_redirects=False)
+        self.assertEqual(response.code, 302)
+        self.assertTrue(response.headers['Location'].endswith(
+            '/twitter/server/authenticate?oauth_token=zxcv'), response.headers['Location'])
+        # the cookie is base64('zxcv')|base64('1234')
+        self.assertTrue(
+            '_oauth_request_token="enhjdg==|MTIzNA=="' in response.headers['Set-Cookie'],
+            response.headers['Set-Cookie'])
+
     def test_twitter_get_user(self):
         response = self.fetch(
             '/twitter/client/login?oauth_token=zxcv',