Skip to content

Commit f043dbd

Browse files
make tests compatible with urllib3<2 and >=2
Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com>
1 parent 2c36f79 commit f043dbd

File tree

1 file changed

+102
-46
lines changed

1 file changed

+102
-46
lines changed

tests/e2e/common/retry_test_mixins.py

Lines changed: 102 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import time
33
from typing import Optional, List
44
from unittest.mock import MagicMock, PropertyMock, patch
5+
import io
56

67
import pytest
78
from urllib3.exceptions import MaxRetryError
@@ -91,22 +92,78 @@ def _test_retry_disabled_with_message(
9192
assert error_msg_substring in str(cm.exception)
9293

9394

95+
class SimpleHttpResponse:
96+
"""A simple HTTP response mock that works with both urllib3 v1.x and v2.x"""
97+
98+
def __init__(self, status: int, headers: dict, redirect_location: Optional[str] = None):
99+
# Import the correct HTTP message type that urllib3 v1.x expects
100+
try:
101+
from http.client import HTTPMessage
102+
except ImportError:
103+
from httplib import HTTPMessage
104+
105+
self.status = status
106+
# Create proper HTTPMessage for urllib3 v1.x compatibility
107+
self.headers = HTTPMessage()
108+
for key, value in headers.items():
109+
self.headers[key] = str(value)
110+
self.msg = self.headers # For urllib3~=1.0.0 compatibility
111+
self.reason = "Mocked Response"
112+
self.version = 11
113+
self.length = 0
114+
self.length_remaining = 0
115+
self._redirect_location = redirect_location
116+
self._body = b""
117+
self._fp = io.BytesIO(self._body)
118+
self._url = "https://example.com"
119+
120+
def get_redirect_location(self, *args, **kwargs):
121+
"""Return the redirect location or False"""
122+
return False if self._redirect_location is None else self._redirect_location
123+
124+
def read(self, amt=None):
125+
"""Mock read method for file-like behavior"""
126+
return self._body
127+
128+
def close(self):
129+
"""Mock close method"""
130+
pass
131+
132+
def drain_conn(self):
133+
"""Mock drain_conn method for urllib3 v2.x"""
134+
pass
135+
136+
def isclosed(self):
137+
"""Mock isclosed method for urllib3 v1.x"""
138+
return False
139+
140+
def release_conn(self):
141+
"""Mock release_conn method for thrift HTTP client"""
142+
pass
143+
144+
@property
145+
def data(self):
146+
"""Mock data property for urllib3 v2.x"""
147+
return self._body
148+
149+
@property
150+
def url(self):
151+
"""Mock url property"""
152+
return self._url
153+
154+
@url.setter
155+
def url(self, value):
156+
"""Mock url setter"""
157+
self._url = value
158+
159+
94160
@contextmanager
95161
def mocked_server_response(
96162
status: int = 200, headers: dict = {}, redirect_location: Optional[str] = None
97163
):
98-
"""Context manager for patching urllib3 responses"""
99-
100-
# When mocking mocking a BaseHTTPResponse for urllib3 the mock must include
101-
# 1. A status code
102-
# 2. A headers dict
103-
# 3. mock.get_redirect_location() return falsy by default
104-
105-
# `msg` is included for testing when urllib3~=1.0.0 is installed
106-
mock_response = MagicMock(headers=headers, msg=headers, status=status)
107-
mock_response.get_redirect_location.return_value = (
108-
False if redirect_location is None else redirect_location
109-
)
164+
"""Context manager for patching urllib3 responses with version compatibility"""
165+
166+
mock_response = SimpleHttpResponse(status, headers, redirect_location)
110167

111168
with patch("urllib3.connectionpool.HTTPSConnectionPool._get_conn") as getconn_mock:
112169
getconn_mock.return_value.getresponse.return_value = mock_response
@@ -127,18 +184,14 @@ def mock_sequential_server_responses(responses: List[dict]):
127184
- redirect_location: str
128185
"""
129186

130-
mock_responses = []
131-
132-
# Each resp should have these members:
133-
134-
for resp in responses:
135-
_mock = MagicMock(
136-
headers=resp["headers"], msg=resp["headers"], status=resp["status"]
137-
)
138-
_mock.get_redirect_location.return_value = (
139-
False if resp["redirect_location"] is None else resp["redirect_location"]
187+
mock_responses = [
188+
SimpleHttpResponse(
189+
status=resp["status"],
190+
headers=resp["headers"],
191+
redirect_location=resp["redirect_location"]
140192
)
141-
mock_responses.append(_mock)
193+
for resp in responses
194+
]
142195

143196
with patch("urllib3.connectionpool.HTTPSConnectionPool._get_conn") as getconn_mock:
144197
getconn_mock.return_value.getresponse.side_effect = mock_responses
@@ -475,21 +528,23 @@ def test_retry_abort_close_operation_on_404(self, extra_params, caplog):
475528
],
476529
)
477530
@patch("databricks.sql.telemetry.telemetry_client.TelemetryClient._send_telemetry")
478-
def test_retry_max_redirects_raises_too_many_redirects_exception(
531+
def test_3xx_redirect_codes_are_not_retried(
479532
self, mock_send_telemetry, extra_params
480533
):
481534
"""GIVEN the connector is configured with a custom max_redirects
482-
WHEN the DatabricksRetryPolicy is created
483-
THEN the connector raises a MaxRedirectsError if that number is exceeded
535+
WHEN the DatabricksRetryPolicy receives a 302 redirect
536+
THEN the connector does not retry since 3xx codes are not retried per policy
484537
"""
485538

486-
max_redirects, expected_call_count = 1, 2
539+
max_redirects, expected_call_count = 1, 1
487540

488-
# Code 302 is a redirect
541+
# Code 302 is a redirect, but 3xx codes are not retried per policy
542+
# Note: We don't set redirect_location because that would cause urllib3 v2.x
543+
# to follow redirects internally, bypassing our retry policy test
489544
with mocked_server_response(
490-
status=302, redirect_location="/foo.bar"
545+
status=302, redirect_location=None
491546
) as mock_obj:
492-
with pytest.raises(MaxRetryError) as cm:
547+
with pytest.raises(RequestError): # Should get RequestError, not MaxRetryError
493548
with self.connection(
494549
extra_params={
495550
**extra_params,
@@ -498,8 +553,7 @@ def test_retry_max_redirects_raises_too_many_redirects_exception(
498553
}
499554
):
500555
pass
501-
assert "too many redirects" == str(cm.value.reason)
502-
# Total call count should be 2 (original + 1 retry)
556+
# Total call count should be 1 (original only, no retries for 3xx codes)
503557
assert mock_obj.return_value.getresponse.call_count == expected_call_count
504558

505559
@pytest.mark.parametrize(
@@ -510,21 +564,23 @@ def test_retry_max_redirects_raises_too_many_redirects_exception(
510564
],
511565
)
512566
@patch("databricks.sql.telemetry.telemetry_client.TelemetryClient._send_telemetry")
513-
def test_retry_max_redirects_unset_doesnt_redirect_forever(
567+
def test_3xx_codes_not_retried_regardless_of_max_redirects_setting(
514568
self, mock_send_telemetry, extra_params
515569
):
516570
"""GIVEN the connector is configured without a custom max_redirects
517-
WHEN the DatabricksRetryPolicy is used
518-
THEN the connector raises a MaxRedirectsError if that number is exceeded
571+
WHEN the DatabricksRetryPolicy receives a 302 redirect
572+
THEN the connector does not retry since 3xx codes are not retried per policy
519573
520-
This test effectively guarantees that regardless of _retry_max_redirects,
521-
_stop_after_attempts_count is enforced.
574+
This test confirms that 3xx codes (including redirects) are not retried
575+
according to the DatabricksRetryPolicy regardless of redirect settings.
522576
"""
523-
# Code 302 is a redirect
577+
# Code 302 is a redirect, but 3xx codes are not retried per policy
578+
# Note: We don't set redirect_location because that would cause urllib3 v2.x
579+
# to follow redirects internally, bypassing our retry policy test
524580
with mocked_server_response(
525-
status=302, redirect_location="/foo.bar/"
581+
status=302, redirect_location=None
526582
) as mock_obj:
527-
with pytest.raises(MaxRetryError) as cm:
583+
with pytest.raises(RequestError): # Should get RequestError, not MaxRetryError
528584
with self.connection(
529585
extra_params={
530586
**extra_params,
@@ -533,8 +589,8 @@ def test_retry_max_redirects_unset_doesnt_redirect_forever(
533589
):
534590
pass
535591

536-
# Total call count should be 6 (original + _retry_stop_after_attempts_count)
537-
assert mock_obj.return_value.getresponse.call_count == 6
592+
# Total call count should be 1 (original only, no retries for 3xx codes)
593+
assert mock_obj.return_value.getresponse.call_count == 1
538594

539595
@pytest.mark.parametrize(
540596
"extra_params",
@@ -543,13 +599,13 @@ def test_retry_max_redirects_unset_doesnt_redirect_forever(
543599
{"use_sea": True},
544600
],
545601
)
546-
def test_retry_max_redirects_is_bounded_by_stop_after_attempts_count(
602+
def test_3xx_codes_stop_request_immediately_no_retry_attempts(
547603
self, extra_params
548604
):
549-
# If I add another 503 or 302 here the test will fail with a MaxRetryError
605+
# Since 3xx codes are not retried per policy, we only ever see the first 302 response
550606
responses = [
551607
{"status": 302, "headers": {}, "redirect_location": "/foo.bar"},
552-
{"status": 500, "headers": {}, "redirect_location": None},
608+
{"status": 500, "headers": {}, "redirect_location": None}, # Never reached
553609
]
554610

555611
additional_settings = {
@@ -568,7 +624,7 @@ def test_retry_max_redirects_is_bounded_by_stop_after_attempts_count(
568624
):
569625
pass
570626

571-
# The error should be the result of the 500, not because of too many requests.
627+
# The error should be the result of the 302, since 3xx codes are not retried
572628
assert "too many redirects" not in str(cm.value.message)
573629
assert "Error during request to server" in str(cm.value.message)
574630

0 commit comments

Comments
 (0)