Search code examples
pythonunit-testinghttp-redirectflaskflask-testing

How to avoid "ClientRedirectError: loop detected" error during testing Flask application (and how is it triggered)?


Environment:

  • Python 3.6.1
  • Flask 0.12.2
  • Werkzeug 0.14.1

While writing tests for my Flask application, I have discovered the following peculiarity: if in tests for Flask application you will be redirected to the same url two times 'in a row' ClientRedirectError: loop detected will be thrown, even if it stops redirecting after the second redirect (i.e. loop is not, actually, happening).

Consider the following, simplified, example:

app.py

from flask import (
    Flask,
    redirect,
    url_for,
    session
)

app = Flask(__name__)
app.secret_key = 'improper secret key'

@app.route('/')
def index():
    if not session.get('test', False):
        session['test'] = True
        return redirect(url_for('index'))
    else:
        return "Hello"

@app.route('/redirection/')
def redirection():
    # do something — login user, for example
    return redirect(url_for('index'))

test_redirect.py

from unittest import TestCase
from app import app

class RedirectTestCase(TestCase):

def setUp(self):
    self.test_client = app.test_client()

def testLoop(self):
    response = self.test_client.get('/redirection/', follow_redirects=True)
    self.assertTrue('Hello'.encode('ascii') in response.data)

Now, if I will run the test — it'll throw a ClientRedirectError: loop detected (even though, it could be seen from the code that second redirect will happen only once).

If I just run the app and go to the /redirection/ — it takes me to the index (i.e. /) with no problem, and no looping is happening.

The reason I need if not session.get('test', False): in index(), is because in my app I'm using it to set some things in session, in case user accessing / for the first time. As suggested by comment in code, in my 'real' app redirection() is a function that logs user in.


My questions are:

  1. Is there a 'right' way to overcome throwing of ClientRedirectError: loop detected in similar cases (i.e. is it possible to make the test run & pass)?
  2. Is there a better/'more correct' way to setup things in session for the 'first-time' user?
  3. Can mentioned behaviour be considered a bug in werkzeug (i.e. actual looping is not happening, but ClientRedirectError: loop detected is thrown, still)?

Workaround, I have came up with (which does not answer my questions, still):

def testLoop(self):
    self.test_client.get('/redirection/') # removed follow_redirects=True
    response = self.test_client.get('/', follow_redirects=True) # navigating to '/', directly 
    self.assertTrue('Hello'.encode('ascii') in response.data)

This might look redundant, but it's just a simplified example (it'll, probably, make more sense if self.test_client.get('/redirection/') would be replaced with something like self.test_client.post('/login/', data=dict(username='user', password='pass')).


Solution

  • It seems, I am ready now (almost 1.5 yeast after posting them) to answer my own questions:

    1. It is possible to make test run & pass.
      Given the code, as it is above, the workaround, provided at the end of the question will do, though it can be shortened to:

      def testLoop(self):
          response = self.test_client.get('/', follow_redirects=True) # navigating to '/', directly 
          self.assertTrue('Hello'.encode('ascii') in response.data)
      

      in this particular case, there is no need for the self.test_client.get('/redirection/'), line. Though, if some changes were made to session['test'] inside the /redirection/, like so:

      @app.route('/redirection/')
      def redirection():
          session['test'] = True
          return redirect(url_for('index'))
      

      then, self.test_client.get('/redirection/'), would be necessary, so test would become:

      from unittest import TestCase
      from app import app
      
      class RedirectTestCase(TestCase):
      
      def setUp(self):
          self.test_client = app.test_client()
      
      def testLoop(self):
          self.test_client.get('/redirection/')
          response = self.test_client.get('/', follow_redirects=True)
      
          self.assertTrue('Hello'.encode('ascii') in response.data)
      

      This, so called, "workaround" is just preventing test from doing two redirects to /, in a row (which is exactly what is causing ClientRedirectError: loop detected — see answer to point 3, below).

    2. If there is a better way or not — will depend on the exact details of the app implementation. In the example from the question, the redirection inside index is, actually, unnecessary and can be removed:

      @app.route('/')
      def index():
          session['test'] = session.get('test', True)
          return "Hello"
      

      Such change, by the way, will also make the initial test (from the question) pass.

    3. As far as I can tell, such behaviour is "by design", namely: ClientRedirectError: loop detected is raised, when redirection to the same url occurs (at least) two times in a row. Below are some examples.

      This, test won't encounter ClientRedirectError (as redirection only happens once):

      isolated_app.py

      from flask import (
          Flask,
          redirect,
          url_for,
          session
          )
      
      app = Flask(__name__)
      app.secret_key = 'improper secret key'
      
      @app.route('/')
      def index():
          session['test'] = session.get('test', 0)
          if session['test'] < 1:
              session['test'] += 1
              return redirect(url_for('index'))
          else:
              return "Hello"
      

      test.py

      from unittest import TestCase
      from isolated_app import app
      
      class RedirectTestCase(TestCase):
      
      def setUp(self):
          self.test_client = app.test_client()
      
      def testLoop(self):
          response = self.test_client.get('/', follow_redirects=True)
          self.assertTrue('Hello'.encode('ascii') in response.data)
      

      Though, if the code of the app will be changed to:

      from flask import (
          Flask,
          redirect,
          url_for,
          session
          )
      
      app = Flask(__name__)
      app.secret_key = 'improper secret key'
      
      @app.route('/')
      def index():
          session['test'] = session.get('test', 0)
          if session['test'] < 1:
              session['test'] += 1
              return redirect(url_for('index'))
          elif session['test'] < 2:
              session['test'] += 1
              return redirect(url_for('index'))
          else:
              return "Hello"
      

      then test will fail, throwing werkzeug.test.ClientRedirectError: loop detected.

      It is also worth mentioning, that triggering of redirection loop errors is different for Flask tests and for real-life browsing. If we will run the last app, and go to / in browser — it will readily return Hello to us. Amount of allowed redirects is defined by each browser's developer (for example in FireFox it is defined by network.http.redirection-limit preference from about:config, and 20 is a default value). Here is an illustration:

      from flask import (
          Flask,
          redirect,
          url_for,
          session
          )
      
      app = Flask(__name__)
      app.secret_key = 'improper secret key'
      
      @app.route('/')
      def index():
          session['test'] = session.get('test', 0)
          while True:
              session['test'] = session.get('test', 0)
              session['test'] += 1
              print(session['test'])
              return redirect(url_for('index'))
      

      This will print 1 2, and then fail with werkzeug.test.ClientRedirectError: loop detected, if we will try to run the test for it. On the other hand, if we will run this app and go to / in FireFox — browser will show us The page isn’t redirecting properly message, and app will print 1 2 3 4 5 6 ... 21, in console.