From ced75f85c5e0b54449eea8cf74958b52be1e82df Mon Sep 17 00:00:00 2001 From: Richard Marmorstein Date: Thu, 14 Dec 2023 10:22:13 -0800 Subject: [PATCH] share logging --- stripe/_api_requestor.py | 39 ++--- tests/test_http_client.py | 335 +++++++++++++++++++++++++++++++++++++- 2 files changed, 345 insertions(+), 29 deletions(-) diff --git a/stripe/_api_requestor.py b/stripe/_api_requestor.py index 73fa7dfca..8feb6ba22 100644 --- a/stripe/_api_requestor.py +++ b/stripe/_api_requestor.py @@ -465,6 +465,20 @@ def _get_request_raw_args( return abs_url, headers, post_data, my_api_key + @staticmethod + def log_response(abs_url, rcode, rcontent, rheaders): + _util.log_info( + "Stripe API response", path=abs_url, response_code=rcode + ) + _util.log_debug("API response body", body=rcontent) + + if "Request-Id" in rheaders: + request_id = rheaders["Request-Id"] + _util.log_debug( + "Dashboard link for request", + link=_util.dashboard_link(request_id), + ) + def request_raw( self, method: str, @@ -493,17 +507,7 @@ def request_raw( method, abs_url, headers, post_data, _usage=_usage ) - _util.log_info( - "Stripe API response", path=abs_url, response_code=rcode - ) - _util.log_debug("API response body", body=rcontent) - - if "Request-Id" in rheaders: - request_id = rheaders["Request-Id"] - _util.log_debug( - "Dashboard link for request", - link=_util.dashboard_link(request_id), - ) + self.log_response(abs_url, rcode, rcontent, rheaders) return rcontent, rcode, rheaders, my_api_key @@ -539,18 +543,7 @@ async def request_raw_async( method, abs_url, headers, post_data, _usage=_usage ) - _util.log_info( - "Stripe API response", path=abs_url, response_code=rcode - ) - _util.log_debug("API response body", body=rcontent) - - if "Request-Id" in rheaders: - request_id = rheaders["Request-Id"] - _util.log_debug( - "Dashboard link for request", - link=_util.dashboard_link(request_id), - ) - + self.log_response(abs_url, rcode, rcontent, rheaders) return rcontent, rcode, rheaders, my_api_key def _should_handle_code_as_error(self, rcode): diff --git a/tests/test_http_client.py b/tests/test_http_client.py index c6881f5e5..edd261251 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -362,7 +362,7 @@ def test_request_stream( def test_exception(self, request_mock, mock_error): mock_error(request_mock) - with pytest.raises(stripe.error.APIConnectionError): + with pytest.raises(stripe.APIConnectionError): self.make_request("get", self.valid_url, {}, None) @@ -577,14 +577,14 @@ def test_retry_error_until_exceeded( self, mock_retry, response, check_call_numbers ): mock_retry(retry_error_num=self.max_retries()) - with pytest.raises(stripe.error.APIConnectionError): + with pytest.raises(stripe.APIConnectionError): self.make_request() check_call_numbers(self.max_retries()) def test_no_retry_error(self, mock_retry, response, check_call_numbers): mock_retry(no_retry_error_num=self.max_retries()) - with pytest.raises(stripe.error.APIConnectionError): + with pytest.raises(stripe.APIConnectionError): self.make_request() check_call_numbers(1) @@ -614,7 +614,7 @@ def test_retry_request_stream_error_until_exceeded( self, mock_retry, response, check_call_numbers ): mock_retry(retry_error_num=self.max_retries()) - with pytest.raises(stripe.error.APIConnectionError): + with pytest.raises(stripe.APIConnectionError): self.make_request_stream() check_call_numbers(self.max_retries(), is_streaming=True) @@ -623,7 +623,7 @@ def test_no_retry_request_stream_error( self, mock_retry, response, check_call_numbers ): mock_retry(no_retry_error_num=self.max_retries()) - with pytest.raises(stripe.error.APIConnectionError): + with pytest.raises(stripe.APIConnectionError): self.make_request_stream() check_call_numbers(1, is_streaming=True) @@ -648,7 +648,7 @@ def connection_error(self, session): client = self.REQUEST_CLIENT() def connection_error(given_exception): - with pytest.raises(stripe.error.APIConnectionError) as error: + with pytest.raises(stripe.APIConnectionError) as error: client._handle_request_error(given_exception) return error.value @@ -999,3 +999,326 @@ def test_encode_array(self): assert ("foo[0][dob][month]", 1) in values assert ("foo[0][name]", "bat") in values + + +class TestHTTPXClient(StripeClientTestCase, ClientTestBase): + HTTPX_CLIENT = _http_client.HTTPXClient + + @pytest.fixture + def session(self, mocker, request_mocks): + return mocker.MagicMock() + + @pytest.fixture + def mock_response(self, mocker, session): + def mock_response(mock, body, code): + result = mocker.Mock() + result.content = body + result.status_code = code + result.headers = {} + result.raw = urllib3.response.HTTPResponse( + body=util.io.BytesIO(str.encode(body)), + preload_content=False, + status=code, + ) + + session.request = mocker.MagicMock(return_value=result) + mock.Session = mocker.MagicMock(return_value=session) + + return mock_response + + @pytest.fixture + def mock_error(self, mocker, session): + def mock_error(mock): + # The first kind of request exceptions we catch + mock.exceptions.SSLError = Exception + session.request.side_effect = mock.exceptions.SSLError() + mock.Session = mocker.MagicMock(return_value=session) + + return mock_error + + # Note that unlike other modules, we don't use the "mock" argument here + # because we need to run the request call against the internal mock + # session. + @pytest.fixture + def check_call(self, session): + def check_call( + mock, + method, + url, + post_data, + headers, + is_streaming=False, + timeout=80, + times=None, + ): + times = times or 1 + args = (method, url) + kwargs = { + "headers": headers, + "data": post_data, + "verify": HTTPXVerify(), + "proxies": {"http": "http://slap/", "https": "http://slap/"}, + "timeout": timeout, + } + + if is_streaming: + kwargs["stream"] = True + + calls = [(args, kwargs) for _ in range(times)] + session.request.assert_has_calls(calls) + + return check_call + + def make_request(self, method, url, headers, post_data, timeout=80): + client = self.HTTPX_CLIENT( + verify_ssl_certs=True, timeout=timeout, proxy="http://slap/" + ) + return client.request_with_retries(method, url, headers, post_data) + + def make_request_stream(self, method, url, headers, post_data, timeout=80): + client = self.HTTPX_CLIENT( + verify_ssl_certs=True, timeout=timeout, proxy="http://slap/" + ) + return client.request_stream_with_retries( + method, url, headers, post_data + ) + + def test_timeout(self, request_mock, mock_response, check_call): + headers = {"my-header": "header val"} + data = "" + mock_response(request_mock, '{"foo": "baz"}', 200) + self.make_request("POST", self.valid_url, headers, data, timeout=5) + + check_call(None, "POST", self.valid_url, data, headers, timeout=5) + + def test_request_stream_forwards_stream_param( + self, mocker, request_mock, mock_response, check_call + ): + mock_response(request_mock, "some streamed content", 200) + self.make_request_stream("GET", self.valid_url, {}, None) + + check_call( + None, + "GET", + self.valid_url, + None, + {}, + is_streaming=True, + ) + + +class TestRequestClientRetryBehavior(TestHTTPXClient): + @pytest.fixture + def response(self, mocker): + def response(code=200, headers={}): + result = mocker.Mock() + result.content = "{}" + result.status_code = code + result.headers = headers + result.raw = urllib3.response.HTTPResponse( + body=util.io.BytesIO(str.encode(result.content)), + preload_content=False, + status=code, + ) + + return result + + return response + + @pytest.fixture + def mock_retry(self, mocker, session, request_mock): + def mock_retry(retry_error_num=0, no_retry_error_num=0, responses=[]): + # Mocking classes of exception we catch. Any group of exceptions + # with the same inheritance pattern will work + request_root_error_class = Exception + request_mock.exceptions.RequestException = request_root_error_class + + no_retry_parent_class = LookupError + no_retry_child_class = KeyError + request_mock.exceptions.SSLError = no_retry_parent_class + no_retry_errors = [no_retry_child_class()] * no_retry_error_num + + retry_parent_class = EnvironmentError + retry_child_class = IOError + request_mock.exceptions.Timeout = retry_parent_class + request_mock.exceptions.ConnectionError = retry_parent_class + retry_errors = [retry_child_class()] * retry_error_num + + # Include mock responses as possible side-effects + # to simulate returning proper results after some exceptions + session.request.side_effect = ( + retry_errors + no_retry_errors + responses + ) + + request_mock.Session = mocker.MagicMock(return_value=session) + return request_mock + + return mock_retry + + @pytest.fixture + def check_call_numbers(self, check_call): + valid_url = self.valid_url + + def check_call_numbers(times, is_streaming=False): + check_call( + None, + "GET", + valid_url, + None, + {}, + times=times, + is_streaming=is_streaming, + ) + + return check_call_numbers + + def max_retries(self): + return 3 + + def make_client(self): + client = self.HTTPX_CLIENT( + verify_ssl_certs=True, timeout=80, proxy="http://slap/" + ) + # Override sleep time to speed up tests + client._sleep_time = lambda _: 0.0001 + # Override configured max retries + client._max_network_retries = lambda: self.max_retries() + return client + + async def make_request(self, *args, **kwargs): + client = self.make_client() + return await client.request_with_retries("GET", self.valid_url, {}, None) + + async def make_request_stream(self, *args, **kwargs): + client = self.make_client() + return await client.request_stream_with_retries( + "GET", self.valid_url, {}, None + ) + + async def test_retry_error_until_response( + self, mock_retry, response, check_call_numbers + ): + mock_retry(retry_error_num=1, responses=[response(code=202)]) + _, code, _ = await self.make_request() + assert code == 202 + check_call_numbers(2) + + async def test_retry_error_until_exceeded( + self, mock_retry, response, check_call_numbers + ): + mock_retry(retry_error_num=self.max_retries()) + with pytest.raises(stripe.APIConnectionError): + await self.make_request() + + check_call_numbers(self.max_retries()) + + async def test_no_retry_error(self, mock_retry, response, check_call_numbers): + mock_retry(no_retry_error_num=self.max_retries()) + with pytest.raises(stripe.APIConnectionError): + await self.make_request() + check_call_numbers(1) + + async def test_retry_codes(self, mock_retry, response, check_call_numbers): + mock_retry(responses=[response(code=409), response(code=202)]) + _, code, _ = await self.make_request() + assert code == 202 + check_call_numbers(2) + + async def test_retry_codes_until_exceeded( + self, mock_retry, response, check_call_numbers + ): + mock_retry(responses=[response(code=409)] * (self.max_retries() + 1)) + _, code, _ = await self.make_request() + assert code == 409 + check_call_numbers(self.max_retries() + 1) + + async def test_retry_request_stream_error_until_response( + self, mock_retry, response, check_call_numbers + ): + mock_retry(retry_error_num=1, responses=[response(code=202)]) + _, code, _ = await self.make_request_stream() + assert code == 202 + check_call_numbers(2, is_streaming=True) + + async def test_retry_request_stream_error_until_exceeded( + self, mock_retry, response, check_call_numbers + ): + mock_retry(retry_error_num=self.max_retries()) + with pytest.raises(stripe.APIConnectionError): + await self.make_request_stream() + + check_call_numbers(self.max_retries(), is_streaming=True) + + async def test_no_retry_request_stream_error( + self, mock_retry, response, check_call_numbers + ): + mock_retry(no_retry_error_num=self.max_retries()) + with pytest.raises(stripe.APIConnectionError): + await self.make_request_stream() + check_call_numbers(1, is_streaming=True) + + async def test_retry_request_stream_codes( + self, mock_retry, response, check_call_numbers + ): + mock_retry(responses=[response(code=409), response(code=202)]) + _, code, _ = await self.make_request_stream() + assert code == 202 + check_call_numbers(2, is_streaming=True) + + async def test_retry_request_stream_codes_until_exceeded( + self, mock_retry, response, check_call_numbers + ): + mock_retry(responses=[response(code=409)] * (self.max_retries() + 1)) + _, code, _ = awayt self.make_request_stream() + assert code == 409 + check_call_numbers(self.max_retries() + 1, is_streaming=True) + + @pytest.fixture + def connection_error(self, session): + client = self.HTTPX_CLIENT() + + def connection_error(given_exception): + with pytest.raises(stripe.APIConnectionError) as error: + client._handle_request_error(given_exception) + return error.value + + return connection_error + + def test_handle_request_error_should_retry( + self, connection_error, mock_retry + ): + request_mock = mock_retry() + + error = connection_error(request_mock.exceptions.Timeout()) + assert error.should_retry + + error = connection_error(request_mock.exceptions.ConnectionError()) + assert error.should_retry + + def test_handle_request_error_should_not_retry( + self, connection_error, mock_retry + ): + request_mock = mock_retry() + + error = connection_error(request_mock.exceptions.SSLError()) + assert error.should_retry is False + assert "not verify Stripe's SSL certificate" in error.user_message + + error = connection_error(request_mock.exceptions.RequestException()) + assert error.should_retry is False + + # Mimic non-requests exception as not being children of Exception, + # See mock_retry for the exceptions setup + error = connection_error(BaseException("")) + assert error.should_retry is False + assert "configuration issue locally" in error.user_message + + # Skip inherited basic requests client tests + def test_request(self, request_mock, mock_response, check_call): + pass + + def test_exception(self, request_mock, mock_error): + pass + + def test_timeout(self, request_mock, mock_response, check_call): + pass