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')