Repository: falconry/falcon

Issue:
TypeError raised when passing unicode string literal to set_header (uWSGI)
The following raises an error:

``` python
import falcon

class MyResource(object):
    def on_get(self, req, resp):
        resp.set_header('X-Name', u'value')
        resp.body = 'Hello world!'


application = api = falcon.API()

api.add_route('/', MyResource())
```

Traceback:

```
Traceback (most recent call last):
  File "/Users/kgriffs/Code/falcon/falcon/api.py", line 265, in __call__
    start_response(resp.status, headers)
TypeError: http header value must be a string
```

Work items:
- Add a type check and convert the incoming value to `str`. Fence this code based on python version (no need to add the overhead of type checking in py3k)
- Add a performance warning to the docstring for py2 users



Discussion:
I'll take a stab at this

From what I can tell, uwsgi raises TypeError when either the header or value isn't a string. gunicorn, on the other hand, doesn't run into this because it str()-fies everything.

We neglected to address this for the header property setters.


Resolving patch:
diff --git a/falcon/response.py b/falcon/response.py
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -22,8 +22,12 @@
 # See issue https://github.com/falconry/falcon/issues/556
 from six.moves import http_cookies
 
-from falcon.response_helpers import format_range, header_property
-from falcon.response_helpers import is_ascii_encodable
+from falcon.response_helpers import (
+    format_header_value_list,
+    format_range,
+    header_property,
+    is_ascii_encodable,
+)
 from falcon.util import dt_to_http, TimezoneGMT
 from falcon.util.uri import encode as uri_encode
 from falcon.util.uri import encode_value as uri_encode_value
@@ -346,11 +350,17 @@ def set_header(self, name, value):
             name (str): Header name (case-insensitive). The restrictions
                 noted below for the header's value also apply here.
             value (str): Value for the header. Must be of type ``str`` or
-                ``StringType`` and contain only ISO-8859-1 characters.
+                ``StringType`` and contain only US-ASCII characters.
                 Under Python 2.x, the ``unicode`` type is also accepted,
-                although such strings are also limited to ISO-8859-1.
+                although such strings are also limited to US-ASCII.
         """
-        name, value = self._encode_header(name, value)
+        if PY2:
+            # NOTE(kgriffs): uwsgi fails with a TypeError if any header
+            # is not a str, so do the conversion here. It's actually
+            # faster to not do an isinstance check. str() will encode
+            # to US-ASCII.
+            name = str(name)
+            value = str(value)
 
         # NOTE(kgriffs): normalize name by lowercasing it
         self._headers[name.lower()] = value
@@ -370,12 +380,18 @@ def append_header(self, name, value):
             name (str): Header name (case-insensitive). The restrictions
                 noted below for the header's value also apply here.
             value (str): Value for the header. Must be of type ``str`` or
-                ``StringType`` and contain only ISO-8859-1 characters.
+                ``StringType`` and contain only US-ASCII characters.
                 Under Python 2.x, the ``unicode`` type is also accepted,
-                although such strings are also limited to ISO-8859-1.
+                although such strings are also limited to US-ASCII.
 
         """
-        name, value = self._encode_header(name, value)
+        if PY2:
+            # NOTE(kgriffs): uwsgi fails with a TypeError if any header
+            # is not a str, so do the conversion here. It's actually
+            # faster to not do an isinstance check. str() will encode
+            # to US-ASCII.
+            name = str(name)
+            value = str(value)
 
         name = name.lower()
         if name in self._headers:
@@ -393,9 +409,9 @@ def set_headers(self, headers):
             headers (dict or list): A dictionary of header names and values
                 to set, or a ``list`` of (*name*, *value*) tuples. Both *name*
                 and *value* must be of type ``str`` or ``StringType`` and
-                contain only ISO-8859-1 characters. Under Python 2.x, the
+                contain only US-ASCII characters. Under Python 2.x, the
                 ``unicode`` type is also accepted, although such strings are
-                also limited to ISO-8859-1.
+                also limited to US-ASCII.
 
                 Note:
                     Falcon can process a list of tuples slightly faster
@@ -412,9 +428,21 @@ def set_headers(self, headers):
         # NOTE(kgriffs): We can't use dict.update because we have to
         # normalize the header names.
         _headers = self._headers
-        for name, value in headers:
-            name, value = self._encode_header(name, value)
-            _headers[name.lower()] = value
+
+        if PY2:
+            for name, value in headers:
+                # NOTE(kgriffs): uwsgi fails with a TypeError if any header
+                # is not a str, so do the conversion here. It's actually
+                # faster to not do an isinstance check. str() will encode
+                # to US-ASCII.
+                name = str(name)
+                value = str(value)
+
+                _headers[name.lower()] = value
+
+        else:
+            for name, value in headers:
+                _headers[name.lower()] = value
 
     def add_link(self, target, rel, title=None, title_star=None,
                  anchor=None, hreflang=None, type_hint=None):
