From c4742567260824ab48c8600896da66dde093ab8a Mon Sep 17 00:00:00 2001
From: Gavin Chappell <g-a-c@users.noreply.github.com>
Date: Tue, 24 Sep 2019 19:41:26 +0100
Subject: [PATCH 1/7] Retry-After support

Per RFC 6585 support returning a Retry-After header to test HTTP clients repeating requests.

MDN[1] says this can be returned in either an "absolute" format (Retry-After contains a fixed date) or "relative" format (Retry-After contains a number of seconds after which the request may be retried). Both formats are supported in this commit.

[1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
---
 httpbin/core.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 63 insertions(+), 1 deletion(-)

diff --git a/httpbin/core.py b/httpbin/core.py
index 305c9882..089e998f 100644
--- a/httpbin/core.py
+++ b/httpbin/core.py
@@ -89,7 +89,6 @@ def jsonify(*args, **kwargs):
 app.add_template_global("HTTPBIN_TRACKING" in os.environ, name="tracking_enabled")
 
 app.config["SWAGGER"] = {"title": "httpbin.org", "uiversion": 3}
-
 template = {
     "swagger": "2.0",
     "info": {
@@ -135,6 +134,10 @@ def jsonify(*args, **kwargs):
         {"name": "Cookies", "description": "Creates, reads and deletes Cookies"},
         {"name": "Images", "description": "Returns different image formats"},
         {"name": "Redirects", "description": "Returns different redirect responses"},
+        {
+            "name": "Rate limiting",
+            "description": "Test client-side rate limiting with Retry-After headers",
+        },
         {
             "name": "Anything",
             "description": "Returns anything that is passed to request",
@@ -142,6 +145,7 @@ def jsonify(*args, **kwargs):
     ],
 }
 
+
 swagger_config = {
     "headers": [],
     "specs": [
@@ -777,6 +781,64 @@ def view_status_code(codes):
     return status_code(code)
 
 
+@app.route(
+    "/retry-after/date/<seconds>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
+)
+def response_retry_after_date(seconds):
+    """Return 429 status code with a Retry-After header per RFC 6585 in Date format
+    ---
+    tags:
+      - Rate limiting
+    parameters:
+      - in: path
+        name: seconds
+    produces:
+      - text/plain
+    responses:
+      429:
+        description: Too Many Requests
+    """
+    try:
+        retry_after = int(seconds)
+    except ValueError:
+        return Response("Invalid number of seconds", status=400)
+
+    response = make_response()
+    response.status_code = 429
+    response.headers['retry-after'] = http_date(time.time() + retry_after)
+
+    return response
+
+
+@app.route(
+    "/retry-after/seconds/<seconds>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
+)
+def response_retry_after_seconds(seconds):
+    """Return 429 status code with a Retry-After header per RFC 6585
+    ---
+    tags:
+      - Rate limiting
+    parameters:
+      - in: path
+        name: seconds
+    produces:
+      - text/plain
+    responses:
+      429:
+        description: Too Many Requests
+    """
+    try:
+        retry_after = int(seconds)
+    except ValueError:
+        return Response("Invalid number of seconds", status=400)
+
+    response = make_response()
+    response.status_code = 429
+    response.headers['retry-after'] = retry_after
+
+    return response
+
+
 @app.route("/response-headers", methods=["GET", "POST"])
 def response_headers():
     """Returns a set of response headers from the query string.

From 207f184ea1ae4344148fafdfd104a66fb5827fa8 Mon Sep 17 00:00:00 2001
From: Gavin Chappell <g-a-c@users.noreply.github.com>
Date: Tue, 24 Sep 2019 19:56:38 +0100
Subject: [PATCH 2/7] add unit tests for Retry-After functionality

---
 test_httpbin.py | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/test_httpbin.py b/test_httpbin.py
index b7104ffc..3e9da7c0 100755
--- a/test_httpbin.py
+++ b/test_httpbin.py
@@ -6,6 +6,7 @@
 import contextlib
 import six
 import json
+import time
 from werkzeug.http import parse_dict_header
 from hashlib import md5, sha256, sha512
 from six import BytesIO
@@ -812,5 +813,23 @@ def test_parse_multi_value_header(self):
         self.assertEqual(parse_multi_value_header('W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"'), [ "xyzzy", "r2d2xxxx", "c3piozzzz" ])
         self.assertEqual(parse_multi_value_header('*'), [ "*" ])
 
+    def test_retry_after_seconds(self):
+        response = self.app.get('/retry-after/seconds/60')
+        self.assertEqual(response.headers.get('Retry-After'), '60')
+
+    def test_retry_after_date(self):
+        response = self.app.get('/retry-after/date/60')
+        # difficult to test the actual response, but we can at least test that it is a parseable date
+        self.assertTrue(time.strptime(response.headers.get('Retry-After'), "%a, %d %b %Y %H:%M:%S GMT"))
+
+    def test_invalid_retry_after_seconds(self):
+        response = self.app.get('/retry-after/seconds/a')
+        self.assertEqual(response.status_code, 400)
+
+    def test_invalid_retry_after_date(self):
+        response = self.app.get('/retry-after/date/a')
+        self.assertEqual(response.status_code, 400)
+
+
 if __name__ == '__main__':
     unittest.main()

From a10693b90d505ce5b0151f1d358d336b7adfe015 Mon Sep 17 00:00:00 2001
From: Gavin Chappell <g-a-c@users.noreply.github.com>
Date: Tue, 24 Sep 2019 20:43:33 +0100
Subject: [PATCH 3/7] Make tox unit tests use the same versions as pipenv

When pushing the Retry-After changes, tox was failing on unit tests that worked on my system running from Pycharm CE. It seems that the reason for this is that the pipenv environment for the repo uses an older version of werkzeug (which in turn relies on an older version of flask). Adding the same fixed versions to tox.ini allows the tox tests to pass on my system so should pass on Travis too.
---
 tox.ini | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/tox.ini b/tox.ini
index 8495eb81..6d1be1a7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,6 +2,9 @@
 envlist = py27,py36,py37
 
 [testenv]
+deps =
+    werkzeug==0.14.1
+    flask==1.0.2
 commands=python test_httpbin.py
 
 [testenv:release]

From e9982f623933ef94afeb273ba980f7cb5528327f Mon Sep 17 00:00:00 2001
From: Gavin Chappell <g-a-c@users.noreply.github.com>
Date: Thu, 13 Feb 2020 09:34:23 +0000
Subject: [PATCH 4/7] Update test_httpbin.py

---
 test_httpbin.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/test_httpbin.py b/test_httpbin.py
index 3e9da7c0..e7df6fb7 100755
--- a/test_httpbin.py
+++ b/test_httpbin.py
@@ -812,13 +812,19 @@ def test_parse_multi_value_header(self):
         self.assertEqual(parse_multi_value_header('"xyzzy", "r2d2xxxx", "c3piozzzz"'), [ "xyzzy", "r2d2xxxx", "c3piozzzz" ])
         self.assertEqual(parse_multi_value_header('W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"'), [ "xyzzy", "r2d2xxxx", "c3piozzzz" ])
         self.assertEqual(parse_multi_value_header('*'), [ "*" ])
+        
+    def test_too_many_requests(self):
+        response = self.app.get('/too-many-requests')
+        self.assertEqual(response.status_code, 429)
 
     def test_retry_after_seconds(self):
         response = self.app.get('/retry-after/seconds/60')
+        self.assertEqual(response.status_code, 429)
         self.assertEqual(response.headers.get('Retry-After'), '60')
 
     def test_retry_after_date(self):
         response = self.app.get('/retry-after/date/60')
+        self.assertEqual(response.status_code, 429)
         # difficult to test the actual response, but we can at least test that it is a parseable date
         self.assertTrue(time.strptime(response.headers.get('Retry-After'), "%a, %d %b %Y %H:%M:%S GMT"))
 

From 46c1e8840132fd71185f53341915846c92a6195f Mon Sep 17 00:00:00 2001
From: Gavin Chappell <g-a-c@users.noreply.github.com>
Date: Thu, 13 Feb 2020 09:39:04 +0000
Subject: [PATCH 5/7] Update core.py

---
 httpbin/core.py | 22 +++++++++++++++++++++-
 1 file changed, 21 insertions(+), 1 deletion(-)

diff --git a/httpbin/core.py b/httpbin/core.py
index 089e998f..a034ce35 100644
--- a/httpbin/core.py
+++ b/httpbin/core.py
@@ -136,7 +136,7 @@ def jsonify(*args, **kwargs):
         {"name": "Redirects", "description": "Returns different redirect responses"},
         {
             "name": "Rate limiting",
-            "description": "Test client-side rate limiting with Retry-After headers",
+            "description": "Test client-side rate limiting with 429 responses and optional Retry-After headers",
         },
         {
             "name": "Anything",
@@ -781,6 +781,26 @@ def view_status_code(codes):
     return status_code(code)
 
 
+@app.route(
+    "/too-many-requests", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
+)
+def response_retry_after():
+    """Return 429 status code with no Retry-After header
+    ---
+    tags:
+      - Rate limiting
+    produces:
+      - text/plain
+    responses:
+      429:
+        description: Too Many Requests
+    """
+    response = make_response()
+    response.status_code = 429
+
+    return response
+
+
 @app.route(
     "/retry-after/date/<seconds>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
 )

From ceeb2b947545d4aab2fea8037e513cda945babb8 Mon Sep 17 00:00:00 2001
From: Gavin Chappell <g-a-c@users.noreply.github.com>
Date: Thu, 13 Feb 2020 11:05:43 +0000
Subject: [PATCH 6/7] Update .travis.yml

---
 .travis.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.travis.yml b/.travis.yml
index dfad587e..0e74a846 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -14,6 +14,7 @@ matrix:
           sudo: true
 
 install:
+    - travis_retry pip install 'six>=1.12.0'
     - travis_retry pip install tox
 
 script:

From a8fd89f748c655f0ff45a131cd934716a08cafbb Mon Sep 17 00:00:00 2001
From: Gavin Chappell <g-a-c@users.noreply.github.com>
Date: Mon, 17 Feb 2020 10:50:32 +0000
Subject: [PATCH 7/7] add some more logical sounding alternative URLs

they now make more sense in English:
"retry after 60 seconds, returned as a date"
"retry after 60 seconds, returned as a number of seconds"
---
 httpbin/core.py | 6 ++++++
 test_httpbin.py | 7 +++++++
 2 files changed, 13 insertions(+)

diff --git a/httpbin/core.py b/httpbin/core.py
index a034ce35..19b4d808 100644
--- a/httpbin/core.py
+++ b/httpbin/core.py
@@ -804,6 +804,9 @@ def response_retry_after():
 @app.route(
     "/retry-after/date/<seconds>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
 )
+@app.route(
+    "/retry-after/<seconds>/date", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
+)
 def response_retry_after_date(seconds):
     """Return 429 status code with a Retry-After header per RFC 6585 in Date format
     ---
@@ -833,6 +836,9 @@ def response_retry_after_date(seconds):
 @app.route(
     "/retry-after/seconds/<seconds>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
 )
+@app.route(
+    "/retry-after/<seconds>/seconds", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]
+)
 def response_retry_after_seconds(seconds):
     """Return 429 status code with a Retry-After header per RFC 6585
     ---
diff --git a/test_httpbin.py b/test_httpbin.py
index e7df6fb7..470aaf5f 100755
--- a/test_httpbin.py
+++ b/test_httpbin.py
@@ -821,12 +821,19 @@ def test_retry_after_seconds(self):
         response = self.app.get('/retry-after/seconds/60')
         self.assertEqual(response.status_code, 429)
         self.assertEqual(response.headers.get('Retry-After'), '60')
+        response = self.app.get('/retry-after/60/seconds')
+        self.assertEqual(response.status_code, 429)
+        self.assertEqual(response.headers.get('Retry-After'), '60')
 
     def test_retry_after_date(self):
         response = self.app.get('/retry-after/date/60')
         self.assertEqual(response.status_code, 429)
         # difficult to test the actual response, but we can at least test that it is a parseable date
         self.assertTrue(time.strptime(response.headers.get('Retry-After'), "%a, %d %b %Y %H:%M:%S GMT"))
+        response = self.app.get('/retry-after/60/date')
+        self.assertEqual(response.status_code, 429)
+        # difficult to test the actual response, but we can at least test that it is a parseable date
+        self.assertTrue(time.strptime(response.headers.get('Retry-After'), "%a, %d %b %Y %H:%M:%S GMT"))
 
     def test_invalid_retry_after_seconds(self):
         response = self.app.get('/retry-after/seconds/a')