Skip to content

Commit 680c12f

Browse files
author
Daniel Bush
committed
test: better retry test
1 parent b3dcb68 commit 680c12f

File tree

7 files changed

+158
-61
lines changed

7 files changed

+158
-61
lines changed

.github/workflows/build.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ jobs:
4242
type pip
4343
type pytest
4444
pip freeze
45-
pytest -s tests/*.py
45+
pytest -s tests/test*.py

pytest.ini

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
norecursedirs = util tests/util

sypht/client.py

+6-8
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ def __init__(
7676
self._authenticate_client()
7777

7878
@property
79-
def _retry_adapter(self):
80-
retry_strategy = Retry(
79+
def _create_session(self):
80+
session = requests.Session()
81+
retries = Retry(
8182
total=None, # set connect, read, redirect, status, other instead
8283
connect=3,
8384
read=3,
@@ -88,13 +89,10 @@ def _retry_adapter(self):
8889
allowed_methods=["GET"],
8990
respect_retry_after_header=False,
9091
backoff_factor=0.5, # 0.0, 0.5, 1.0, 2.0, 4.0
92+
# Support manual status handling in _parse_response.
93+
raise_on_status=False,
9194
)
92-
return HTTPAdapter(max_retries=retry_strategy)
93-
94-
@property
95-
def _create_session(self):
96-
session = requests.Session()
97-
session.mount(self.base_endpoint, self._retry_adapter)
95+
session.mount(self.base_endpoint, HTTPAdapter(max_retries=retries))
9896
return session
9997

10098
def _authenticate_v2(self, endpoint, client_id, client_secret, audience):

tests/__init__.py

Whitespace-only changes.

tests/tests_client.py

+58-52
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from sypht.client import SyphtClient
99

10+
from .util.mock_http_server import MockRequestHandler, MockServerSession
11+
1012

1113
def validate_uuid4(uuid_string):
1214
try:
@@ -99,60 +101,64 @@ class RetryTest(unittest.TestCase):
99101

100102
@patch.object(SyphtClient, "_authenticate_v2", return_value=("access_token", 100))
101103
@patch.object(SyphtClient, "_authenticate_v1", return_value=("access_token2", 100))
102-
@patch("urllib3.connectionpool.HTTPConnectionPool._get_conn")
103-
def test_it_should_eventually_fail_for_50x(
104-
self, getconn_mock: Mock, auth_v1: Mock, auth_v2: Mock
105-
):
106-
"""See https://stackoverflow.com/questions/66497627/how-to-test-retry-attempts-in-python-using-the-request-library ."""
107-
104+
def test_it_should_eventually_fail_for_50x(self, auth_v1: Mock, auth_v2: Mock):
108105
# arrange
109-
getconn_mock.return_value.getresponse.side_effect = [
110-
Mock(status=502, msg=HTTPMessage()),
111-
# Retries start from here...
112-
# There should be n for where Retry(status=n).
113-
Mock(status=502, msg=HTTPMessage()),
114-
Mock(status=503, msg=HTTPMessage()),
115-
Mock(status=504, msg=HTTPMessage()),
116-
]
117-
sypht_client = SyphtClient()
118-
119-
# act / assert
120-
with self.assertRaisesRegex(Exception, "Max retries exceeded") as e:
121-
sypht_client.get_annotations(
122-
from_date=datetime(
123-
year=2021, month=1, day=1, hour=0, minute=0, second=0
124-
).strftime("%Y-%m-%d"),
125-
to_date=datetime(
126-
year=2021, month=1, day=1, hour=0, minute=0, second=0
127-
).strftime("%Y-%m-%d"),
106+
requests = []
107+
108+
def create_request_handler(*args, **kwargs):
109+
response_sequences = {
110+
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01": [
111+
(502, {}),
112+
# Retries start from here...
113+
# There should be n for where Retry(status=n).
114+
(503, {}),
115+
(504, {}),
116+
(502, {}),
117+
],
118+
}
119+
return MockRequestHandler(
120+
*args, **kwargs, requests=requests, responses=response_sequences
121+
)
122+
123+
with MockServerSession(create_request_handler) as address:
124+
sypht_client = SyphtClient(base_endpoint=address)
125+
126+
# act / assert
127+
with self.assertRaisesRegex(Exception, ".") as e:
128+
sypht_client.get_annotations(
129+
from_date=datetime(
130+
year=2021, month=1, day=1, hour=0, minute=0, second=0
131+
).strftime("%Y-%m-%d"),
132+
to_date=datetime(
133+
year=2021, month=1, day=1, hour=0, minute=0, second=0
134+
).strftime("%Y-%m-%d"),
135+
)
136+
137+
self.assertEqual(
138+
[
139+
(
140+
"GET",
141+
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
142+
{},
143+
),
144+
(
145+
"GET",
146+
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
147+
{},
148+
),
149+
(
150+
"GET",
151+
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
152+
{},
153+
),
154+
(
155+
"GET",
156+
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
157+
{},
158+
),
159+
],
160+
requests,
128161
)
129-
assert getconn_mock.return_value.request.mock_calls == [
130-
call(
131-
"GET",
132-
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
133-
body=None,
134-
headers=ANY,
135-
),
136-
# Retries start here...
137-
call(
138-
"GET",
139-
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
140-
body=None,
141-
headers=ANY,
142-
),
143-
call(
144-
"GET",
145-
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
146-
body=None,
147-
headers=ANY,
148-
),
149-
call(
150-
"GET",
151-
"/app/annotations?offset=0&fromDate=2021-01-01&toDate=2021-01-01",
152-
body=None,
153-
headers=ANY,
154-
),
155-
]
156162

157163

158164
if __name__ == "__main__":

tests/util/__init__.py

Whitespace-only changes.

tests/util/mock_http_server.py

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import http.server
2+
import json
3+
import socketserver
4+
import threading
5+
from typing import Callable
6+
7+
8+
class MockRequestHandler(http.server.SimpleHTTPRequestHandler):
9+
# class MockRequestHandler(http.server.CGIHTTPRequestHandler):
10+
def __init__(self, *args, responses=None, requests=[], **kwargs):
11+
self.response_sequences = responses
12+
self.requests = requests
13+
super().__init__(*args, **kwargs)
14+
15+
def log_message(self, format, *args):
16+
"""Suppress logging to stdout."""
17+
pass
18+
19+
def do_GET(self):
20+
status = 404
21+
response = {}
22+
if self.path in self.response_sequences:
23+
responses = self.response_sequences[self.path]
24+
if responses:
25+
print(f"<< pop {self.path}")
26+
status, response = responses.pop(0)
27+
self.requests.append((self.command, self.path, response))
28+
else:
29+
raise Exception(f"Unexpected path: {self.path}")
30+
31+
self.send_response(status)
32+
self.send_header("Content-type", "application/json")
33+
self.end_headers()
34+
if response:
35+
self.wfile.write(json.dumps(response).encode())
36+
37+
38+
class MockServer(socketserver.TCPServer):
39+
allow_reuse_address = True
40+
"""I think this implements socket.SO_REUSEADDR, which allows the server to restart without waiting for a TIME_WAIT to expire from a previous run of the code that left a socket dangling (in a separate process). Otherwise back-to-back server starts can fail with "socket already in use" error."""
41+
42+
43+
def start_test_server(
44+
create_request_handler: Callable[..., http.server.BaseHTTPRequestHandler]
45+
):
46+
host = "localhost"
47+
port = 4444
48+
address = f"http://{host}:{port}"
49+
httpd = MockServer((host, port), create_request_handler)
50+
httpd_thread = threading.Thread(target=httpd.serve_forever)
51+
httpd_thread.daemon = True
52+
httpd_thread.start()
53+
return address, httpd, httpd_thread
54+
55+
56+
class MockServerSession:
57+
"""Use this in tests to start a test server and shut it down when the test is done.
58+
59+
Example:
60+
61+
def create_request_handler(*args, **kwargs):
62+
...
63+
return MockRequestHandler(*args, **kwargs, responses=response_sequences)
64+
65+
with TestServerSession(create_request_handler):
66+
...
67+
"""
68+
69+
__test__ = False
70+
"""Stop pytest trying to "collect" this class as a test."""
71+
72+
def __init__(
73+
self, create_request_handler: Callable[..., http.server.BaseHTTPRequestHandler]
74+
):
75+
self.create_request_handler = create_request_handler
76+
77+
def __enter__(self):
78+
self.address, self.httpd, self.httpd_thread = start_test_server(
79+
self.create_request_handler
80+
)
81+
return self.address
82+
83+
def __exit__(self, exc_type, exc_val, exc_tb):
84+
self.httpd.shutdown()
85+
self.httpd_thread.join()
86+
87+
88+
if __name__ == "__main__":
89+
# To test this server in the terminal, run:
90+
httpd, httpd_thread = start_test_server()
91+
httpd_thread.join()

0 commit comments

Comments
 (0)