@@ -523,6 +551,13 @@ def add_link(self, target, rel, title=None, title_star=None,
         if anchor is not None:
             value += '; anchor="' + uri_encode(anchor) + '"'
 
+        if PY2:
+            # NOTE(kgriffs): uwsgi fails with a TypeError if any header
+            # is not a str, so do the conversion here. It's actually
+            # faster to not do an isinstance check. str() will encode
+            # to US-ASCII.
+            value = str(value)
+
         _headers = self._headers
         if 'link' in _headers:
             _headers['link'] += ', ' + value
@@ -538,7 +573,7 @@ def add_link(self, target, rel, title=None, title_star=None,
         the value for the header.
 
         """,
-        lambda v: ', '.join(v))
+        format_header_value_list)
 
     content_location = header_property(
         'Content-Location',
@@ -623,7 +658,7 @@ def add_link(self, target, rel, title=None, title_star=None,
         See also: http://goo.gl/NGHdL
 
         """,
-        lambda v: ', '.join(v))
+        format_header_value_list)
 
     accept_ranges = header_property(
         'Accept-Ranges',
@@ -643,16 +678,6 @@ def add_link(self, target, rel, title=None, title_star=None,
 
         """)
 
-    def _encode_header(self, name, value, py2=PY2):
-        if py2:
-            if isinstance(name, unicode):
-                name = name.encode('ISO-8859-1')
-
-            if isinstance(value, unicode):
-                value = value.encode('ISO-8859-1')
-
-        return name, value
-
     def _wsgi_headers(self, media_type=None, py2=PY2):
         """Convert headers into the format expected by WSGI servers.
 
diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py
--- a/falcon/response_helpers.py
+++ b/falcon/response_helpers.py
@@ -14,6 +14,8 @@
 
 """Utilities for the Response class."""
 
+import six
+
 
 def header_property(name, doc, transform=None):
     """Creates a header getter/setter.
@@ -36,8 +38,12 @@ def fget(self):
             return None
 
     if transform is None:
-        def fset(self, value):
-            self._headers[normalized_name] = value
+        if six.PY2:
+            def fset(self, value):
+                self._headers[normalized_name] = str(value)
+        else:
+            def fset(self, value):
+                self._headers[normalized_name] = value
     else:
         def fset(self, value):
             self._headers[normalized_name] = transform(value)
@@ -59,9 +65,26 @@ def format_range(value):
     # string concatenation, and str.join() in this case.
 
     if len(value) == 4:
-        return '%s %s-%s/%s' % (value[3], value[0], value[1], value[2])
+        result = '%s %s-%s/%s' % (value[3], value[0], value[1], value[2])
+    else:
+        result = 'bytes %s-%s/%s' % (value[0], value[1], value[2])
+
+    if six.PY2:
+        # NOTE(kgriffs): In case one of the values was a unicode
+        # string, convert back to str
+        result = str(result)
+
+    return result
+
 
-    return 'bytes %s-%s/%s' % (value[0], value[1], value[2])
+if six.PY2:
+    def format_header_value_list(iterable):
+        """Joins an iterable of strings with commas."""
+        return str(', '.join(iterable))
+else:
+    def format_header_value_list(iterable):
+        """Joins an iterable of strings with commas."""
+        return ', '.join(iterable)
 
 
 def is_ascii_encodable(s):


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -109,14 +109,16 @@ def on_get(self, req, resp):
             (u'X-auTH-toKEN', 'toomanysecrets'),
             ('Content-TYpE', u'application/json'),
             (u'X-symBOl', u'@'),
+        ])
+
+    def on_post(self, req, resp):
+        resp.set_headers([
+            (u'X-symb\u00F6l', 'thing'),
+        ])
 
-            # TODO(kgriffs): This will cause the wsgiref validator
-            # to raise an error. Falcon itself does not currently
-            # check for non-ASCII chars to save some CPU cycles. The
-            # app is responsible for doing the right thing, and
-            # validating its own output as needed.
-            #
-            # (u'X-symb\u00F6l', u'\u00FF'),
+    def on_put(self, req, resp):
+        resp.set_headers([
+            ('X-Thing', u'\u00FF'),
         ])
 
 
@@ -321,7 +323,8 @@ def test_response_header_helpers_on_get(self):
         self.assertEqual(resp.content_range, content_range)
         self.assertEqual(result.headers['Content-Range'], content_range)
 
-        resp.content_range = (1, 499, 10 * 1024, 'bytes')
+        resp.content_range = (1, 499, 10 * 1024, u'bytes')
+        self.assertIsInstance(resp.content_range, str)
         self.assertEqual(resp.content_range, 'bytes 1-499/10240')
 
         self.assertEqual(resp.accept_ranges, 'bytes')
@@ -350,7 +353,7 @@ def test_unicode_location_headers(self):
                          '/%C3%A7runchy/bacon')
         self.assertEqual(result.headers['Location'], 'ab%C3%A7')
 
-    def test_unicode_headers(self):
+    def test_unicode_headers_convertable(self):
         self.api.add_route('/', UnicodeHeaderResource())
 
         result = self.simulate_get('/')
@@ -359,6 +362,14 @@ def test_unicode_headers(self):
         self.assertEqual(result.headers['X-Auth-Token'], 'toomanysecrets')
         self.assertEqual(result.headers['X-Symbol'], '@')
 
+    def test_unicode_headers_not_convertable(self):
+        if six.PY3:
+            self.skipTest('Test only applies to Python 2')
+
+        self.api.add_route('/', UnicodeHeaderResource())
+        self.assertRaises(UnicodeEncodeError, self.simulate_post, '/')
+        self.assertRaises(UnicodeEncodeError, self.simulate_put, '/')
+
     def test_response_set_and_get_header(self):
         resource = HeaderHelpersResource()
         self.api.add_route('/', resource)
@@ -401,8 +412,8 @@ def test_vary_star(self):
 
     @ddt.data(
         (['accept-encoding'], 'accept-encoding'),
-        (['accept-encoding', 'x-auth-token'], 'accept-encoding, x-auth-token'),
-        (('accept-encoding', 'x-auth-token'), 'accept-encoding, x-auth-token'),
+        ([u'accept-encoding', 'x-auth-token'], 'accept-encoding, x-auth-token'),
+        (('accept-encoding', u'x-auth-token'), 'accept-encoding, x-auth-token'),
     )
     @ddt.unpack
     def test_vary_header(self, vary, expected_value):