From c6d59a1b2fce536b9cb11139a38c474ce23ca416 Mon Sep 17 00:00:00 2001 From: Adel Haddad <26027314+adehad@users.noreply.github.com> Date: Sat, 11 Jun 2022 17:08:24 +0100 Subject: [PATCH] tidy ResilientSession implementation (#1366) * tidy ResilientSession implementation * new retry framework, implemented for encoded attachment * join iterable error message list with `\n` rather than only keeping first * move warning about debug log dumping headers into init --- docs/conf.py | 3 + jira/client.py | 122 +++++----- jira/resilientsession.py | 409 ++++++++++++++++++++------------- jira/utils/__init__.py | 12 +- tests/test_resilientsession.py | 19 +- 5 files changed, 332 insertions(+), 233 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2e5a1d254..8bd95769c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -56,6 +56,7 @@ nitpick_ignore = [ ("py:class", "JIRA"), # in jira.resources we only import this class if type ("py:class", "jira.resources.AnyLike"), # Dummy subclass for type checking + ("py:meth", "__recoverable"), # ResilientSession, not autogenerated # From other packages ("py:mod", "filemagic"), ("py:mod", "ipython"), @@ -69,6 +70,8 @@ ("py:class", "Response"), ("py:mod", "requests-kerberos"), ("py:mod", "requests-oauthlib"), + ("py:class", "typing_extensions.TypeGuard"), # Py38 not happy with this typehint + ("py:class", "TypeGuard"), # Py38 not happy with 'TypeGuard' in docstring ] # Add any paths that contain templates here, relative to this directory. diff --git a/jira/client.py b/jira/client.py index 37ed83fb5..468fad4b5 100644 --- a/jira/client.py +++ b/jira/client.py @@ -35,7 +35,6 @@ Type, TypeVar, Union, - cast, no_type_check, overload, ) @@ -47,10 +46,11 @@ from requests.auth import AuthBase from requests.structures import CaseInsensitiveDict from requests.utils import get_netrc_auth +from requests_toolbelt import MultipartEncoder from jira import __version__ from jira.exceptions import JIRAError -from jira.resilientsession import ResilientSession, raise_on_error +from jira.resilientsession import PrepareRequestForRetry, ResilientSession from jira.resources import ( AgileResource, Attachment, @@ -92,12 +92,6 @@ ) from jira.utils import json_loads, threaded_requests -try: - # noinspection PyUnresolvedReferences - from requests_toolbelt import MultipartEncoder -except ImportError: - pass - try: from requests_jwt import JWTAuth except ImportError: @@ -954,70 +948,68 @@ def add_attachment( """ close_attachment = False if isinstance(attachment, str): - attachment: BufferedReader = open(attachment, "rb") # type: ignore - attachment = cast(BufferedReader, attachment) + attachment_io = open(attachment, "rb") # type: ignore close_attachment = True - elif isinstance(attachment, BufferedReader) and attachment.mode != "rb": - self.log.warning( - "%s was not opened in 'rb' mode, attaching file may fail." - % attachment.name - ) - - url = self._get_url("issue/" + str(issue) + "/attachments") + else: + attachment_io = attachment + if isinstance(attachment, BufferedReader) and attachment.mode != "rb": + self.log.warning( + "%s was not opened in 'rb' mode, attaching file may fail." + % attachment.name + ) fname = filename if not fname and isinstance(attachment, BufferedReader): fname = os.path.basename(attachment.name) - if "MultipartEncoder" not in globals(): - method = "old" - try: - r = self._session.post( - url, - files={"file": (fname, attachment, "application/octet-stream")}, - headers=CaseInsensitiveDict( - {"content-type": None, "X-Atlassian-Token": "no-check"} - ), - ) - finally: - if close_attachment: - attachment.close() - else: - method = "MultipartEncoder" - - def file_stream() -> MultipartEncoder: - """Returns files stream of attachment.""" - return MultipartEncoder( - fields={"file": (fname, attachment, "application/octet-stream")} - ) - - m = file_stream() - try: - r = self._session.post( - url, - data=m, - headers=CaseInsensitiveDict( - { - "content-type": m.content_type, - "X-Atlassian-Token": "no-check", - } - ), - retry_data=file_stream, - ) - finally: - if close_attachment: - attachment.close() + def generate_multipartencoded_request_args() -> Tuple[ + MultipartEncoder, CaseInsensitiveDict + ]: + """Returns MultipartEncoder stream of attachment, and the header.""" + attachment_io.seek(0) + encoded_data = MultipartEncoder( + fields={"file": (fname, attachment_io, "application/octet-stream")} + ) + request_headers = CaseInsensitiveDict( + { + "content-type": encoded_data.content_type, + "X-Atlassian-Token": "no-check", + } + ) + return encoded_data, request_headers + + class RetryableMultipartEncoder(PrepareRequestForRetry): + def prepare( + self, original_request_kwargs: CaseInsensitiveDict + ) -> CaseInsensitiveDict: + encoded_data, request_headers = generate_multipartencoded_request_args() + original_request_kwargs["data"] = encoded_data + original_request_kwargs["headers"] = request_headers + return super().prepare(original_request_kwargs) + + url = self._get_url(f"issue/{issue}/attachments") + try: + encoded_data, request_headers = generate_multipartencoded_request_args() + r = self._session.post( + url, + data=encoded_data, + headers=request_headers, + _prepare_retry_class=RetryableMultipartEncoder(), # type: ignore[call-arg] # ResilientSession handles + ) + finally: + if close_attachment: + attachment_io.close() js: Union[Dict[str, Any], List[Dict[str, Any]]] = json_loads(r) if not js or not isinstance(js, Iterable): - raise JIRAError(f"Unable to parse JSON: {js}") + raise JIRAError(f"Unable to parse JSON: {js}. Failed to add attachment?") jira_attachment = Attachment( self._options, self._session, js[0] if isinstance(js, List) else js ) if jira_attachment.size == 0: raise JIRAError( - "Added empty attachment via %s method?!: r: %s\nattachment: %s" - % (method, r, jira_attachment) + "Added empty attachment?!: " + + f"Response: {r}\nAttachment: {jira_attachment}" ) return jira_attachment @@ -1785,8 +1777,7 @@ def assign_issue(self, issue: Union[int, str], assignee: Optional[str]) -> bool: url = self._get_latest_url(f"issue/{issue}/assignee") user_id = self._get_user_id(assignee) payload = {"accountId": user_id} if self._is_cloud else {"name": user_id} - r = self._session.put(url, data=json.dumps(payload)) - raise_on_error(r) + self._session.put(url, data=json.dumps(payload)) return True @translate_resource_args @@ -2666,7 +2657,7 @@ def create_temp_project_avatar( if size != size_from_file: size = size_from_file - params = {"filename": filename, "size": size} + params: Dict[str, Union[int, str]] = {"filename": filename, "size": size} headers: Dict[str, Any] = {"X-Atlassian-Token": "no-check"} if contentType is not None: @@ -3167,7 +3158,11 @@ def create_temp_user_avatar( # remove path from filename filename = os.path.split(filename)[1] - params = {"username": user, "filename": filename, "size": size} + params: Dict[str, Union[str, int]] = { + "username": user, + "filename": filename, + "size": size, + } headers: Dict[str, Any] headers = {"X-Atlassian-Token": "no-check"} @@ -3701,8 +3696,7 @@ def rename_user(self, old_user: str, new_user: str): # raw displayName self.log.debug(f"renaming {self.user(old_user).emailAddress}") - r = self._session.put(url, params=params, data=json.dumps(payload)) - raise_on_error(r) + self._session.put(url, params=params, data=json.dumps(payload)) else: raise NotImplementedError( "Support for renaming users in Jira " "< 6.0.0 has been removed." diff --git a/jira/resilientsession.py b/jira/resilientsession.py index ec01b6f5d..039b449ed 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -1,119 +1,288 @@ +import abc import json import logging import random import time -from typing import Callable, Optional, Union, cast +from typing import Any, Dict, Optional, Union from requests import Response, Session from requests.exceptions import ConnectionError +from requests.structures import CaseInsensitiveDict +from typing_extensions import TypeGuard from jira.exceptions import JIRAError -logging.getLogger("jira").addHandler(logging.NullHandler()) +LOG = logging.getLogger(__name__) -def raise_on_error(r: Optional[Response], verb="???", **kwargs): +class PrepareRequestForRetry(metaclass=abc.ABCMeta): + """This class allows for the manipulation of the Request keyword arguments before a retry. + + The :py:meth:`.prepare` handles the processing of the Request keyword arguments. + """ + + @abc.abstractmethod + def prepare( + self, original_request_kwargs: CaseInsensitiveDict + ) -> CaseInsensitiveDict: + """Process the Request's keyword arguments before retrying the Request. + + Args: + original_request_kwargs (CaseInsensitiveDict): The keyword arguments of the Request. + + Returns: + CaseInsensitiveDict: The new keyword arguments to use in the retried Request. + """ + return original_request_kwargs + + +class PassthroughRetryPrepare(PrepareRequestForRetry): + """Returns the Request's keyword arguments unchanged, when no change needs to be made before a retry.""" + + def prepare( + self, original_request_kwargs: CaseInsensitiveDict + ) -> CaseInsensitiveDict: + return super().prepare(original_request_kwargs) + + +def raise_on_error(resp: Optional[Response], **kwargs) -> TypeGuard[Response]: """Handle errors from a Jira Request Args: - r (Optional[Response]): Response from Jira request - verb (Optional[str]): Request type, e.g. POST. Defaults to "???". + resp (Optional[Response]): Response from Jira request Raises: JIRAError: If Response is None JIRAError: for unhandled 400 status codes. - JIRAError: for unhandled 200 status codes. + + Returns: + TypeGuard[Response]: True if the passed in Response is all good. """ request = kwargs.get("request", None) - # headers = kwargs.get('headers', None) - if r is None: - raise JIRAError(None, **kwargs) + if resp is None: + raise JIRAError("Empty Response!", response=resp, **kwargs) + + if not resp.ok: + error = parse_error_msg(resp=resp) - if r.status_code >= 400: - error = "" - if r.status_code == 403 and "x-authentication-denied-reason" in r.headers: - error = r.headers["x-authentication-denied-reason"] - elif r.text: - try: - response = json.loads(r.text) - if "message" in response: - # Jira 5.1 errors - error = response["message"] - elif "errorMessages" in response and len(response["errorMessages"]) > 0: - # Jira 5.0.x error messages sometimes come wrapped in this array - # Sometimes this is present but empty - errorMessages = response["errorMessages"] - if isinstance(errorMessages, (list, tuple)): - error = errorMessages[0] - else: - error = errorMessages - # Catching only 'errors' that are dict. See https://github.com/pycontribs/jira/issues/350 - elif ( - "errors" in response - and len(response["errors"]) > 0 - and isinstance(response["errors"], dict) - ): - # Jira 6.x error messages are found in this array. - error_list = response["errors"].values() - error = ", ".join(error_list) - else: - error = r.text - except ValueError: - error = r.text raise JIRAError( error, - status_code=r.status_code, - url=r.url, + status_code=resp.status_code, + url=resp.url, request=request, - response=r, + response=resp, **kwargs, ) - # for debugging weird errors on CI - if r.status_code not in [200, 201, 202, 204]: - raise JIRAError( - status_code=r.status_code, request=request, response=r, **kwargs - ) - # testing for the bug exposed on - # https://answers.atlassian.com/questions/11457054/answers/11975162 - if ( - r.status_code == 200 - and len(r.content) == 0 - and "X-Seraph-LoginReason" in r.headers - and "AUTHENTICATED_FAILED" in r.headers["X-Seraph-LoginReason"] - ): - pass + + return True # if no exception was raised, we have a valid Response + + +def parse_error_msg(resp: Response) -> str: + """Parse a Jira Error message from the Response. + + https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/#status-codes + + Args: + resp (Response): The Jira API request's response. + + Returns: + str: The error message parsed from the Response. An empty string if no error. + """ + resp_data: Dict[str, Any] = {} # json parsed from the response + parsed_error = "" # error message parsed from the response + + if resp.status_code == 403 and "x-authentication-denied-reason" in resp.headers: + parsed_error = resp.headers["x-authentication-denied-reason"] + elif resp.text: + try: + resp_data = resp.json() + except ValueError: + parsed_error = resp.text + + if "message" in resp_data: + # Jira 5.1 errors + parsed_error = resp_data["message"] + elif "errorMessages" in resp_data: + # Jira 5.0.x error messages sometimes come wrapped in this array + # Sometimes this is present but empty + error_messages = resp_data["errorMessages"] + if len(error_messages) > 0: + if isinstance(error_messages, (list, tuple)): + parsed_error = "\n".join(error_messages) + else: + parsed_error = error_messages + elif "errors" in resp_data: + resp_errors = resp_data["errors"] + if len(resp_errors) > 0 and isinstance(resp_errors, dict): + # Catching only 'errors' that are dict. See https://github.com/pycontribs/jira/issues/350 + # Jira 6.x error messages are found in this array. + error_list = resp_errors.values() + parsed_error = ", ".join(error_list) + + return parsed_error class ResilientSession(Session): """This class is supposed to retry requests that do return temporary errors. - At this moment it supports: 429 + :py:meth:`__recoverable` handles all retry-able errors. """ - def __init__(self, timeout=None): - self.max_retries = 3 - self.max_retry_delay = 60 - self.timeout = timeout + def __init__(self, timeout=None, max_retries: int = 3, max_retry_delay: int = 60): + """A Session subclass catered for the Jira API with exponential delaying retry. + + Args: + timeout (Optional[int]): Timeout. Defaults to None. + max_retries (int): Max number of times to retry a request. Defaults to 3. + max_retry_delay (int): Max delay allowed between retries. Defaults to 60. + """ + self.timeout = timeout # TODO: Unused? + self.max_retries = max_retries + self.max_retry_delay = max_retry_delay super().__init__() # Indicate our preference for JSON to avoid https://bitbucket.org/bspeakmon/jira-python/issue/46 and https://jira.atlassian.com/browse/JRA-38551 self.headers.update({"Accept": "application/json,*.*;q=0.9"}) + # Warn users on instantiation the debug level shouldn't be used for prod + LOG.debug( + "WARNING: On error, will dump Response headers and body to logs. " + + f"Log level debug in '{__name__}' is not safe for production code!" + ) + + def _jira_prepare(self, **original_kwargs) -> dict: + """Do any pre-processing of our own and return the updated kwargs.""" + prepared_kwargs = original_kwargs.copy() + + request_headers = self.headers.copy() + request_headers.update(original_kwargs.get("headers", {})) + prepared_kwargs["headers"] = request_headers + + data = original_kwargs.get("data", {}) + if isinstance(data, dict): + # mypy ensures we don't do this, + # but for people subclassing we should preserve old behaviour + prepared_kwargs["data"] = json.dumps(data) + + return prepared_kwargs + + def request( # type: ignore[override] # An intentionally different override + self, + method: str, + url: Union[str, bytes], + _prepare_retry_class: PrepareRequestForRetry = PassthroughRetryPrepare(), + **kwargs, + ) -> Response: + """This is an intentional override of `Session.request()` to inject some error handling and retry logic. + + Raises: + Exception: Various exceptions as defined in py:method:`raise_on_error`. + + Returns: + Response: The response. + """ + + retry_number = 0 + exception: Optional[Exception] = None + response: Optional[Response] = None + response_or_exception: Optional[Union[ConnectionError, Response]] + + processed_kwargs = self._jira_prepare(**kwargs) + + def is_allowed_to_retry() -> bool: + """Helper method to say if we should still be retrying.""" + return retry_number <= self.max_retries + + while is_allowed_to_retry(): + response = None + exception = None + + try: + response = super().request(method, url, **processed_kwargs) + if response.ok: + self.__handle_known_ok_response_errors(response) + return response + # Can catch further exceptions as required below + except ConnectionError as e: + exception = e + + # Decide if we should keep retrying + response_or_exception = response if response is not None else exception + retry_number += 1 + if is_allowed_to_retry() and self.__recoverable( + response_or_exception, url, method.upper(), retry_number + ): + _prepare_retry_class.prepare(processed_kwargs) # type: ignore[arg-type] # Dict and CaseInsensitiveDict are fine here + else: + retry_number = self.max_retries + 1 # exit the while loop, as above max + + if exception is not None: + # We got an exception we could not recover from + raise exception + elif raise_on_error(response, **processed_kwargs): + # raise_on_error will raise an exception if the response is invalid + return response + else: + # Shouldn't reach here...(but added for mypy's benefit) + raise RuntimeError("Expected a Response or Exception to raise!") + + def __handle_known_ok_response_errors(self, response: Response): + """Responses that report ok may also have errors. + + We can either log the error or raise the error as appropriate here. + + Args: + response (Response): The response. + """ + if not response.ok: + return # We use self.__recoverable() to handle these + if ( + len(response.content) == 0 + and "X-Seraph-LoginReason" in response.headers + and "AUTHENTICATED_FAILED" in response.headers["X-Seraph-LoginReason"] + ): + LOG.warning("Atlassian's bug https://jira.atlassian.com/browse/JRA-41559") + def __recoverable( self, response: Optional[Union[ConnectionError, Response]], - url: str, - request, + url: Union[str, bytes], + request_method: str, counter: int = 1, ): + """Return whether the request is recoverable and hence should be retried. + Exponentially delays if recoverable. + + At this moment it supports: 429 + + Args: + response (Optional[Union[ConnectionError, Response]]): The response or exception. + Note: the response here is expected to be ``not response.ok``. + url (Union[str, bytes]): The URL. + request_method (str): The request method. + counter (int, optional): The retry counter to use when calculating the exponential delay. Defaults to 1. + + Returns: + bool: True if the request should be retried. + """ + is_recoverable = False # Controls return value AND whether we delay or not, Not-recoverable by default msg = str(response) + if isinstance(response, ConnectionError): - logging.warning( - f"Got ConnectionError [{response}] errno:{response.errno} on {request} {url}\n{vars(response)}\n{response.__dict__}" + is_recoverable = True + LOG.warning( + f"Got ConnectionError [{response}] errno:{response.errno} on {request_method} " + + f"{url}\n" # type: ignore[str-bytes-safe] ) + if LOG.level > logging.DEBUG: + LOG.warning( + "Response headers for ConnectionError are only printed for log level DEBUG." + ) + if isinstance(response, Response): if response.status_code in [429]: + is_recoverable = True number_of_tokens_issued_per_interval = response.headers[ "X-RateLimit-FillRate" ] @@ -123,103 +292,27 @@ def __recoverable( maximum_number_of_tokens = response.headers["X-RateLimit-Limit"] retry_after = response.headers["retry-after"] msg = f"{response.status_code} {response.reason}" - logging.warning( + LOG.warning( f"Request rate limited by Jira: request should be retried after {retry_after} seconds.\n" + f"{number_of_tokens_issued_per_interval} tokens are issued every {token_issuing_rate_interval_seconds} seconds. " + f"You can accumulate up to {maximum_number_of_tokens} tokens.\n" + "Consider adding an exemption for the user as explained in: " + "https://confluence.atlassian.com/adminjiraserver/improving-instance-stability-with-rate-limiting-983794911.html" ) - elif not ( - response.status_code == 200 - and len(response.content) == 0 - and "X-Seraph-LoginReason" in response.headers - and "AUTHENTICATED_FAILED" in response.headers["X-Seraph-LoginReason"] - ): - return False - else: - msg = "Atlassian's bug https://jira.atlassian.com/browse/JRA-41559" - - # Exponential backoff with full jitter. - delay = min(self.max_retry_delay, 10 * 2**counter) * random.random() - logging.warning( - "Got recoverable error from %s %s, will retry [%s/%s] in %ss. Err: %s" - % (request, url, counter, self.max_retries, delay, msg) - ) - if isinstance(response, Response): - logging.debug("response.headers: %s", response.headers) - logging.debug("response.body: %s", response.content) - time.sleep(delay) - return True - - def __verb( - self, verb: str, url: str, retry_data: Callable = None, **kwargs - ) -> Response: - - d = self.headers.copy() - d.update(kwargs.get("headers", {})) - kwargs["headers"] = d - - # if we pass a dictionary as the 'data' we assume we want to send json - # data - data = kwargs.get("data", {}) - if isinstance(data, dict): - data = json.dumps(data) - retry_number = 0 - exception = None - response = None - while retry_number <= self.max_retries: - response = None - exception = None - try: - method = getattr(super(), verb.lower()) - response = method(url, timeout=self.timeout, **kwargs) - if response.status_code >= 200 and response.status_code <= 299: - return response - except ConnectionError as e: - logging.warning(f"{e} while doing {verb.upper()} {url}") - - exception = e - retry_number += 1 - - if retry_number <= self.max_retries: - response_or_exception = response if response is not None else exception - if self.__recoverable( - response_or_exception, url, verb.upper(), retry_number - ): - if retry_data: - # if data is a stream, we cannot just read again from it, - # retry_data() will give us a new stream with the data - kwargs["data"] = retry_data() - continue - else: - break - - if exception is not None: - raise exception - raise_on_error(response, verb=verb, **kwargs) - # after raise_on_error, only Response objects are allowed through - response = cast(Response, response) # tell mypy only Response-like are here - return response - - def get(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore - return self.__verb("GET", str(url), **kwargs) - - def post(self, url: Union[str, bytes], data=None, json=None, **kwargs) -> Response: # type: ignore - return self.__verb("POST", str(url), data=data, json=json, **kwargs) - - def put(self, url: Union[str, bytes], data=None, **kwargs) -> Response: # type: ignore - return self.__verb("PUT", str(url), data=data, **kwargs) - - def delete(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore - return self.__verb("DELETE", str(url), **kwargs) - - def head(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore - return self.__verb("HEAD", str(url), **kwargs) - - def patch(self, url: Union[str, bytes], data=None, **kwargs) -> Response: # type: ignore - return self.__verb("PATCH", str(url), data=data, **kwargs) + if is_recoverable: + # Exponential backoff with full jitter. + delay = min(self.max_retry_delay, 10 * 2**counter) * random.random() + LOG.warning( + "Got recoverable error from %s %s, will retry [%s/%s] in %ss. Err: %s" + % (request_method, url, counter, self.max_retries, delay, msg) # type: ignore[str-bytes-safe] + ) + if isinstance(response, Response): + LOG.debug( + "response.headers:\n%s", + json.dumps(dict(response.headers), indent=4), + ) + LOG.debug("response.body:\n%s", response.content) + time.sleep(delay) - def options(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore - return self.__verb("OPTIONS", str(url), **kwargs) + return is_recoverable diff --git a/jira/utils/__init__.py b/jira/utils/__init__.py index 2c0e5a266..954503237 100644 --- a/jira/utils/__init__.py +++ b/jira/utils/__init__.py @@ -56,11 +56,11 @@ def threaded_requests(requests): th.join() -def json_loads(r: Optional[Response]) -> Any: +def json_loads(resp: Optional[Response]) -> Any: """Attempts to load json the result of a response Args: - r (Optional[Response]): The Response object + resp (Optional[Response]): The Response object Raises: JIRAError: via :py:func:`jira.resilientsession.raise_on_error` @@ -68,12 +68,12 @@ def json_loads(r: Optional[Response]) -> Any: Returns: Union[List[Dict[str, Any]], Dict[str, Any]]: the json """ - raise_on_error(r) # if 'r' is None, will raise an error here - r = cast(Response, r) # tell mypy only Response-like are here + raise_on_error(resp) # if 'resp' is None, will raise an error here + resp = cast(Response, resp) # tell mypy only Response-like are here try: - return r.json() + return resp.json() except ValueError: # json.loads() fails with empty bodies - if not r.text: + if not resp.text: return {} raise diff --git a/tests/test_resilientsession.py b/tests/test_resilientsession.py index 023fc5c8e..22e875228 100644 --- a/tests/test_resilientsession.py +++ b/tests/test_resilientsession.py @@ -71,15 +71,15 @@ def tearDown(self): ] -@patch("requests.Session.get") -@patch("time.sleep") +@patch("requests.Session.request") +@patch(f"{jira.resilientsession.__name__}.time.sleep") @pytest.mark.parametrize( "status_code,expected_number_of_retries,expected_number_of_sleep_invocations", status_codes_retries_test_data, ) def test_status_codes_retries( mocked_sleep_method: Mock, - mocked_get_method: Mock, + mocked_request_method: Mock, status_code: int, expected_number_of_retries: int, expected_number_of_sleep_invocations: int, @@ -90,11 +90,20 @@ def test_status_codes_retries( mocked_response.headers["X-RateLimit-Interval-Seconds"] = "1" mocked_response.headers["retry-after"] = "1" mocked_response.headers["X-RateLimit-Limit"] = "1" - mocked_get_method.return_value = mocked_response + mocked_request_method.return_value = mocked_response session: jira.resilientsession.ResilientSession = ( jira.resilientsession.ResilientSession() ) with pytest.raises(JIRAError): session.get("mocked_url") - assert mocked_get_method.call_count == expected_number_of_retries + assert mocked_request_method.call_count == expected_number_of_retries assert mocked_sleep_method.call_count == expected_number_of_sleep_invocations + + +def test_passthrough_class(): + # GIVEN: The passthrough class and a dict of request args + passthrough_class = jira.resilientsession.PassthroughRetryPrepare() + my_kwargs = {"nice": "arguments"} + # WHEN: the dict of request args are prepared + # THEN: The exact same dict is returned + assert passthrough_class.prepare(my_kwargs) is my_kwargs