From 9a0bc333750ce4006596ef788a24e06cb798d0bb Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 15:22:14 +0100 Subject: [PATCH 01/28] Started implementing a new Locust HTTP client that uses geventhttpclient (https://github.com/gwik/geventhttpclient/) --- locust/contrib/__init__.py | 0 locust/contrib/geventhttpclient.py | 112 +++++++++++++++++++ locust/test/test_geventhttpclient.py | 158 +++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 locust/contrib/__init__.py create mode 100644 locust/contrib/geventhttpclient.py create mode 100644 locust/test/test_geventhttpclient.py diff --git a/locust/contrib/__init__.py b/locust/contrib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/locust/contrib/geventhttpclient.py b/locust/contrib/geventhttpclient.py new file mode 100644 index 0000000000..511fca074e --- /dev/null +++ b/locust/contrib/geventhttpclient.py @@ -0,0 +1,112 @@ +from time import time +from http.cookiejar import CookieJar + +from geventhttpclient.useragent import UserAgent, CompatRequest, CompatResponse, ConnectionError + +from locust import events +from locust.core import Locust + + +# Monkey patch geventhttpclient.useragent.CompatRequest so that Cookiejar works with Python >= 3.3 +# More info: https://github.com/requests/requests/pull/871 +CompatRequest.unverifiable = False + + +class GeventHttpLocust(Locust): + """ + Represents an HTTP "user" which is to be hatched and attack the system that is to be load tested. + + The behaviour of this user is defined by the task_set attribute, which should point to a + :py:class:`TaskSet ` class. + + This class creates a *client* attribute on instantiation which is an HTTP client with support + for keeping a user session between requests. + """ + + client = None + """ + Instance of HttpSession that is created upon instantiation of Locust. + The client support cookies, and therefore keeps the session between HTTP requests. + """ + + def __init__(self): + super(GeventHttpLocust, self).__init__() + if self.host is None: + raise LocustError("You must specify the base host. Either in the host attribute in the Locust class, or on the command line using the --host option.") + + self.client = GeventHttpSession(base_url=self.host) + + +class LocustErrorResponse(object): + content = None + def __init__(self, error): + self.error = error + self.status_code = 0 + + +class GeventHttpSession(object): + def __init__(self, base_url): + self.base_url = base_url + self.cookiejar = CookieJar() + self.client = UserAgent(max_retries=1, cookiejar=self.cookiejar) + + def request(self, method, path, name=None, **kwargs): + url = self.base_url + path + + # store meta data that is used when reporting the request to locust's statistics + request_meta = {} + # set up pre_request hook for attaching meta data to the request object + request_meta["method"] = method + request_meta["start_time"] = time() + request_meta["name"] = name or path + + try: + response = self.client.urlopen(url, method=method, **kwargs) + except (ConnectionError, ConnectionRefusedError) as e: + # record the consumed time + request_meta["response_time"] = int((time() - request_meta["start_time"]) * 1000) + events.request_failure.fire( + request_type=request_meta["method"], + name=request_meta["name"], + response_time=request_meta["response_time"], + exception=e, + ) + if hasattr(e, "response"): + return e.response + else: + return LocustErrorResponse(e) + else: + # get the length of the content, but if the argument stream is set to True, we take + # the size from the content-length header, in order to not trigger fetching of the body + if kwargs.get("stream", False): + request_meta["content_size"] = int(response.headers.get("content-length") or 0) + else: + request_meta["content_size"] = len(response.content or "") + + # record the consumed time + request_meta["response_time"] = int((time() - request_meta["start_time"]) * 1000) + events.request_success.fire( + request_type=request_meta["method"], + name=request_meta["name"], + response_time=request_meta["response_time"], + response_length=request_meta["content_size"], + ) + return response + + def delete(self, path, **kwargs): + return self.request("DELETE", path, **kwargs) + + def get(self, path, **kwargs): + return self.request("GET", path, **kwargs) + + def head(self, path, **kwargs): + return self.request("HEAD", path, **kwargs) + + def options(self, path, **kwargs): + return self.request("OPTIONS", path, **kwargs) + + def post(self, path, data=None, **kwargs): + return self.request("POST", path, payload=data, **kwargs) + + def put(self, path, data=None, **kwargs): + return self.request("PUT", path, payload=data, **kwargs) diff --git a/locust/test/test_geventhttpclient.py b/locust/test/test_geventhttpclient.py new file mode 100644 index 0000000000..beb9de37d7 --- /dev/null +++ b/locust/test/test_geventhttpclient.py @@ -0,0 +1,158 @@ +import six + +from locust.contrib.geventhttpclient import GeventHttpSession, GeventHttpLocust +from locust.stats import global_stats + +from .testcases import WebserverTestCase + + +class TestGeventHttpSession(WebserverTestCase): + def test_get(self): + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + r = s.get("/ultra_fast") + self.assertEqual(200, r.status_code) + + def test_connection_error(self): + global_stats.clear_all() + s = GeventHttpSession("http://localhost:1") + r = s.get("/", timeout=0.1) + self.assertEqual(r.status_code, 0) + self.assertEqual(None, r.content) + self.assertTrue(isinstance(r.error, ConnectionRefusedError)) + self.assertEqual(1, len(global_stats.errors)) + self.assertTrue(isinstance(six.next(six.itervalues(global_stats.errors)).error, ConnectionRefusedError)) + + def test_404(self): + global_stats.clear_all() + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + r = s.get("/does_not_exist") + self.assertEqual(404, r.status_code) + self.assertEqual(1, global_stats.get("/does_not_exist", "GET").num_failures) + + def test_streaming_response(self): + """ + Test a request to an endpoint that returns a streaming response + """ + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + r = s.get("/streaming/30") + + # verify that the time reported includes the download time of the whole streamed response + self.assertGreater(global_stats.get("/streaming/30", method="GET").avg_response_time, 250) + global_stats.clear_all() + + # verify that response time does NOT include whole download time, when using stream=True + r = s.get("/streaming/30", stream=True) + self.assertGreater(global_stats.get("/streaming/30", method="GET").avg_response_time, 0) + self.assertLess(global_stats.get("/streaming/30", method="GET").avg_response_time, 250) + + # download the content of the streaming response (so we don't get an ugly exception in the log) + _ = r.content + + def test_slow_redirect(self): + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + url = "/redirect?url=/redirect?delay=0.5" + r = s.get(url) + stats = global_stats.get(url, method="GET") + self.assertEqual(1, stats.num_requests) + self.assertGreater(stats.avg_response_time, 500) + + def test_post_redirect(self): + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + url = "/redirect" + r = s.post(url) + self.assertEqual(200, r.status_code) + post_stats = global_stats.get(url, method="POST") + get_stats = global_stats.get(url, method="GET") + self.assertEqual(1, post_stats.num_requests) + self.assertEqual(0, get_stats.num_requests) + + def test_cookie(self): + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + r = s.post("/set_cookie?name=testcookie&value=1337") + self.assertEqual(200, r.status_code) + r = s.get("/get_cookie?name=testcookie") + self.assertEqual('1337', r.content.decode()) + + def test_head(self): + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + r = s.head("/request_method") + self.assertEqual(200, r.status_code) + self.assertEqual("", r.content.decode()) + + def test_delete(self): + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + r = s.delete("/request_method") + self.assertEqual(200, r.status_code) + self.assertEqual("DELETE", r.content.decode()) + + def test_options(self): + s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + r = s.options("/request_method") + self.assertEqual(200, r.status_code) + self.assertEqual("", r.content.decode()) + self.assertEqual( + set(["OPTIONS", "DELETE", "PUT", "GET", "POST", "HEAD"]), + set(r.headers["allow"].split(", ")), + ) + + +class TestRequestStatsWithWebserver(WebserverTestCase): + def test_request_stats_content_length(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + locust.client.get("/ultra_fast") + self.assertEqual(global_stats.get("/ultra_fast", "GET").avg_content_length, len("This is an ultra fast response")) + locust.client.get("/ultra_fast") + self.assertEqual(global_stats.get("/ultra_fast", "GET").avg_content_length, len("This is an ultra fast response")) + + def test_request_stats_no_content_length(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + l = MyLocust() + path = "/no_content_length" + r = l.client.get(path) + self.assertEqual(global_stats.get(path, "GET").avg_content_length, len("This response does not have content-length in the header")) + + def test_request_stats_no_content_length_streaming(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + l = MyLocust() + path = "/no_content_length" + r = l.client.get(path, stream=True) + self.assertEqual(0, global_stats.get(path, "GET").avg_content_length) + + def test_request_stats_named_endpoint(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + locust.client.get("/ultra_fast", name="my_custom_name") + self.assertEqual(1, global_stats.get("my_custom_name", "GET").num_requests) + + def test_request_stats_query_variables(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + locust.client.get("/ultra_fast?query=1") + self.assertEqual(1, global_stats.get("/ultra_fast?query=1", "GET").num_requests) + + def test_request_stats_put(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + locust.client.put("/put") + self.assertEqual(1, global_stats.get("/put", "PUT").num_requests) + + def test_request_connection_error(self): + class MyLocust(GeventHttpLocust): + host = "http://localhost:1" + + locust = MyLocust() + response = locust.client.get("/", timeout=0.1) + self.assertEqual(response.status_code, 0) + self.assertEqual(1, global_stats.get("/", "GET").num_failures) + self.assertEqual(0, global_stats.get("/", "GET").num_requests) From 5de56c98348569b837c56530bb5def5d11488446 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 16:55:34 +0100 Subject: [PATCH 02/28] Added geventhttpclient to tests_require in setup.py. This makes the tests work without adding a dependency on having a working C compiler environment set up when installing Locust (since geventhttpclient package doesn't have wheels). --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d59fa4b87d..7d472fd3bb 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ zip_safe=False, install_requires=["gevent>=1.2.2", "flask>=0.10.1", "requests>=2.9.1", "msgpack-python>=0.4.2", "six>=1.10.0", "pyzmq>=16.0.2"], test_suite="locust.test", - tests_require=['unittest2', 'mock'], + tests_require=['unittest2', 'mock', 'geventhttpclient'], entry_points={ 'console_scripts': [ 'locust = locust.main:main', From 9fd6621c58948d2d5ef11d17451d26a9e14650db Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 17:00:10 +0100 Subject: [PATCH 03/28] Added geventhttpclient to tox.ini --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 3b2519e3ec..bf6269304d 100644 --- a/tox.ini +++ b/tox.ini @@ -8,5 +8,6 @@ deps = mock pyzmq unittest2 + geventhttpclient commands = coverage run -m unittest2 discover [] From 0a8af7bc6817d9561da270f0cc8c1957fe5f0378 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 17:17:19 +0100 Subject: [PATCH 04/28] Python 2 fixes --- locust/contrib/geventhttpclient.py | 17 +++++++++++++++-- locust/test/test_geventhttpclient.py | 9 +++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/locust/contrib/geventhttpclient.py b/locust/contrib/geventhttpclient.py index 511fca074e..5b3ecf08c4 100644 --- a/locust/contrib/geventhttpclient.py +++ b/locust/contrib/geventhttpclient.py @@ -1,5 +1,18 @@ +from __future__ import absolute_import + +import errno +import six +import socket from time import time -from http.cookiejar import CookieJar + +if six.PY2: + from cookielib import CookieJar + class ConnectionRefusedError(Exception): + # ConnectionRefusedError doesn't exist in python 2, so we'll + # define a dummy class to avoid a NameError + pass +else: + from http.cookiejar import CookieJar from geventhttpclient.useragent import UserAgent, CompatRequest, CompatResponse, ConnectionError @@ -62,7 +75,7 @@ def request(self, method, path, name=None, **kwargs): try: response = self.client.urlopen(url, method=method, **kwargs) - except (ConnectionError, ConnectionRefusedError) as e: + except (ConnectionError, ConnectionRefusedError, socket.error) as e: # record the consumed time request_meta["response_time"] = int((time() - request_meta["start_time"]) * 1000) events.request_failure.fire( diff --git a/locust/test/test_geventhttpclient.py b/locust/test/test_geventhttpclient.py index beb9de37d7..632d9e8b66 100644 --- a/locust/test/test_geventhttpclient.py +++ b/locust/test/test_geventhttpclient.py @@ -1,4 +1,5 @@ import six +import socket from locust.contrib.geventhttpclient import GeventHttpSession, GeventHttpLocust from locust.stats import global_stats @@ -18,9 +19,13 @@ def test_connection_error(self): r = s.get("/", timeout=0.1) self.assertEqual(r.status_code, 0) self.assertEqual(None, r.content) - self.assertTrue(isinstance(r.error, ConnectionRefusedError)) self.assertEqual(1, len(global_stats.errors)) - self.assertTrue(isinstance(six.next(six.itervalues(global_stats.errors)).error, ConnectionRefusedError)) + if six.PY2: + self.assertTrue(isinstance(r.error, socket.error)) + self.assertTrue(isinstance(six.next(six.itervalues(global_stats.errors)).error, socket.error)) + else: + self.assertTrue(isinstance(r.error, ConnectionRefusedError)) + self.assertTrue(isinstance(six.next(six.itervalues(global_stats.errors)).error, ConnectionRefusedError)) def test_404(self): global_stats.clear_all() From 84bb9881e32f9359e1ea616fa57093c004303a33 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 18:21:40 +0100 Subject: [PATCH 05/28] Use timeit.default_timer() instead of time.time() --- locust/contrib/geventhttpclient.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/locust/contrib/geventhttpclient.py b/locust/contrib/geventhttpclient.py index 5b3ecf08c4..26e9212f10 100644 --- a/locust/contrib/geventhttpclient.py +++ b/locust/contrib/geventhttpclient.py @@ -3,7 +3,7 @@ import errno import six import socket -from time import time +from timeit import default_timer if six.PY2: from cookielib import CookieJar @@ -70,14 +70,14 @@ def request(self, method, path, name=None, **kwargs): request_meta = {} # set up pre_request hook for attaching meta data to the request object request_meta["method"] = method - request_meta["start_time"] = time() + request_meta["start_time"] = default_timer() request_meta["name"] = name or path try: response = self.client.urlopen(url, method=method, **kwargs) except (ConnectionError, ConnectionRefusedError, socket.error) as e: # record the consumed time - request_meta["response_time"] = int((time() - request_meta["start_time"]) * 1000) + request_meta["response_time"] = int((default_timer() - request_meta["start_time"]) * 1000) events.request_failure.fire( request_type=request_meta["method"], name=request_meta["name"], @@ -97,7 +97,7 @@ def request(self, method, path, name=None, **kwargs): request_meta["content_size"] = len(response.content or "") # record the consumed time - request_meta["response_time"] = int((time() - request_meta["start_time"]) * 1000) + request_meta["response_time"] = int((default_timer() - request_meta["start_time"]) * 1000) events.request_success.fire( request_type=request_meta["method"], name=request_meta["name"], From 255c76c5ad2e2f2c851b320cf729c422ebe21dcc Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 18:23:32 +0100 Subject: [PATCH 06/28] Added text property to Response objects returned by GeventHttpSession. The text property function tries to decode the text in the same way as python-requests. This makes the API more similar to HttpSession. Added more tests. --- locust/contrib/geventhttpclient.py | 54 +++++++++++++-- locust/test/test_geventhttpclient.py | 98 ++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/locust/contrib/geventhttpclient.py b/locust/contrib/geventhttpclient.py index 26e9212f10..dce052fb09 100644 --- a/locust/contrib/geventhttpclient.py +++ b/locust/contrib/geventhttpclient.py @@ -1,6 +1,7 @@ from __future__ import absolute_import -import errno +import chardet +import re import six import socket from timeit import default_timer @@ -11,6 +12,7 @@ class ConnectionRefusedError(Exception): # ConnectionRefusedError doesn't exist in python 2, so we'll # define a dummy class to avoid a NameError pass + str = unicode else: from http.cookiejar import CookieJar @@ -25,6 +27,9 @@ class ConnectionRefusedError(Exception): CompatRequest.unverifiable = False +absolute_http_url_regexp = re.compile(r"^https?://", re.I) + + class GeventHttpLocust(Locust): """ Represents an HTTP "user" which is to be hatched and attack the system that is to be load tested. @@ -61,10 +66,18 @@ class GeventHttpSession(object): def __init__(self, base_url): self.base_url = base_url self.cookiejar = CookieJar() - self.client = UserAgent(max_retries=1, cookiejar=self.cookiejar) - + self.client = LocustUserAgent(max_retries=1, cookiejar=self.cookiejar) + + def _build_url(self, path): + """ prepend url with hostname unless it's already an absolute URL """ + if absolute_http_url_regexp.match(path): + return path + else: + return "%s%s" % (self.base_url, path) + def request(self, method, path, name=None, **kwargs): - url = self.base_url + path + # prepend url with hostname unless it's already an absolute URL + url = self._build_url(path) # store meta data that is used when reporting the request to locust's statistics request_meta = {} @@ -123,3 +136,36 @@ def post(self, path, data=None, **kwargs): def put(self, path, data=None, **kwargs): return self.request("PUT", path, payload=data, **kwargs) + + +class LocustCompatResponse(CompatResponse): + @property + def text(self): + # Decode unicode from detected encoding. + try: + content = str(self.content, self.apparent_encoding, errors='replace') + except (LookupError, TypeError): + # A LookupError is raised if the encoding was not found which could + # indicate a misspelling or similar mistake. + # + # A TypeError can be raised if encoding is None + # + # Fallback to decode without specifying encoding + content = str(self.content, errors='replace') + return content + + @property + def apparent_encoding(self): + """The apparent encoding, provided by the chardet library.""" + return chardet.detect(self.content)['encoding'] + + +class LocustUserAgent(UserAgent): + response_type = LocustCompatResponse + + def _urlopen(self, request): + """Override _urlopen() in order to make it use the response_type attribute""" + client = self.clientpool.get_client(request.url_split) + resp = client.request(request.method, request.url_split.request_uri, + body=request.payload, headers=request.headers) + return self.response_type(resp, request=request, sent_request=resp._sent_request) diff --git a/locust/test/test_geventhttpclient.py b/locust/test/test_geventhttpclient.py index 632d9e8b66..54e3d9ffad 100644 --- a/locust/test/test_geventhttpclient.py +++ b/locust/test/test_geventhttpclient.py @@ -1,6 +1,8 @@ import six import socket +from locust import TaskSet, task +from locust.core import LocustError from locust.contrib.geventhttpclient import GeventHttpSession, GeventHttpLocust from locust.stats import global_stats @@ -161,3 +163,99 @@ class MyLocust(GeventHttpLocust): self.assertEqual(response.status_code, 0) self.assertEqual(1, global_stats.get("/", "GET").num_failures) self.assertEqual(0, global_stats.get("/", "GET").num_requests) + + +class TestGeventHttpLocustClass(WebserverTestCase): + def test_get_request(self): + self.response = "" + def t1(l): + self.response = l.client.get("/ultra_fast") + class MyLocust(GeventHttpLocust): + tasks = [t1] + host = "http://127.0.0.1:%i" % self.port + + my_locust = MyLocust() + t1(my_locust) + self.assertEqual(self.response.text, "This is an ultra fast response") + + def test_client_request_headers(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + self.assertEqual("hello", locust.client.get("/request_header_test", headers={"X-Header-Test":"hello"}).text) + + def test_client_get(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + self.assertEqual("GET", locust.client.get("/request_method").text) + + def test_client_get_absolute_url(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + self.assertEqual("GET", locust.client.get("http://127.0.0.1:%i/request_method" % self.port).text) + + def test_client_post(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + self.assertEqual("POST", locust.client.post("/request_method", {"arg":"hello world"}).text) + self.assertEqual("hello world", locust.client.post("/post", {"arg":"hello world"}).text) + + def test_client_put(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + self.assertEqual("PUT", locust.client.put("/request_method", {"arg":"hello world"}).text) + self.assertEqual("hello world", locust.client.put("/put", {"arg":"hello world"}).text) + + def test_client_delete(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + self.assertEqual("DELETE", locust.client.delete("/request_method").text) + self.assertEqual(200, locust.client.delete("/request_method").status_code) + + def test_client_head(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + locust = MyLocust() + self.assertEqual(200, locust.client.head("/request_method").status_code) + + def test_log_request_name_argument(self): + from locust.stats import global_stats + self.response = "" + + class MyLocust(GeventHttpLocust): + tasks = [] + host = "http://127.0.0.1:%i" % self.port + + @task() + def t1(l): + self.response = l.client.get("/ultra_fast", name="new name!") + + my_locust = MyLocust() + my_locust.t1() + + self.assertEqual(1, global_stats.get("new name!", "GET").num_requests) + self.assertEqual(0, global_stats.get("/ultra_fast", "GET").num_requests) + + def test_redirect_url_original_path_as_name(self): + class MyLocust(GeventHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + l = MyLocust() + l.client.get("/redirect") + + from locust.stats import global_stats + self.assertEqual(1, len(global_stats.entries)) + self.assertEqual(1, global_stats.get("/redirect", "GET").num_requests) + self.assertEqual(0, global_stats.get("/ultra_fast", "GET").num_requests) From cce8c668315e17673b97ffb35d9eceea3f2f3155 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:00:17 +0100 Subject: [PATCH 07/28] Fixed wrong link target --- docs/writing-a-locustfile.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/writing-a-locustfile.rst b/docs/writing-a-locustfile.rst index a63afba61f..3a8f2d5ec4 100644 --- a/docs/writing-a-locustfile.rst +++ b/docs/writing-a-locustfile.rst @@ -305,7 +305,7 @@ instance's TaskSet instances so that it's easy to retrieve the client and make H tasks. Here's a simple example that makes a GET request to the */about* path (in this case we assume *self* -is an instance of a :py:class:`TaskSet ` or :py:class:`HttpLocust ` +is an instance of a :py:class:`TaskSet ` or :py:class:`HttpLocust ` class:: response = self.client.get("/about") From 0351db89663ef036f2565235ed1d6d998f540663 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:03:21 +0100 Subject: [PATCH 08/28] Renamed GeventHttpLocust -> FasHttpLocust and GeventHttpSession -> FastHttpSession --- locust/contrib/geventhttpclient.py | 8 ++-- locust/test/test_geventhttpclient.py | 60 ++++++++++++++-------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/locust/contrib/geventhttpclient.py b/locust/contrib/geventhttpclient.py index dce052fb09..eee12b571c 100644 --- a/locust/contrib/geventhttpclient.py +++ b/locust/contrib/geventhttpclient.py @@ -30,7 +30,7 @@ class ConnectionRefusedError(Exception): absolute_http_url_regexp = re.compile(r"^https?://", re.I) -class GeventHttpLocust(Locust): +class FastHttpLocust(Locust): """ Represents an HTTP "user" which is to be hatched and attack the system that is to be load tested. @@ -48,11 +48,11 @@ class GeventHttpLocust(Locust): """ def __init__(self): - super(GeventHttpLocust, self).__init__() + super(FastHttpLocust, self).__init__() if self.host is None: raise LocustError("You must specify the base host. Either in the host attribute in the Locust class, or on the command line using the --host option.") - self.client = GeventHttpSession(base_url=self.host) + self.client = FastHttpSession(base_url=self.host) class LocustErrorResponse(object): @@ -62,7 +62,7 @@ def __init__(self, error): self.status_code = 0 -class GeventHttpSession(object): +class FastHttpSession(object): def __init__(self, base_url): self.base_url = base_url self.cookiejar = CookieJar() diff --git a/locust/test/test_geventhttpclient.py b/locust/test/test_geventhttpclient.py index 54e3d9ffad..223ff39066 100644 --- a/locust/test/test_geventhttpclient.py +++ b/locust/test/test_geventhttpclient.py @@ -3,21 +3,21 @@ from locust import TaskSet, task from locust.core import LocustError -from locust.contrib.geventhttpclient import GeventHttpSession, GeventHttpLocust +from locust.contrib.geventhttpclient import FastHttpSession, FastHttpLocust from locust.stats import global_stats from .testcases import WebserverTestCase -class TestGeventHttpSession(WebserverTestCase): +class TestFastHttpSession(WebserverTestCase): def test_get(self): - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.get("/ultra_fast") self.assertEqual(200, r.status_code) def test_connection_error(self): global_stats.clear_all() - s = GeventHttpSession("http://localhost:1") + s = FastHttpSession("http://localhost:1") r = s.get("/", timeout=0.1) self.assertEqual(r.status_code, 0) self.assertEqual(None, r.content) @@ -31,7 +31,7 @@ def test_connection_error(self): def test_404(self): global_stats.clear_all() - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.get("/does_not_exist") self.assertEqual(404, r.status_code) self.assertEqual(1, global_stats.get("/does_not_exist", "GET").num_failures) @@ -40,7 +40,7 @@ def test_streaming_response(self): """ Test a request to an endpoint that returns a streaming response """ - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.get("/streaming/30") # verify that the time reported includes the download time of the whole streamed response @@ -56,7 +56,7 @@ def test_streaming_response(self): _ = r.content def test_slow_redirect(self): - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) url = "/redirect?url=/redirect?delay=0.5" r = s.get(url) stats = global_stats.get(url, method="GET") @@ -64,7 +64,7 @@ def test_slow_redirect(self): self.assertGreater(stats.avg_response_time, 500) def test_post_redirect(self): - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) url = "/redirect" r = s.post(url) self.assertEqual(200, r.status_code) @@ -74,26 +74,26 @@ def test_post_redirect(self): self.assertEqual(0, get_stats.num_requests) def test_cookie(self): - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.post("/set_cookie?name=testcookie&value=1337") self.assertEqual(200, r.status_code) r = s.get("/get_cookie?name=testcookie") self.assertEqual('1337', r.content.decode()) def test_head(self): - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.head("/request_method") self.assertEqual(200, r.status_code) self.assertEqual("", r.content.decode()) def test_delete(self): - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.delete("/request_method") self.assertEqual(200, r.status_code) self.assertEqual("DELETE", r.content.decode()) def test_options(self): - s = GeventHttpSession("http://127.0.0.1:%i" % self.port) + s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.options("/request_method") self.assertEqual(200, r.status_code) self.assertEqual("", r.content.decode()) @@ -105,7 +105,7 @@ def test_options(self): class TestRequestStatsWithWebserver(WebserverTestCase): def test_request_stats_content_length(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -115,7 +115,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual(global_stats.get("/ultra_fast", "GET").avg_content_length, len("This is an ultra fast response")) def test_request_stats_no_content_length(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port l = MyLocust() path = "/no_content_length" @@ -123,7 +123,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual(global_stats.get(path, "GET").avg_content_length, len("This response does not have content-length in the header")) def test_request_stats_no_content_length_streaming(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port l = MyLocust() path = "/no_content_length" @@ -131,7 +131,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual(0, global_stats.get(path, "GET").avg_content_length) def test_request_stats_named_endpoint(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -139,7 +139,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual(1, global_stats.get("my_custom_name", "GET").num_requests) def test_request_stats_query_variables(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -147,7 +147,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual(1, global_stats.get("/ultra_fast?query=1", "GET").num_requests) def test_request_stats_put(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -155,7 +155,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual(1, global_stats.get("/put", "PUT").num_requests) def test_request_connection_error(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://localhost:1" locust = MyLocust() @@ -165,12 +165,12 @@ class MyLocust(GeventHttpLocust): self.assertEqual(0, global_stats.get("/", "GET").num_requests) -class TestGeventHttpLocustClass(WebserverTestCase): +class TestFastHttpLocustClass(WebserverTestCase): def test_get_request(self): self.response = "" def t1(l): self.response = l.client.get("/ultra_fast") - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): tasks = [t1] host = "http://127.0.0.1:%i" % self.port @@ -179,28 +179,28 @@ class MyLocust(GeventHttpLocust): self.assertEqual(self.response.text, "This is an ultra fast response") def test_client_request_headers(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() self.assertEqual("hello", locust.client.get("/request_header_test", headers={"X-Header-Test":"hello"}).text) def test_client_get(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() self.assertEqual("GET", locust.client.get("/request_method").text) def test_client_get_absolute_url(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() self.assertEqual("GET", locust.client.get("http://127.0.0.1:%i/request_method" % self.port).text) def test_client_post(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -208,7 +208,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual("hello world", locust.client.post("/post", {"arg":"hello world"}).text) def test_client_put(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -216,7 +216,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual("hello world", locust.client.put("/put", {"arg":"hello world"}).text) def test_client_delete(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -224,7 +224,7 @@ class MyLocust(GeventHttpLocust): self.assertEqual(200, locust.client.delete("/request_method").status_code) def test_client_head(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port locust = MyLocust() @@ -234,7 +234,7 @@ def test_log_request_name_argument(self): from locust.stats import global_stats self.response = "" - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): tasks = [] host = "http://127.0.0.1:%i" % self.port @@ -249,7 +249,7 @@ def t1(l): self.assertEqual(0, global_stats.get("/ultra_fast", "GET").num_requests) def test_redirect_url_original_path_as_name(self): - class MyLocust(GeventHttpLocust): + class MyLocust(FastHttpLocust): host = "http://127.0.0.1:%i" % self.port l = MyLocust() From f1e5761b21474a3a7bf5505a16564cd06da2c477 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:04:52 +0100 Subject: [PATCH 09/28] Renamed files --- locust/contrib/{geventhttpclient.py => fasthttp.py} | 0 locust/test/{test_geventhttpclient.py => test_fasthttp.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename locust/contrib/{geventhttpclient.py => fasthttp.py} (100%) rename locust/test/{test_geventhttpclient.py => test_fasthttp.py} (100%) diff --git a/locust/contrib/geventhttpclient.py b/locust/contrib/fasthttp.py similarity index 100% rename from locust/contrib/geventhttpclient.py rename to locust/contrib/fasthttp.py diff --git a/locust/test/test_geventhttpclient.py b/locust/test/test_fasthttp.py similarity index 100% rename from locust/test/test_geventhttpclient.py rename to locust/test/test_fasthttp.py From a33a4f506d06d485863bb9cdcae19ab5211d7d84 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:05:24 +0100 Subject: [PATCH 10/28] Fixed import --- locust/test/test_fasthttp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locust/test/test_fasthttp.py b/locust/test/test_fasthttp.py index 223ff39066..585c72e83d 100644 --- a/locust/test/test_fasthttp.py +++ b/locust/test/test_fasthttp.py @@ -3,7 +3,7 @@ from locust import TaskSet, task from locust.core import LocustError -from locust.contrib.geventhttpclient import FastHttpSession, FastHttpLocust +from locust.contrib.fasthttp import FastHttpSession, FastHttpLocust from locust.stats import global_stats from .testcases import WebserverTestCase From 7fb8dd5c3cd6d12322ee566f886f5101ab37bb30 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:24:33 +0100 Subject: [PATCH 11/28] Renamed response class --- locust/contrib/fasthttp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index eee12b571c..6f07f5ddce 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -138,7 +138,7 @@ def put(self, path, data=None, **kwargs): return self.request("PUT", path, payload=data, **kwargs) -class LocustCompatResponse(CompatResponse): +class FastResponse(CompatResponse): @property def text(self): # Decode unicode from detected encoding. @@ -161,7 +161,7 @@ def apparent_encoding(self): class LocustUserAgent(UserAgent): - response_type = LocustCompatResponse + response_type = FastResponse def _urlopen(self, request): """Override _urlopen() in order to make it use the response_type attribute""" From c5e061844e87a95cfe35b1a6855a738f28b2574d Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:36:31 +0100 Subject: [PATCH 12/28] Added FastHttpSession.patch() --- locust/contrib/fasthttp.py | 4 ++++ locust/test/test_fasthttp.py | 8 +++++++- locust/test/testcases.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index 6f07f5ddce..b9c6bf4efc 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -131,6 +131,10 @@ def head(self, path, **kwargs): def options(self, path, **kwargs): return self.request("OPTIONS", path, **kwargs) + def patch(self, path, data=None, **kwargs): + """Sends a POST request""" + return self.request("PATCH", path, payload=data, **kwargs) + def post(self, path, data=None, **kwargs): return self.request("POST", path, payload=data, **kwargs) diff --git a/locust/test/test_fasthttp.py b/locust/test/test_fasthttp.py index 585c72e83d..8cd971d010 100644 --- a/locust/test/test_fasthttp.py +++ b/locust/test/test_fasthttp.py @@ -92,13 +92,19 @@ def test_delete(self): self.assertEqual(200, r.status_code) self.assertEqual("DELETE", r.content.decode()) + def test_patch(self): + s = FastHttpSession("http://127.0.0.1:%i" % self.port) + r = s.patch("/request_method") + self.assertEqual(200, r.status_code) + self.assertEqual("PATCH", r.content.decode()) + def test_options(self): s = FastHttpSession("http://127.0.0.1:%i" % self.port) r = s.options("/request_method") self.assertEqual(200, r.status_code) self.assertEqual("", r.content.decode()) self.assertEqual( - set(["OPTIONS", "DELETE", "PUT", "GET", "POST", "HEAD"]), + set(["OPTIONS", "DELETE", "PUT", "GET", "POST", "HEAD", "PATCH"]), set(r.headers["allow"].split(", ")), ) diff --git a/locust/test/testcases.py b/locust/test/testcases.py index 270542b9e2..2b7a97303d 100644 --- a/locust/test/testcases.py +++ b/locust/test/testcases.py @@ -57,7 +57,7 @@ def consistent(): gevent.sleep(0.2) return "This is a consistent response" -@app.route("/request_method", methods=["POST", "GET", "HEAD", "PUT", "DELETE"]) +@app.route("/request_method", methods=["POST", "GET", "HEAD", "PUT", "DELETE", "PATCH"]) def request_method(): return request.method From 5f464a154354786ca0c3f8999ef3138cb50fe6ce Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:40:57 +0100 Subject: [PATCH 13/28] Started writing docs for FastHttpLocust --- docs/increase-performance.rst | 67 +++++++++++++++++++++++++++++ docs/index.rst | 1 + docs/running-locust-distributed.rst | 7 +++ docs/writing-a-locustfile.rst | 2 + locust/contrib/fasthttp.py | 12 ++++++ 5 files changed, 89 insertions(+) create mode 100644 docs/increase-performance.rst diff --git a/docs/increase-performance.rst b/docs/increase-performance.rst new file mode 100644 index 0000000000..5ef437ee0f --- /dev/null +++ b/docs/increase-performance.rst @@ -0,0 +1,67 @@ +.. _increase-performance: + +============================================================== +Increase Locust's performance with a faster HTTP client +============================================================== + +Locust's default HTTP client uses `python-requests `_. +The reason for this is that requests is a very well-maintained python package, that +provides a really nice API, that many python developers are familiar with. Therefore, +in many cases, we recommend that you use the default :py:class:`HttpLocust ` +which uses requests. However, if your're planning to run really large scale scale tests, +Locust comes with an alternative HTTP client, +:py:class:`FastHttpLocust ` which +uses `geventhttpclient `_ instead of requests. +This client is significantly faster, and we've seen 5x-6x performance increases for +HTTP-requests. + + +Known limitations +================= + +* :py:class:`FastHttpLocust ` does not + support the :ref:`catch-response ` argument. +* Basic auth is currently not supported. + + +How to use FastHttpLocust +=========================== + +First, you need to install the geventhttplocust python package:: + + pip install geventhttpclient + +Then you just subclass FastHttpLocust instead of HttpLocust:: + + from locust import TaskSet, task + from locust.contrib.fasthttp import FastHttpLocust + + class MyTaskSet(TaskSet): + @task + def index(self): + response = self.client.get("/") + + class MyLocust(FastHttpLocust): + task_set = MyTaskSet + min_wait = 1000 + max_wait = 60000 + + +.. note:: + + FastHttpLocust uses a whole other HTTP client implementation, with a different API, compared to + the default HttpLocust that uses python-requests. Therefore FastHttpLocust might not work as a d + rop-in replacement for HttpLocust, depending on how the HttpClient is used. + + +API +=== + +FastHttpSession class +===================== + +.. autoclass:: locust.contrib.fasthttp.FastHttpSession + :members: __init__, request, get, post, delete, put, head, options, patch + +.. autoclass:: locust.contrib.fasthttp.FastResponse + :members: content, text, headers diff --git a/docs/index.rst b/docs/index.rst index 86e4f2ae43..49f0c7968d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,6 +31,7 @@ Locust Documentation running-locust-distributed running-locust-without-web-ui + increase-performance .. toctree :: :maxdepth: 1 diff --git a/docs/running-locust-distributed.rst b/docs/running-locust-distributed.rst index 4f2633e80d..f76eb14e8a 100644 --- a/docs/running-locust-distributed.rst +++ b/docs/running-locust-distributed.rst @@ -85,3 +85,10 @@ Running Locust distributed without the web UI ============================================= See :ref:`running-locust-distributed-without-web-ui` + + +Increase Locust's performance +============================= + +If your planning to run large-scale load tests you might be interested to use the alternative +HTTP client that's shipped with Locust. You can read more about it here: :ref:`increase-performance` diff --git a/docs/writing-a-locustfile.rst b/docs/writing-a-locustfile.rst index 3a8f2d5ec4..7b6c8674d4 100644 --- a/docs/writing-a-locustfile.rst +++ b/docs/writing-a-locustfile.rst @@ -324,6 +324,8 @@ Response object. The request will be reported as a failure in Locust's statistic Response's *content* attribute will be set to None, and its *status_code* will be 0. +.. _catch-response: + Manually controlling if a request should be considered successful or a failure ------------------------------------------------------------------------------ diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index b9c6bf4efc..250a2bb174 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -123,12 +123,15 @@ def delete(self, path, **kwargs): return self.request("DELETE", path, **kwargs) def get(self, path, **kwargs): + """Sends a GET request""" return self.request("GET", path, **kwargs) def head(self, path, **kwargs): + """Sends a HEAD request""" return self.request("HEAD", path, **kwargs) def options(self, path, **kwargs): + """Sends a OPTIONS request""" return self.request("OPTIONS", path, **kwargs) def patch(self, path, data=None, **kwargs): @@ -136,15 +139,24 @@ def patch(self, path, data=None, **kwargs): return self.request("PATCH", path, payload=data, **kwargs) def post(self, path, data=None, **kwargs): + """Sends a POST request""" return self.request("POST", path, payload=data, **kwargs) def put(self, path, data=None, **kwargs): + """Sends a PUT request""" return self.request("PUT", path, payload=data, **kwargs) class FastResponse(CompatResponse): + headers = None + """Dict like object containing the response headers""" + @property def text(self): + """ + Returns the text content of the response as a decoded string + (unicode on python2) + """ # Decode unicode from detected encoding. try: content = str(self.content, self.apparent_encoding, errors='replace') From 5116f30b44aacc98b338c09069ca971942437aec Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Tue, 19 Dec 2017 19:43:29 +0100 Subject: [PATCH 14/28] Updated test that accidentally broke --- locust/test/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locust/test/test_client.py b/locust/test/test_client.py index e33019ebcf..82fe866a82 100644 --- a/locust/test/test_client.py +++ b/locust/test/test_client.py @@ -94,6 +94,6 @@ def test_options(self): self.assertEqual(200, r.status_code) self.assertEqual("", r.content.decode()) self.assertEqual( - set(["OPTIONS", "DELETE", "PUT", "GET", "POST", "HEAD"]), + set(["OPTIONS", "DELETE", "PUT", "GET", "POST", "HEAD", "PATCH"]), set(r.headers["allow"].split(", ")), ) From 9ed5623b2da4532e0ce49f323432d5e3ac318c6c Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 20 Dec 2017 12:35:13 +0100 Subject: [PATCH 15/28] Minor RST syntax fix --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c5def5e8e7..78b7ce0eb7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,7 +14,7 @@ Changelog * Python 3 support * Dropped support for Python 2.6 -* Added `--no-reset-stats` option for controling if the statistics should be reset once +* Added :code:`--no-reset-stats` option for controling if the statistics should be reset once the hatching is complete * Added charts to the web UI for requests per second, average response time, and number of simulated users. From e8c87ac7aae0f7f33a568594ae963b8c58a6478b Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 20 Dec 2017 12:55:41 +0100 Subject: [PATCH 16/28] Check that the given FastHttpLocust.host is valid and doesn't contain a trailing slash --- locust/contrib/fasthttp.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index 250a2bb174..bfc2a42fb7 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -20,6 +20,7 @@ class ConnectionRefusedError(Exception): from locust import events from locust.core import Locust +from locust.exception import LocustError # Monkey patch geventhttpclient.useragent.CompatRequest so that Cookiejar works with Python >= 3.3 @@ -51,6 +52,8 @@ def __init__(self): super(FastHttpLocust, self).__init__() if self.host is None: raise LocustError("You must specify the base host. Either in the host attribute in the Locust class, or on the command line using the --host option.") + if not re.match(r"^https?://[^/]+$", self.host, re.I): + raise LocustError("Invalid host (`%s`). The specified host string must be a base URL without a trailing slash. E.g. http://example.org" % self.host) self.client = FastHttpSession(base_url=self.host) From 0f0d04720d64baeab88f853e4dbcc53a3045cfcd Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Thu, 21 Dec 2017 17:47:51 +0100 Subject: [PATCH 17/28] Implemented support for using the catch_response argument when making requests with FastHttpSession, in order to manually control if a request should result in a success or failure --- locust/contrib/fasthttp.py | 215 ++++++++++++++++++++++++++++------- locust/test/test_fasthttp.py | 105 ++++++++++++++++- 2 files changed, 279 insertions(+), 41 deletions(-) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index bfc2a42fb7..fcfee539d9 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -4,6 +4,7 @@ import re import six import socket +from ssl import SSLError from timeit import default_timer if six.PY2: @@ -16,20 +17,26 @@ class ConnectionRefusedError(Exception): else: from http.cookiejar import CookieJar +from gevent.timeout import Timeout from geventhttpclient.useragent import UserAgent, CompatRequest, CompatResponse, ConnectionError from locust import events from locust.core import Locust -from locust.exception import LocustError +from locust.exception import LocustError, CatchResponseError, ResponseError # Monkey patch geventhttpclient.useragent.CompatRequest so that Cookiejar works with Python >= 3.3 # More info: https://github.com/requests/requests/pull/871 CompatRequest.unverifiable = False - +# Regexp for checking if an absolute URL was specified absolute_http_url_regexp = re.compile(r"^https?://", re.I) +# List of exceptions that can be raised by geventhttpclient when sending an HTTP request, +# and that should result in a Locust failure +FAILURE_EXCEPTIONS = (ConnectionError, ConnectionRefusedError, socket.error, \ + SSLError, Timeout) + class FastHttpLocust(Locust): """ @@ -58,13 +65,6 @@ def __init__(self): self.client = FastHttpSession(base_url=self.host) -class LocustErrorResponse(object): - content = None - def __init__(self, error): - self.error = error - self.status_code = 0 - - class FastHttpSession(object): def __init__(self, base_url): self.base_url = base_url @@ -78,7 +78,22 @@ def _build_url(self, path): else: return "%s%s" % (self.base_url, path) - def request(self, method, path, name=None, **kwargs): + def _send_request_safe_mode(self, method, url, **kwargs): + """ + Send an HTTP request, and catch any exception that might occur due to either + connection problems, or invalid HTTP status codes + """ + try: + return self.client.urlopen(url, method=method, **kwargs) + except FAILURE_EXCEPTIONS as e: + if hasattr(e, "response"): + r = e.response + else: + r = ErrorResponse() + r.error = e + return r + + def request(self, method, path, name=None, catch_response=False, **kwargs): # prepend url with hostname unless it's already an absolute URL url = self._build_url(path) @@ -89,38 +104,42 @@ def request(self, method, path, name=None, **kwargs): request_meta["start_time"] = default_timer() request_meta["name"] = name or path - try: - response = self.client.urlopen(url, method=method, **kwargs) - except (ConnectionError, ConnectionRefusedError, socket.error) as e: - # record the consumed time - request_meta["response_time"] = int((default_timer() - request_meta["start_time"]) * 1000) - events.request_failure.fire( - request_type=request_meta["method"], - name=request_meta["name"], - response_time=request_meta["response_time"], - exception=e, - ) - if hasattr(e, "response"): - return e.response - else: - return LocustErrorResponse(e) + # send request, and catch any exceptions + response = self._send_request_safe_mode(method, url, **kwargs) + + # get the length of the content, but if the argument stream is set to True, we take + # the size from the content-length header, in order to not trigger fetching of the body + if kwargs.get("stream", False): + request_meta["content_size"] = int(response.headers.get("content-length") or 0) + else: + request_meta["content_size"] = len(response.content or "") + + # Record the consumed time + # Note: This is intentionally placed after we record the content_size above, since + # we'll then trigger fetching of the body (unless stream=True) + request_meta["response_time"] = int((default_timer() - request_meta["start_time"]) * 1000) + + if catch_response: + response.locust_request_meta = request_meta + return ResponseContextManager(response) else: - # get the length of the content, but if the argument stream is set to True, we take - # the size from the content-length header, in order to not trigger fetching of the body - if kwargs.get("stream", False): - request_meta["content_size"] = int(response.headers.get("content-length") or 0) + try: + response.raise_for_status() + except FAILURE_EXCEPTIONS as e: + events.request_failure.fire( + request_type=request_meta["method"], + name=request_meta["name"], + response_time=request_meta["response_time"], + exception=e, + ) else: - request_meta["content_size"] = len(response.content or "") - - # record the consumed time - request_meta["response_time"] = int((default_timer() - request_meta["start_time"]) * 1000) - events.request_success.fire( - request_type=request_meta["method"], - name=request_meta["name"], - response_time=request_meta["response_time"], - response_length=request_meta["content_size"], - ) - return response + events.request_success.fire( + request_type=request_meta["method"], + name=request_meta["name"], + response_time=request_meta["response_time"], + response_length=request_meta["content_size"], + ) + return response def delete(self, path, **kwargs): return self.request("DELETE", path, **kwargs) @@ -154,6 +173,8 @@ class FastResponse(CompatResponse): headers = None """Dict like object containing the response headers""" + _response = None + @property def text(self): """ @@ -177,6 +198,38 @@ def text(self): def apparent_encoding(self): """The apparent encoding, provided by the chardet library.""" return chardet.detect(self.content)['encoding'] + + def raise_for_status(self): + """Raise any connection errors that occured during the request""" + if hasattr(self, 'error') and self.error: + raise self.error + + @property + def status_code(self): + """ + We override status_code in order to return None if bo valid response was + returned. E.g. in the case of connection errors + """ + return self._response is not None and self._response.get_code() or 0 + + def _content(self): + if self.headers is None: + return None + return super(FastResponse, self)._content() + + +class ErrorResponse(object): + """ + This is used as a dummy response object when geventhttpclient raises an error + that doesn't have a real Response object attached. E.g. a socket error or similar + """ + headers = None + content = None + status_code = 0 + error = None + text = None + def raise_for_status(self): + raise self.error class LocustUserAgent(UserAgent): @@ -188,3 +241,85 @@ def _urlopen(self, request): resp = client.request(request.method, request.url_split.request_uri, body=request.payload, headers=request.headers) return self.response_type(resp, request=request, sent_request=resp._sent_request) + + +class ResponseContextManager(FastResponse): + """ + A Response class that also acts as a context manager that provides the ability to manually + control if an HTTP request should be marked as successful or a failure in Locust's statistics + + This class is a subclass of :py:class:`FastResponse ` + with two additional methods: :py:meth:`success ` + and :py:meth:`failure `. + """ + + _is_reported = False + + def __init__(self, response): + # copy data from response to this object + self.__dict__ = response.__dict__ + + def __enter__(self): + return self + + def __exit__(self, exc, value, traceback): + if self._is_reported: + # if the user has already manually marked this response as failure or success + # we can ignore the default haviour of letting the response code determine the outcome + return exc is None + + if exc: + if isinstance(value, ResponseError): + self.failure(value) + else: + return False + else: + try: + self.raise_for_status() + except FAILURE_EXCEPTIONS as e: + self.failure(e) + else: + self.success() + return True + + def success(self): + """ + Report the response as successful + + Example:: + + with self.client.get("/does/not/exist", catch_response=True) as response: + if response.status_code == 404: + response.success() + """ + events.request_success.fire( + request_type=self.locust_request_meta["method"], + name=self.locust_request_meta["name"], + response_time=self.locust_request_meta["response_time"], + response_length=self.locust_request_meta["content_size"], + ) + self._is_reported = True + + def failure(self, exc): + """ + Report the response as a failure. + + exc can be either a python exception, or a string in which case it will + be wrapped inside a CatchResponseError. + + Example:: + + with self.client.get("/", catch_response=True) as response: + if response.content == "": + response.failure("No data") + """ + if isinstance(exc, six.string_types): + exc = CatchResponseError(exc) + + events.request_failure.fire( + request_type=self.locust_request_meta["method"], + name=self.locust_request_meta["name"], + response_time=self.locust_request_meta["response_time"], + exception=exc, + ) + self._is_reported = True diff --git a/locust/test/test_fasthttp.py b/locust/test/test_fasthttp.py index 8cd971d010..1afd901514 100644 --- a/locust/test/test_fasthttp.py +++ b/locust/test/test_fasthttp.py @@ -1,9 +1,10 @@ import six import socket -from locust import TaskSet, task +from locust import TaskSet, task, events from locust.core import LocustError from locust.contrib.fasthttp import FastHttpSession, FastHttpLocust +from locust.exception import CatchResponseError, InterruptTaskSet, ResponseError from locust.stats import global_stats from .testcases import WebserverTestCase @@ -265,3 +266,105 @@ class MyLocust(FastHttpLocust): self.assertEqual(1, len(global_stats.entries)) self.assertEqual(1, global_stats.get("/redirect", "GET").num_requests) self.assertEqual(0, global_stats.get("/ultra_fast", "GET").num_requests) + + +class TestFastHttpCatchResponse(WebserverTestCase): + def setUp(self): + super(TestFastHttpCatchResponse, self).setUp() + + class MyLocust(FastHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + self.locust = MyLocust() + + self.num_failures = 0 + self.num_success = 0 + def on_failure(request_type, name, response_time, exception): + self.num_failures += 1 + self.last_failure_exception = exception + def on_success(**kwargs): + self.num_success += 1 + events.request_failure += on_failure + events.request_success += on_success + + def test_catch_response(self): + self.assertEqual(500, self.locust.client.get("/fail").status_code) + self.assertEqual(1, self.num_failures) + self.assertEqual(0, self.num_success) + + with self.locust.client.get("/ultra_fast", catch_response=True) as response: pass + self.assertEqual(1, self.num_failures) + self.assertEqual(1, self.num_success) + + with self.locust.client.get("/ultra_fast", catch_response=True) as response: + raise ResponseError("Not working") + + self.assertEqual(2, self.num_failures) + self.assertEqual(1, self.num_success) + + def test_catch_response_http_fail(self): + with self.locust.client.get("/fail", catch_response=True) as response: pass + self.assertEqual(1, self.num_failures) + self.assertEqual(0, self.num_success) + + def test_catch_response_http_manual_fail(self): + with self.locust.client.get("/ultra_fast", catch_response=True) as response: + response.failure("Haha!") + self.assertEqual(1, self.num_failures) + self.assertEqual(0, self.num_success) + self.assertTrue( + isinstance(self.last_failure_exception, CatchResponseError), + "Failure event handler should have been passed a CatchResponseError instance" + ) + + def test_catch_response_http_manual_success(self): + with self.locust.client.get("/fail", catch_response=True) as response: + response.success() + self.assertEqual(0, self.num_failures) + self.assertEqual(1, self.num_success) + + def test_catch_response_allow_404(self): + with self.locust.client.get("/does/not/exist", catch_response=True) as response: + self.assertEqual(404, response.status_code) + if response.status_code == 404: + response.success() + self.assertEqual(0, self.num_failures) + self.assertEqual(1, self.num_success) + + def test_interrupt_taskset_with_catch_response(self): + class MyTaskSet(TaskSet): + @task + def interrupted_task(self): + with self.client.get("/ultra_fast", catch_response=True) as r: + raise InterruptTaskSet() + class MyLocust(FastHttpLocust): + host = "http://127.0.0.1:%i" % self.port + task_set = MyTaskSet + + l = MyLocust() + ts = MyTaskSet(l) + self.assertRaises(InterruptTaskSet, lambda: ts.interrupted_task()) + self.assertEqual(0, self.num_failures) + self.assertEqual(0, self.num_success) + + def test_catch_response_connection_error_success(self): + class MyLocust(FastHttpLocust): + host = "http://127.0.0.1:1" + l = MyLocust() + with l.client.get("/", catch_response=True) as r: + self.assertEqual(r.status_code, 0) + self.assertEqual(None, r.content) + r.success() + self.assertEqual(1, self.num_success) + self.assertEqual(0, self.num_failures) + + def test_catch_response_connection_error_fail(self): + class MyLocust(FastHttpLocust): + host = "http://127.0.0.1:1" + l = MyLocust() + with l.client.get("/", catch_response=True) as r: + self.assertEqual(r.status_code, 0) + self.assertEqual(None, r.content) + r.success() + self.assertEqual(1, self.num_success) + self.assertEqual(0, self.num_failures) From 064f5e02cbeb983b246267cdcc6602836068ce43 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Fri, 22 Dec 2017 01:36:01 +0100 Subject: [PATCH 18/28] Implemented support for HTTP Basic Auth in FastHttp client. Improved documentation of arguments for FastHttpSession.request() method. --- locust/contrib/fasthttp.py | 70 ++++++++++++++++++++++++++++++++---- locust/test/test_fasthttp.py | 19 ++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index fcfee539d9..ce09ef0c35 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -4,6 +4,8 @@ import re import six import socket +from base64 import b64encode +from six.moves.urllib.parse import urlparse, urlunparse from ssl import SSLError from timeit import default_timer @@ -38,6 +40,15 @@ class ConnectionRefusedError(Exception): SSLError, Timeout) +def _construct_basic_auth_str(username, password): + """Construct Authorization header value to be used in HTTP Basic Auth""" + if isinstance(username, str): + username = username.encode('latin1') + if isinstance(password, str): + password = password.encode('latin1') + return 'Basic ' + b64encode(b':'.join((username, password))).strip().decode("ascii") + + class FastHttpLocust(Locust): """ Represents an HTTP "user" which is to be hatched and attack the system that is to be load tested. @@ -66,10 +77,25 @@ def __init__(self): class FastHttpSession(object): + auth_header = None + def __init__(self, base_url): self.base_url = base_url self.cookiejar = CookieJar() self.client = LocustUserAgent(max_retries=1, cookiejar=self.cookiejar) + + # Check for basic authentication + parsed_url = urlparse(self.base_url) + if parsed_url.username and parsed_url.password: + netloc = parsed_url.hostname + if parsed_url.port: + netloc += ":%d" % parsed_url.port + + # remove username and password from the base_url + self.base_url = urlunparse((parsed_url.scheme, netloc, parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment)) + # store authentication header (we construct this by using _basic_auth_str() function from requests.auth) + self.auth_header = _construct_basic_auth_str(parsed_url.username, parsed_url.password) + #self.auth = HTTPBasicAuth(parsed_url.username, parsed_url.password) def _build_url(self, path): """ prepend url with hostname unless it's already an absolute URL """ @@ -93,7 +119,32 @@ def _send_request_safe_mode(self, method, url, **kwargs): r.error = e return r - def request(self, method, path, name=None, catch_response=False, **kwargs): + def request(self, method, path, name=None, data=None, catch_response=False, stream=False, \ + headers=None, auth=None, **kwargs): + """ + Send and HTTP request + Returns :py:class:`locust.contrib.fasthttp.FastResponse` object. + + :param method: method for the new :class:`Request` object. + :param path: Path that will be concatenated with the base host URL that has been specified. + Can also be a full URL, in which case the full URL will be requested, and the base host + is ignored. + :param name: (optional) An argument that can be specified to use as label in Locust's + statistics instead of the URL path. This can be used to group different URL's + that are requested into a single entry in Locust's statistics. + :param catch_response: (optional) Boolean argument that, if set, can be used to make a request + return a context manager to work as argument to a with statement. This will allow the + request to be marked as a fail based on the content of the response, even if the response + code is ok (2xx). The opposite also works, one can use catch_response to catch a request + and then mark it as successful even if the response code was not (i.e 500 or 404). + :param data: (optional) Dictionary or bytes to send in the body of the request. + :param headers: (optional) Dictionary of HTTP Headers to send with the request. + :param auth: (optional) Auth (username, password) tuple to enable Basic HTTP Auth. + :param stream: (optional) If set to true the response body will not be consumed immediately + and can instead be consumed by accessing the stream attribute on the Response object. + Another side effect of setting stream to True is that the time for downloading the response + content will not be accounted for in the request time that is reported by Locust. + """ # prepend url with hostname unless it's already an absolute URL url = self._build_url(path) @@ -104,12 +155,19 @@ def request(self, method, path, name=None, catch_response=False, **kwargs): request_meta["start_time"] = default_timer() request_meta["name"] = name or path + if auth: + headers = headers or {} + headers['Authorization'] = _construct_basic_auth_str(auth[0], auth[1]) + elif self.auth_header: + headers = headers or {} + headers['Authorization'] = self.auth_header + # send request, and catch any exceptions - response = self._send_request_safe_mode(method, url, **kwargs) + response = self._send_request_safe_mode(method, url, payload=data, headers=headers, **kwargs) # get the length of the content, but if the argument stream is set to True, we take # the size from the content-length header, in order to not trigger fetching of the body - if kwargs.get("stream", False): + if stream: request_meta["content_size"] = int(response.headers.get("content-length") or 0) else: request_meta["content_size"] = len(response.content or "") @@ -158,15 +216,15 @@ def options(self, path, **kwargs): def patch(self, path, data=None, **kwargs): """Sends a POST request""" - return self.request("PATCH", path, payload=data, **kwargs) + return self.request("PATCH", path, data=data, **kwargs) def post(self, path, data=None, **kwargs): """Sends a POST request""" - return self.request("POST", path, payload=data, **kwargs) + return self.request("POST", path, data=data, **kwargs) def put(self, path, data=None, **kwargs): """Sends a PUT request""" - return self.request("PUT", path, payload=data, **kwargs) + return self.request("PUT", path, data=data, **kwargs) class FastResponse(CompatResponse): diff --git a/locust/test/test_fasthttp.py b/locust/test/test_fasthttp.py index 1afd901514..6417cde68c 100644 --- a/locust/test/test_fasthttp.py +++ b/locust/test/test_fasthttp.py @@ -266,6 +266,25 @@ class MyLocust(FastHttpLocust): self.assertEqual(1, len(global_stats.entries)) self.assertEqual(1, global_stats.get("/redirect", "GET").num_requests) self.assertEqual(0, global_stats.get("/ultra_fast", "GET").num_requests) + + def test_client_basic_auth(self): + class MyLocust(FastHttpLocust): + host = "http://127.0.0.1:%i" % self.port + + class MyAuthorizedLocust(FastHttpLocust): + host = "http://locust:menace@127.0.0.1:%i" % self.port + + class MyUnauthorizedLocust(FastHttpLocust): + host = "http://locust:wrong@127.0.0.1:%i" % self.port + + locust = MyLocust() + unauthorized = MyUnauthorizedLocust() + authorized = MyAuthorizedLocust() + response = authorized.client.get("/basic_auth") + self.assertEqual(200, response.status_code) + self.assertEqual("Authorized", response.text) + self.assertEqual(401, locust.client.get("/basic_auth").status_code) + self.assertEqual(401, unauthorized.client.get("/basic_auth").status_code) class TestFastHttpCatchResponse(WebserverTestCase): From 748ba8d412474b63d839a3a9194af6c8ee32d27e Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Fri, 22 Dec 2017 01:44:52 +0100 Subject: [PATCH 19/28] Typo --- locust/contrib/fasthttp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index ce09ef0c35..31c2881eda 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -265,7 +265,7 @@ def raise_for_status(self): @property def status_code(self): """ - We override status_code in order to return None if bo valid response was + We override status_code in order to return None if no valid response was returned. E.g. in the case of connection errors """ return self._response is not None and self._response.get_code() or 0 From a1a42f04f254dcad7f4ad914e0551f237d0b0ed6 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Fri, 22 Dec 2017 14:32:16 +0100 Subject: [PATCH 20/28] Fixed tests for manually failing an already failed request with catch_response --- locust/test/test_fasthttp.py | 6 +++--- locust/test/test_locust_class.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/locust/test/test_fasthttp.py b/locust/test/test_fasthttp.py index 6417cde68c..7f12f23fe1 100644 --- a/locust/test/test_fasthttp.py +++ b/locust/test/test_fasthttp.py @@ -384,6 +384,6 @@ class MyLocust(FastHttpLocust): with l.client.get("/", catch_response=True) as r: self.assertEqual(r.status_code, 0) self.assertEqual(None, r.content) - r.success() - self.assertEqual(1, self.num_success) - self.assertEqual(0, self.num_failures) + r.failure("Manual fail") + self.assertEqual(0, self.num_success) + self.assertEqual(1, self.num_failures) diff --git a/locust/test/test_locust_class.py b/locust/test/test_locust_class.py index e1198f3f93..f34c7821e3 100644 --- a/locust/test/test_locust_class.py +++ b/locust/test/test_locust_class.py @@ -547,6 +547,6 @@ class MyLocust(HttpLocust): with l.client.get("/", catch_response=True) as r: self.assertEqual(r.status_code, 0) self.assertEqual(None, r.content) - r.success() - self.assertEqual(1, self.num_success) - self.assertEqual(0, self.num_failures) + r.failure("Manual fail") + self.assertEqual(0, self.num_success) + self.assertEqual(1, self.num_failures) From 03d436e1a8ffc6f48048a611322fdb7e5c013959 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Thu, 28 Dec 2017 19:58:13 +0100 Subject: [PATCH 21/28] Removed commented out code --- locust/contrib/fasthttp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index 31c2881eda..58ed0710fa 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -95,7 +95,6 @@ def __init__(self, base_url): self.base_url = urlunparse((parsed_url.scheme, netloc, parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment)) # store authentication header (we construct this by using _basic_auth_str() function from requests.auth) self.auth_header = _construct_basic_auth_str(parsed_url.username, parsed_url.password) - #self.auth = HTTPBasicAuth(parsed_url.username, parsed_url.password) def _build_url(self, path): """ prepend url with hostname unless it's already an absolute URL """ From 42bfffb3d8eaef8205ccc4f7cdead9dd9a22c2ae Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Thu, 28 Dec 2017 20:46:23 +0100 Subject: [PATCH 22/28] Clarified documentation about performance gains of FastHttpClient --- docs/increase-performance.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/increase-performance.rst b/docs/increase-performance.rst index 5ef437ee0f..a1b5dcb6c6 100644 --- a/docs/increase-performance.rst +++ b/docs/increase-performance.rst @@ -12,8 +12,11 @@ which uses requests. However, if your're planning to run really large scale scal Locust comes with an alternative HTTP client, :py:class:`FastHttpLocust ` which uses `geventhttpclient `_ instead of requests. -This client is significantly faster, and we've seen 5x-6x performance increases for -HTTP-requests. +This client is significantly faster, and we've seen 5x-6x performance increases for making +HTTP-requests. This does not necessarily mean that the number of users one can simulate +per CPU core will automatically increase 5x-6x, since it also depends on what else +the load testing script does. However, if your locust scripts are spending most of their +CPU time in making HTTP-requests, you are likely to see signifant performance gains. Known limitations From a8b8abdb06e40c9c24fd391619b15f51626c8f16 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Thu, 28 Dec 2017 20:48:01 +0100 Subject: [PATCH 23/28] Added HTTPConnectionClosed to "whitelisted" exceptions that should be reported as failures --- locust/contrib/fasthttp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py index 58ed0710fa..880b6e85b4 100644 --- a/locust/contrib/fasthttp.py +++ b/locust/contrib/fasthttp.py @@ -21,6 +21,7 @@ class ConnectionRefusedError(Exception): from gevent.timeout import Timeout from geventhttpclient.useragent import UserAgent, CompatRequest, CompatResponse, ConnectionError +from geventhttpclient.response import HTTPConnectionClosed from locust import events from locust.core import Locust @@ -37,7 +38,7 @@ class ConnectionRefusedError(Exception): # List of exceptions that can be raised by geventhttpclient when sending an HTTP request, # and that should result in a Locust failure FAILURE_EXCEPTIONS = (ConnectionError, ConnectionRefusedError, socket.error, \ - SSLError, Timeout) + SSLError, Timeout, HTTPConnectionClosed) def _construct_basic_auth_str(username, password): From 84b05793acf3352f5c7259d5475bc2abc83bf376 Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 24 Jan 2018 14:35:52 +0100 Subject: [PATCH 24/28] Spelling typo --- docs/increase-performance.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/increase-performance.rst b/docs/increase-performance.rst index a1b5dcb6c6..8eaf6254dc 100644 --- a/docs/increase-performance.rst +++ b/docs/increase-performance.rst @@ -8,7 +8,7 @@ Locust's default HTTP client uses `python-requests ` -which uses requests. However, if your're planning to run really large scale scale tests, +which uses requests. However, if you're planning to run really large scale scale tests, Locust comes with an alternative HTTP client, :py:class:`FastHttpLocust ` which uses `geventhttpclient `_ instead of requests. From f34dcac7017b65bd486073e6e9df8c1a0744672c Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 24 Jan 2018 14:42:28 +0100 Subject: [PATCH 25/28] Removed "Known limitations" (yay) --- docs/increase-performance.rst | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/increase-performance.rst b/docs/increase-performance.rst index 8eaf6254dc..f27da73095 100644 --- a/docs/increase-performance.rst +++ b/docs/increase-performance.rst @@ -19,14 +19,6 @@ the load testing script does. However, if your locust scripts are spending most CPU time in making HTTP-requests, you are likely to see signifant performance gains. -Known limitations -================= - -* :py:class:`FastHttpLocust ` does not - support the :ref:`catch-response ` argument. -* Basic auth is currently not supported. - - How to use FastHttpLocust =========================== From 7055334e1b1f0a3353ee292a1dab735d7125a2fa Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 24 Jan 2018 16:50:38 +0100 Subject: [PATCH 26/28] Added dependency on PyPI package geventhttpclient-wheels --- setup.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 7d472fd3bb..68dfc9a503 100644 --- a/setup.py +++ b/setup.py @@ -41,9 +41,17 @@ packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), include_package_data=True, zip_safe=False, - install_requires=["gevent>=1.2.2", "flask>=0.10.1", "requests>=2.9.1", "msgpack-python>=0.4.2", "six>=1.10.0", "pyzmq>=16.0.2"], + install_requires=[ + "gevent>=1.2.2", + "flask>=0.10.1", + "requests>=2.9.1", + "msgpack-python>=0.4.2", + "six>=1.10.0", + "pyzmq>=16.0.2", + "geventhttpclient-wheels", + ], test_suite="locust.test", - tests_require=['unittest2', 'mock', 'geventhttpclient'], + tests_require=['unittest2', 'mock'], entry_points={ 'console_scripts': [ 'locust = locust.main:main', From 40aebc6d67908ec11f82122c13f90ba511ca230b Mon Sep 17 00:00:00 2001 From: Jonatan Heyman Date: Wed, 24 Jan 2018 16:55:16 +0100 Subject: [PATCH 27/28] Removed geventhttpclient from tox dependencies --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index bf6269304d..3b2519e3ec 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,5 @@ deps = mock pyzmq unittest2 - geventhttpclient commands = coverage run -m unittest2 discover [] From 1171b9b3ebd14fda5909ee8ef26884f5749681e9 Mon Sep 17 00:00:00 2001 From: spencerpinegar Date: Mon, 9 Jul 2018 14:49:22 -0600 Subject: [PATCH 28/28] I was getting the assertionError 0.0 not greater than 0 -- I assumed the response was very quick (not a failure) and that 0.0 was a valid response time for a local server. Because of this i changes it to assertGreaterEqual. --- locust/test/test_fasthttp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locust/test/test_fasthttp.py b/locust/test/test_fasthttp.py index 7f12f23fe1..f0a45107ec 100644 --- a/locust/test/test_fasthttp.py +++ b/locust/test/test_fasthttp.py @@ -50,7 +50,7 @@ def test_streaming_response(self): # verify that response time does NOT include whole download time, when using stream=True r = s.get("/streaming/30", stream=True) - self.assertGreater(global_stats.get("/streaming/30", method="GET").avg_response_time, 0) + self.assertGreaterEqual(global_stats.get("/streaming/30", method="GET").avg_response_time, 0) self.assertLess(global_stats.get("/streaming/30", method="GET").avg_response_time, 250) # download the content of the streaming response (so we don't get an ugly exception in the log)