diff --git a/docs/increase-performance.rst b/docs/increase-performance.rst new file mode 100644 index 0000000000..f27da73095 --- /dev/null +++ b/docs/increase-performance.rst @@ -0,0 +1,62 @@ +.. _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 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. +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. + + +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 1f001a8e2b..f0219d97ab 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: 4 diff --git a/docs/running-locust-distributed.rst b/docs/running-locust-distributed.rst index d0cb704871..6bd0ad1397 100644 --- a/docs/running-locust-distributed.rst +++ b/docs/running-locust-distributed.rst @@ -91,3 +91,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 104998541b..5c1460fffe 100644 --- a/docs/writing-a-locustfile.rst +++ b/docs/writing-a-locustfile.rst @@ -73,7 +73,7 @@ classes. Say for example, web users are three times more likely than mobile user The *host* attribute -------------------- -The host attribute is a URL prefix (i.e. "https://google.com") to the host that is to be loaded. +The host attribute is a URL prefix (i.e. "https://google.com") to the host that is to be loaded. Usually, this is specified on the command line, using the :code:`--host` option, when locust is started. If one declares a host attribute in the locust class, it will be used in the case when no :code:`--host` is specified on the command line. @@ -322,7 +322,7 @@ Since many setup and cleanup operations are dependent on each other, here is the In general, the setup and teardown methods should be complementary. -Making HTTP requests +Making HTTP requests ===================== So far, we've only covered the task scheduling part of a Locust user. In order to actually load test @@ -405,6 +405,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/__init__.py b/locust/contrib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/locust/contrib/fasthttp.py b/locust/contrib/fasthttp.py new file mode 100644 index 0000000000..880b6e85b4 --- /dev/null +++ b/locust/contrib/fasthttp.py @@ -0,0 +1,383 @@ +from __future__ import absolute_import + +import chardet +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 + +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 + str = unicode +else: + from http.cookiejar import CookieJar + +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 +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, HTTPConnectionClosed) + + +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. + + 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(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) + + +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) + + 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 _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, 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) + + # 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"] = 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, 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 stream: + 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: + 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: + 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): + """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): + """Sends a POST request""" + return self.request("PATCH", path, data=data, **kwargs) + + def post(self, path, data=None, **kwargs): + """Sends a POST request""" + return self.request("POST", path, data=data, **kwargs) + + def put(self, path, data=None, **kwargs): + """Sends a PUT request""" + return self.request("PUT", path, data=data, **kwargs) + + +class FastResponse(CompatResponse): + headers = None + """Dict like object containing the response headers""" + + _response = None + + @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') + 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'] + + 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 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 + + 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): + response_type = FastResponse + + 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) + + +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_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(", ")), ) diff --git a/locust/test/test_fasthttp.py b/locust/test/test_fasthttp.py new file mode 100644 index 0000000000..f0a45107ec --- /dev/null +++ b/locust/test/test_fasthttp.py @@ -0,0 +1,389 @@ +import six +import socket + +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 + + +class TestFastHttpSession(WebserverTestCase): + def test_get(self): + 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 = FastHttpSession("http://localhost:1") + r = s.get("/", timeout=0.1) + self.assertEqual(r.status_code, 0) + self.assertEqual(None, r.content) + self.assertEqual(1, len(global_stats.errors)) + 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() + 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) + + def test_streaming_response(self): + """ + Test a request to an endpoint that returns a streaming response + """ + 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 + 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.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) + _ = r.content + + def test_slow_redirect(self): + 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") + self.assertEqual(1, stats.num_requests) + self.assertGreater(stats.avg_response_time, 500) + + def test_post_redirect(self): + s = FastHttpSession("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 = 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 = 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 = 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_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", "PATCH"]), + set(r.headers["allow"].split(", ")), + ) + + +class TestRequestStatsWithWebserver(WebserverTestCase): + def test_request_stats_content_length(self): + class MyLocust(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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) + + +class TestFastHttpLocustClass(WebserverTestCase): + def test_get_request(self): + self.response = "" + def t1(l): + self.response = l.client.get("/ultra_fast") + class MyLocust(FastHttpLocust): + 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(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(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(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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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(FastHttpLocust): + 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) + + 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): + 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.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 9b2bc64eb4..6613685890 100644 --- a/locust/test/test_locust_class.py +++ b/locust/test/test_locust_class.py @@ -555,6 +555,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) diff --git a/locust/test/testcases.py b/locust/test/testcases.py index 5e997d7419..c2671e9160 100644 --- a/locust/test/testcases.py +++ b/locust/test/testcases.py @@ -41,7 +41,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 diff --git a/setup.py b/setup.py index e286d07563..458aaba6c4 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,15 @@ packages=find_packages(exclude=['examples', 'tests']), include_package_data=True, zip_safe=False, - install_requires=["gevent>=1.2.2", "flask>=0.10.1", "requests>=2.9.1", "msgpack>=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=['mock'], entry_points={