fix(Response): Headers are case-sensitive

Web developers tend to think of headers being case-insensitive. However,
if you try to set the same header twice, with differently-cased header
names, Falcon treated them as separate headers.

This patch changes falcon.Request such that it now normalizes header
names internally, and when performing lookups.

Applications shouldn't break due to this change unless they were
accessing the private req._headers attribute directly.

Closes #185
This commit is contained in:
kgriffs
2014-01-02 12:45:58 -06:00
parent f4fcb5a9b0
commit 9a22f69e73
8 changed files with 116 additions and 74 deletions

View File

@@ -108,34 +108,43 @@ class Response(object):
Warning: Overwrites the existing value, if any. Warning: Overwrites the existing value, if any.
Args: Args:
name: Header name to set. Must be of type str or StringType, and name: Header name to set (case-insensitive). Must be of type str
only character values 0x00 through 0xFF may be used on or StringType, and only character values 0x00 through 0xFF
platforms that use wide characters. may be used on platforms that use wide characters.
value: Value for the header. Must be of type str or StringType, and value: Value for the header. Must be of type str or StringType, and
only character values 0x00 through 0xFF may be used on only character values 0x00 through 0xFF may be used on
platforms that use wide characters. platforms that use wide characters.
""" """
self._headers[name] = value # NOTE(kgriffs): normalize name by lowercasing it
self._headers[name.lower()] = value
def set_headers(self, headers): def set_headers(self, headers):
"""Set several headers at once. May be faster than set_header(). """Set several headers at once.
Warning: Overwrites existing values, if any. Warning: Overwrites existing values, if any.
Args: Args:
headers: A dict containing header names and values to set. Both headers: A dict containing header names and values to set, or
names and values must be of type str or StringType, and list of (name, value) tuples. A list can be read slightly
only character values 0x00 through 0xFF may be used on faster than a dict. Both names and values must be of type
platforms that use wide characters. str or StringType, and only character values 0x00 through
0xFF may be used on platforms that use wide characters.
Raises: Raises:
ValueError: headers was not a dictionary or list of tuples. ValueError: headers was not a dictionary or list of tuples.
""" """
self._headers.update(headers) if isinstance(headers, dict):
headers = headers.items()
# NOTE(kgriffs): We can't use dict.update because we have to
# normalize the header names.
_headers = self._headers
for name, value in headers:
_headers[name.lower()] = value
cache_control = header_property( cache_control = header_property(
'Cache-Control', 'Cache-Control',
@@ -225,16 +234,18 @@ class Response(object):
Args: Args:
media_type: Default media type to use for the Content-Type media_type: Default media type to use for the Content-Type
header if the header was not set explicitly. (default None) header if the header was not set explicitly (default None).
""" """
headers = self._headers headers = self._headers
# PERF(kgriffs): Using "in" like this is faster than using
# dict.setdefault (tested on py27).
set_content_type = (media_type is not None and set_content_type = (media_type is not None and
'Content-Type' not in headers and
'content-type' not in headers) 'content-type' not in headers)
if set_content_type: if set_content_type:
headers['Content-Type'] = media_type headers['content-type'] = media_type
return list(headers.items()) return list(headers.items())

View File

@@ -29,21 +29,23 @@ def header_property(name, doc, transform=None):
as the value of the header (default None) as the value of the header (default None)
""" """
normalized_name = name.lower()
def fget(self): def fget(self):
try: try:
return self._headers[name] return self._headers[normalized_name]
except KeyError: except KeyError:
return None return None
if transform is None: if transform is None:
def fset(self, value): def fset(self, value):
self._headers[name] = value self._headers[normalized_name] = value
else: else:
def fset(self, value): def fset(self, value):
self._headers[name] = transform(value) self._headers[normalized_name] = transform(value)
def fdel(self): def fdel(self):
del self._headers[name] del self._headers[normalized_name]
return property(fget, fset, fdel, doc) return property(fget, fset, fdel, doc)

View File

@@ -147,4 +147,4 @@ class TestHooks(testing.TestBase):
body = self.simulate_request('/one', method='OPTIONS') body = self.simulate_request('/one', method='OPTIONS')
self.assertEqual(falcon.HTTP_501, self.srmock.status) self.assertEqual(falcon.HTTP_501, self.srmock.status)
self.assertEqual([b'fluffy'], body) self.assertEqual([b'fluffy'], body)
self.assertNotIn('Allow', self.srmock.headers_dict) self.assertNotIn('allow', self.srmock.headers_dict)

View File

@@ -49,7 +49,7 @@ class TestDefaultRouting(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_405) self.assertEqual(self.srmock.status, falcon.HTTP_405)
headers = self.srmock.headers headers = self.srmock.headers
allow_header = ('Allow', 'GET, OPTIONS') allow_header = ('allow', 'GET, OPTIONS')
self.assertThat(headers, Contains(allow_header)) self.assertThat(headers, Contains(allow_header))

View File

@@ -24,7 +24,7 @@ class XmlResource:
self.content_type = content_type self.content_type = content_type
def on_get(self, req, resp): def on_get(self, req, resp):
resp.set_header('Content-Type', self.content_type) resp.set_header('content-type', self.content_type)
class DefaultContentTypeResource: class DefaultContentTypeResource:
@@ -44,6 +44,10 @@ class HeaderHelpersResource:
else: else:
self.last_modified = datetime.utcnow() self.last_modified = datetime.utcnow()
def _overwrite_headers(self, req, resp):
resp.content_type = 'x-falcon/peregrine'
resp.cache_control = ['no-store']
def on_get(self, req, resp): def on_get(self, req, resp):
resp.body = "{}" resp.body = "{}"
resp.content_type = 'x-falcon/peregrine' resp.content_type = 'x-falcon/peregrine'
@@ -68,13 +72,34 @@ class HeaderHelpersResource:
def on_head(self, req, resp): def on_head(self, req, resp):
resp.set_header('Content-Type', 'x-swallow/unladen') resp.set_header('Content-Type', 'x-swallow/unladen')
resp.set_header('X-Auth-Token', 'setecastronomy') resp.set_header('X-Auth-Token', 'setecastronomy')
resp.set_header('X-Auth-Token', 'toomanysecrets') resp.set_header('X-AUTH-TOKEN', 'toomanysecrets')
resp.content_type = 'x-falcon/peregrine'
resp.cache_control = ['no-store']
resp.location = '/things/87' resp.location = '/things/87'
del resp.location del resp.location
self._overwrite_headers(req, resp)
self.resp = resp
def on_post(self, req, resp):
resp.set_headers([
('CONTENT-TYPE', 'x-swallow/unladen'),
('X-Auth-Token', 'setecastronomy'),
('X-AUTH-TOKEN', 'toomanysecrets')
])
self._overwrite_headers(req, resp)
self.resp = resp
def on_put(self, req, resp):
resp.set_headers({
'CONTENT-TYPE': 'x-swallow/unladen',
'X-aUTH-tOKEN': 'toomanysecrets'
})
self._overwrite_headers(req, resp)
self.resp = resp self.resp = resp
@@ -115,7 +140,7 @@ class TestHeaders(testing.TestBase):
# Test Content-Length header set # Test Content-Length header set
content_length = str(len(self.resource.sample_body)) content_length = str(len(self.resource.sample_body))
content_length_header = ('Content-Length', content_length) content_length_header = ('content-length', content_length)
self.assertThat(headers, Contains(content_length_header)) self.assertThat(headers, Contains(content_length_header))
def test_default_value(self): def test_default_value(self):
@@ -219,8 +244,9 @@ class TestHeaders(testing.TestBase):
resp_headers = self.srmock.headers resp_headers = self.srmock.headers
for h in self.resource.resp_headers.items(): for name, value in self.resource.resp_headers.items():
self.assertThat(resp_headers, Contains(h)) expected = (name.lower(), value)
self.assertThat(resp_headers, Contains(expected))
def test_default_media_type(self): def test_default_media_type(self):
self.resource = DefaultContentTypeResource('Hello world!') self.resource = DefaultContentTypeResource('Hello world!')
@@ -228,7 +254,7 @@ class TestHeaders(testing.TestBase):
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
content_type = falcon.DEFAULT_MEDIA_TYPE content_type = falcon.DEFAULT_MEDIA_TYPE
self.assertIn(('Content-Type', content_type), self.srmock.headers) self.assertIn(('content-type', content_type), self.srmock.headers)
def test_custom_media_type(self): def test_custom_media_type(self):
self.resource = DefaultContentTypeResource('Hello world!') self.resource = DefaultContentTypeResource('Hello world!')
@@ -237,7 +263,7 @@ class TestHeaders(testing.TestBase):
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
content_type = 'application/atom+xml' content_type = 'application/atom+xml'
self.assertIn(('Content-Type', content_type), self.srmock.headers) self.assertIn(('content-type', content_type), self.srmock.headers)
def test_response_header_helpers_on_get(self): def test_response_header_helpers_on_get(self):
last_modified = datetime(2013, 1, 1, 10, 30, 30) last_modified = datetime(2013, 1, 1, 10, 30, 30)
@@ -249,35 +275,35 @@ class TestHeaders(testing.TestBase):
content_type = 'x-falcon/peregrine' content_type = 'x-falcon/peregrine'
self.assertEqual(content_type, resp.content_type) self.assertEqual(content_type, resp.content_type)
self.assertIn(('Content-Type', content_type), self.srmock.headers) self.assertIn(('content-type', content_type), self.srmock.headers)
cache_control = ('public, private, no-cache, no-store, ' cache_control = ('public, private, no-cache, no-store, '
'must-revalidate, proxy-revalidate, max-age=3600, ' 'must-revalidate, proxy-revalidate, max-age=3600, '
's-maxage=60, no-transform') 's-maxage=60, no-transform')
self.assertEqual(cache_control, resp.cache_control) self.assertEqual(cache_control, resp.cache_control)
self.assertIn(('Cache-Control', cache_control), self.srmock.headers) self.assertIn(('cache-control', cache_control), self.srmock.headers)
etag = 'fa0d1a60ef6616bb28038515c8ea4cb2' etag = 'fa0d1a60ef6616bb28038515c8ea4cb2'
self.assertEqual(etag, resp.etag) self.assertEqual(etag, resp.etag)
self.assertIn(('ETag', etag), self.srmock.headers) self.assertIn(('etag', etag), self.srmock.headers)
last_modified_http_date = 'Tue, 01 Jan 2013 10:30:30 GMT' last_modified_http_date = 'Tue, 01 Jan 2013 10:30:30 GMT'
self.assertEqual(last_modified_http_date, resp.last_modified) self.assertEqual(last_modified_http_date, resp.last_modified)
self.assertIn(('Last-Modified', last_modified_http_date), self.assertIn(('last-modified', last_modified_http_date),
self.srmock.headers) self.srmock.headers)
self.assertEqual('3601', resp.retry_after) self.assertEqual('3601', resp.retry_after)
self.assertIn(('Retry-After', '3601'), self.srmock.headers) self.assertIn(('retry-after', '3601'), self.srmock.headers)
self.assertEqual('/things/87', resp.location) self.assertEqual('/things/87', resp.location)
self.assertIn(('Location', '/things/87'), self.srmock.headers) self.assertIn(('location', '/things/87'), self.srmock.headers)
self.assertEqual('/things/78', resp.content_location) self.assertEqual('/things/78', resp.content_location)
self.assertIn(('Content-Location', '/things/78'), self.srmock.headers) self.assertIn(('content-location', '/things/78'), self.srmock.headers)
self.assertEqual('bytes 0-499/10240', resp.content_range) self.assertEqual('bytes 0-499/10240', resp.content_range)
self.assertIn(('Content-Range', 'bytes 0-499/10240'), self.assertIn(('content-range', 'bytes 0-499/10240'),
self.srmock.headers) self.srmock.headers)
# Check for duplicate headers # Check for duplicate headers
@@ -290,52 +316,55 @@ class TestHeaders(testing.TestBase):
self.api.add_route(self.test_route, LocationHeaderUnicodeResource()) self.api.add_route(self.test_route, LocationHeaderUnicodeResource())
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
location = ('Location', '/%C3%A7runchy/bacon') location = ('location', '/%C3%A7runchy/bacon')
self.assertIn(location, self.srmock.headers) self.assertIn(location, self.srmock.headers)
content_location = ('Content-Location', 'ab%C3%A7') content_location = ('content-location', 'ab%C3%A7')
self.assertIn(content_location, self.srmock.headers) self.assertIn(content_location, self.srmock.headers)
# Test with the values swapped # Test with the values swapped
self.simulate_request(self.test_route, method='HEAD') self.simulate_request(self.test_route, method='HEAD')
location = ('Location', 'ab%C3%A7') location = ('location', 'ab%C3%A7')
self.assertIn(location, self.srmock.headers) self.assertIn(location, self.srmock.headers)
content_location = ('Content-Location', '/%C3%A7runchy/bacon') content_location = ('content-location', '/%C3%A7runchy/bacon')
self.assertIn(content_location, self.srmock.headers) self.assertIn(content_location, self.srmock.headers)
def test_response_header_helpers_on_head(self): def test_response_set_header(self):
self.resource = HeaderHelpersResource() self.resource = HeaderHelpersResource()
self.api.add_route(self.test_route, self.resource) self.api.add_route(self.test_route, self.resource)
self.simulate_request(self.test_route, method="HEAD")
content_type = 'x-falcon/peregrine' for method in ('HEAD', 'POST', 'PUT'):
self.assertIn(('Content-Type', content_type), self.srmock.headers) self.simulate_request(self.test_route, method=method)
self.assertIn(('Cache-Control', 'no-store'), self.srmock.headers)
self.assertIn(('X-Auth-Token', 'toomanysecrets'), self.srmock.headers)
self.assertEqual(None, self.resource.resp.location) content_type = 'x-falcon/peregrine'
self.assertIn(('content-type', content_type), self.srmock.headers)
self.assertIn(('cache-control', 'no-store'), self.srmock.headers)
self.assertIn(('x-auth-token', 'toomanysecrets'),
self.srmock.headers)
# Check for duplicate headers self.assertEqual(None, self.resource.resp.location)
hist = defaultdict(lambda: 0)
for name, value in self.srmock.headers: # Check for duplicate headers
hist[name] += 1 hist = defaultdict(lambda: 0)
self.assertEqual(1, hist[name]) for name, value in self.srmock.headers:
hist[name] += 1
self.assertEqual(1, hist[name])
def test_vary_star(self): def test_vary_star(self):
self.resource = VaryHeaderResource(['*']) self.resource = VaryHeaderResource(['*'])
self.api.add_route(self.test_route, self.resource) self.api.add_route(self.test_route, self.resource)
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
self.assertIn(('Vary', '*'), self.srmock.headers) self.assertIn(('vary', '*'), self.srmock.headers)
def test_vary_header(self): def test_vary_header(self):
self.resource = VaryHeaderResource(['accept-encoding']) self.resource = VaryHeaderResource(['accept-encoding'])
self.api.add_route(self.test_route, self.resource) self.api.add_route(self.test_route, self.resource)
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
self.assertIn(('Vary', 'accept-encoding'), self.srmock.headers) self.assertIn(('vary', 'accept-encoding'), self.srmock.headers)
def test_vary_headers(self): def test_vary_headers(self):
self.resource = VaryHeaderResource(['accept-encoding', 'x-auth-token']) self.resource = VaryHeaderResource(['accept-encoding', 'x-auth-token'])
@@ -343,7 +372,7 @@ class TestHeaders(testing.TestBase):
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
vary = 'accept-encoding, x-auth-token' vary = 'accept-encoding, x-auth-token'
self.assertIn(('Vary', vary), self.srmock.headers) self.assertIn(('vary', vary), self.srmock.headers)
def test_vary_headers_tuple(self): def test_vary_headers_tuple(self):
self.resource = VaryHeaderResource(('accept-encoding', 'x-auth-token')) self.resource = VaryHeaderResource(('accept-encoding', 'x-auth-token'))
@@ -351,14 +380,14 @@ class TestHeaders(testing.TestBase):
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
vary = 'accept-encoding, x-auth-token' vary = 'accept-encoding, x-auth-token'
self.assertIn(('Vary', vary), self.srmock.headers) self.assertIn(('vary', vary), self.srmock.headers)
def test_no_content_type(self): def test_no_content_type(self):
self.resource = DefaultContentTypeResource() self.resource = DefaultContentTypeResource()
self.api.add_route(self.test_route, self.resource) self.api.add_route(self.test_route, self.resource)
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
self.assertNotIn('Content-Type', self.srmock.headers_dict) self.assertNotIn('content-type', self.srmock.headers_dict)
def test_custom_content_type(self): def test_custom_content_type(self):
content_type = 'application/xml; charset=utf-8' content_type = 'application/xml; charset=utf-8'
@@ -366,4 +395,4 @@ class TestHeaders(testing.TestBase):
self.api.add_route(self.test_route, self.resource) self.api.add_route(self.test_route, self.resource)
self.simulate_request(self.test_route) self.simulate_request(self.test_route)
self.assertIn(('Content-Type', content_type), self.srmock.headers) self.assertIn(('content-type', content_type), self.srmock.headers)

View File

@@ -96,7 +96,7 @@ class TestHelloWorld(testing.TestBase):
body = self.simulate_request(self.test_route) body = self.simulate_request(self.test_route)
resp = self.resource.resp resp = self.resource.resp
content_length = int(self.srmock.headers_dict['Content-Length']) content_length = int(self.srmock.headers_dict['content-length'])
self.assertEqual(content_length, len(self.resource.sample_utf8)) self.assertEqual(content_length, len(self.resource.sample_utf8))
self.assertEqual(self.srmock.status, self.resource.sample_status) self.assertEqual(self.srmock.status, self.resource.sample_status)
@@ -108,7 +108,7 @@ class TestHelloWorld(testing.TestBase):
body = self.simulate_request('/bytes') body = self.simulate_request('/bytes')
resp = self.bytes_resource.resp resp = self.bytes_resource.resp
content_length = int(self.srmock.headers_dict['Content-Length']) content_length = int(self.srmock.headers_dict['content-length'])
self.assertEqual(content_length, len(self.resource.sample_utf8)) self.assertEqual(content_length, len(self.resource.sample_utf8))
self.assertEqual(self.srmock.status, self.resource.sample_status) self.assertEqual(self.srmock.status, self.resource.sample_status)
@@ -120,7 +120,7 @@ class TestHelloWorld(testing.TestBase):
body = self.simulate_request('/data') body = self.simulate_request('/data')
resp = self.data_resource.resp resp = self.data_resource.resp
content_length = int(self.srmock.headers_dict['Content-Length']) content_length = int(self.srmock.headers_dict['content-length'])
self.assertEqual(content_length, len(self.resource.sample_utf8)) self.assertEqual(content_length, len(self.resource.sample_utf8))
self.assertEqual(self.srmock.status, self.resource.sample_status) self.assertEqual(self.srmock.status, self.resource.sample_status)
@@ -153,7 +153,7 @@ class TestHelloWorld(testing.TestBase):
dest.write(chunk) dest.write(chunk)
expected_len = self.stream_resource.resp.stream_len expected_len = self.stream_resource.resp.stream_len
content_length = ('Content-Length', str(expected_len)) content_length = ('content-length', str(expected_len))
self.assertThat(self.srmock.headers, Contains(content_length)) self.assertThat(self.srmock.headers, Contains(content_length))
self.assertEqual(dest.tell(), expected_len) self.assertEqual(dest.tell(), expected_len)

View File

@@ -163,7 +163,7 @@ class TestHttpMethodRouting(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_405) self.assertEqual(self.srmock.status, falcon.HTTP_405)
headers = self.srmock.headers headers = self.srmock.headers
allow_header = ('Allow', 'GET, HEAD, PUT, OPTIONS') allow_header = ('allow', 'GET, HEAD, PUT, OPTIONS')
self.assertThat(headers, Contains(allow_header)) self.assertThat(headers, Contains(allow_header))
@@ -180,7 +180,7 @@ class TestHttpMethodRouting(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_405) self.assertEqual(self.srmock.status, falcon.HTTP_405)
headers = self.srmock.headers headers = self.srmock.headers
allow_header = ('Allow', 'GET, PUT, OPTIONS') allow_header = ('allow', 'GET, PUT, OPTIONS')
self.assertThat(headers, Contains(allow_header)) self.assertThat(headers, Contains(allow_header))
@@ -189,7 +189,7 @@ class TestHttpMethodRouting(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_204) self.assertEqual(self.srmock.status, falcon.HTTP_204)
headers = self.srmock.headers headers = self.srmock.headers
allow_header = ('Allow', 'GET, HEAD, PUT') allow_header = ('allow', 'GET, HEAD, PUT')
self.assertThat(headers, Contains(allow_header)) self.assertThat(headers, Contains(allow_header))

View File

@@ -259,7 +259,7 @@ class TestHTTPError(testing.TestBase):
self.simulate_request('/401') self.simulate_request('/401')
self.assertEqual(self.srmock.status, falcon.HTTP_401) self.assertEqual(self.srmock.status, falcon.HTTP_401)
self.assertIn(('WWW-Authenticate', 'Token; UUID'), self.assertIn(('www-authenticate', 'Token; UUID'),
self.srmock.headers) self.srmock.headers)
def test_401_schemaless(self): def test_401_schemaless(self):
@@ -267,7 +267,7 @@ class TestHTTPError(testing.TestBase):
self.simulate_request('/401') self.simulate_request('/401')
self.assertEqual(self.srmock.status, falcon.HTTP_401) self.assertEqual(self.srmock.status, falcon.HTTP_401)
self.assertNotIn(('WWW-Authenticate', 'Token'), self.srmock.headers) self.assertNotIn(('www-authenticate', 'Token'), self.srmock.headers)
def test_404(self): def test_404(self):
self.api.add_route('/404', NotFoundResource()) self.api.add_route('/404', NotFoundResource())
@@ -282,7 +282,7 @@ class TestHTTPError(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_405) self.assertEqual(self.srmock.status, falcon.HTTP_405)
self.assertEqual(body, []) self.assertEqual(body, [])
self.assertIn(('Allow', 'PUT'), self.srmock.headers) self.assertIn(('allow', 'PUT'), self.srmock.headers)
def test_411(self): def test_411(self):
self.api.add_route('/411', LengthRequiredResource()) self.api.add_route('/411', LengthRequiredResource())
@@ -300,9 +300,9 @@ class TestHTTPError(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_416) self.assertEqual(self.srmock.status, falcon.HTTP_416)
self.assertEqual(body, []) self.assertEqual(body, [])
self.assertIn(('Content-Range', 'bytes */123456'), self.srmock.headers) self.assertIn(('content-range', 'bytes */123456'), self.srmock.headers)
self.assertIn(('Content-Type', 'application/xml'), self.srmock.headers) self.assertIn(('content-type', 'application/xml'), self.srmock.headers)
self.assertNotIn(('Content-Length', '0'), self.srmock.headers) self.assertNotIn(('content-length', '0'), self.srmock.headers)
def test_416_custom_media_type(self): def test_416_custom_media_type(self):
self.api.add_route('/416', RangeNotSatisfiableResource()) self.api.add_route('/416', RangeNotSatisfiableResource())
@@ -310,9 +310,9 @@ class TestHTTPError(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_416) self.assertEqual(self.srmock.status, falcon.HTTP_416)
self.assertEqual(body, []) self.assertEqual(body, [])
self.assertIn(('Content-Range', 'bytes */123456'), self.assertIn(('content-range', 'bytes */123456'),
self.srmock.headers) self.srmock.headers)
self.assertIn(('Content-Type', 'x-falcon/peregrine'), self.assertIn(('content-type', 'x-falcon/peregrine'),
self.srmock.headers) self.srmock.headers)
def test_503(self): def test_503(self):
@@ -324,7 +324,7 @@ class TestHTTPError(testing.TestBase):
self.assertEqual(self.srmock.status, falcon.HTTP_503) self.assertEqual(self.srmock.status, falcon.HTTP_503)
self.assertEqual(body, [expected_body]) self.assertEqual(body, [expected_body])
self.assertIn(('Retry-After', '60'), self.srmock.headers) self.assertIn(('retry-after', '60'), self.srmock.headers)
def test_misc(self): def test_misc(self):
self._misc_test(falcon.HTTPBadRequest, falcon.HTTP_400) self._misc_test(falcon.HTTPBadRequest, falcon.HTTP_400)