Repository: falconry/falcon

Issue:
Add HTTPTooManyRequests exception
Implementation of http status code `429`, following the suggestions in  [rfc6585](http://tools.ietf.org/html/rfc6585#section-4)



Discussion:

Resolving patch:
diff --git a/falcon/errors.py b/falcon/errors.py
--- a/falcon/errors.py
+++ b/falcon/errors.py
@@ -252,7 +252,7 @@ class HTTPRequestEntityTooLarge(HTTPError):
         retry_after (datetime or int, optional): Value for the Retry-After
             header. If a ``datetime`` object, will serialize as an HTTP date.
             Otherwise, a non-negative ``int`` is expected, representing the
-            number of seconds to wait. See also: http://goo.gl/DIrWr .
+            number of seconds to wait.
         kwargs (optional): Same as for ``HTTPError``.
 
     """
@@ -262,7 +262,6 @@ def __init__(self, title, description, retry_after=None, **kwargs):
 
         if isinstance(retry_after, datetime):
             headers['Retry-After'] = util.dt_to_http(retry_after)
-
         elif retry_after is not None:
             headers['Retry-After'] = str(retry_after)
 
@@ -324,6 +323,44 @@ def __init__(self, title, description, **kwargs):
                                                       description, **kwargs)
 
 
+class HTTPTooManyRequests(HTTPError):
+    """429 Too Many Requests.
+
+    The user has sent too many requests in a given amount of time
+    ("rate limiting").
+
+    The response representations SHOULD include details explaining the
+    condition, and MAY include a Retry-After header indicating how long
+    to wait before making a new request.
+
+    (RFC 6585)
+
+    Args:
+        title (str): Error title (e.g., 'Too Many Requests').
+        description (str): Human-friendly description of the rate limit that
+            was exceeded.
+        retry_after (datetime or int, optional): Value for the Retry-After
+            header. If a ``datetime`` object, will serialize as an HTTP date.
+            Otherwise, a non-negative ``int`` is expected, representing the
+            number of seconds to wait.
+        kwargs (optional): Same as for ``HTTPError``.
+
+    """
+
+    def __init__(self, title, description, retry_after=None, **kwargs):
+        headers = kwargs.setdefault('headers', {})
+
+        if isinstance(retry_after, datetime):
+            headers['Retry-After'] = util.dt_to_http(retry_after)
+        elif retry_after is not None:
+            headers['Retry-After'] = str(retry_after)
+
+        super(HTTPTooManyRequests, self).__init__(status.HTTP_429,
+                                                  title,
+                                                  description,
+                                                  **kwargs)
+
+
 class HTTPInternalServerError(HTTPError):
     """500 Internal Server Error.
 
@@ -367,7 +404,7 @@ class HTTPServiceUnavailable(HTTPError):
         retry_after (datetime or int): Value for the Retry-After header. If a
             ``datetime`` object, will serialize as an HTTP date. Otherwise,
             a non-negative ``int`` is expected, representing the number of
-            seconds to wait. See also: http://goo.gl/DIrWr .
+            seconds to wait.
         kwargs (optional): Same as for ``HTTPError``.
 
     """


Test patch:
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -159,6 +159,17 @@ def on_get(self, req, resp):
         raise falcon.HTTPRangeNotSatisfiable(123456)
 
 
+class TooManyRequestsResource:
+
+    def __init__(self, retry_after=None):
+        self.retry_after = retry_after
+
+    def on_get(self, req, resp):
+        raise falcon.HTTPTooManyRequests('Too many requests',
+                                         '1 per minute',
+                                         retry_after=self.retry_after)
+
+
 class ServiceUnavailableResource:
 
     def __init__(self, retry_after):
@@ -583,6 +594,38 @@ def test_416(self):
         self.assertIn(('content-range', 'bytes */123456'), self.srmock.headers)
         self.assertIn(('content-length', '0'), self.srmock.headers)
 
+    def test_429_no_retry_after(self):
+        self.api.add_route('/429', TooManyRequestsResource())
+        body = self.simulate_request('/429')
+        parsed_body = json.loads(body[0].decode())
+
+        self.assertEqual(self.srmock.status, falcon.HTTP_429)
+        self.assertEqual(parsed_body['title'], 'Too many requests')
+        self.assertEqual(parsed_body['description'], '1 per minute')
+        self.assertNotIn('retry-after', self.srmock.headers)
+
+    def test_429(self):
+        self.api.add_route('/429', TooManyRequestsResource(60))
+        body = self.simulate_request('/429')
+        parsed_body = json.loads(body[0].decode())
+
+        self.assertEqual(self.srmock.status, falcon.HTTP_429)
+        self.assertEqual(parsed_body['title'], 'Too many requests')
+        self.assertEqual(parsed_body['description'], '1 per minute')
+        self.assertIn(('retry-after', '60'), self.srmock.headers)
+
+    def test_429_datetime(self):
+        date = datetime.datetime.now() + datetime.timedelta(minutes=1)
+        self.api.add_route('/429', TooManyRequestsResource(date))
+        body = self.simulate_request('/429')
+        parsed_body = json.loads(body[0].decode())
+
+        self.assertEqual(self.srmock.status, falcon.HTTP_429)
+        self.assertEqual(parsed_body['title'], 'Too many requests')
+        self.assertEqual(parsed_body['description'], '1 per minute')
+        self.assertIn(('retry-after', falcon.util.dt_to_http(date)),
+                      self.srmock.headers)
+
     def test_503_integer_retry_after(self):
         self.api.add_route('/503', ServiceUnavailableResource(60))
         body = self.simulate_request('/503', decode='utf-8')