Skip to content

Commit 4975646

Browse files
authored
Retry logic change proposal (#73)
1 parent 55a44af commit 4975646

File tree

4 files changed

+87
-3
lines changed

4 files changed

+87
-3
lines changed

gql/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(
2525
type_def=None,
2626
transport=None,
2727
fetch_schema_from_transport=False,
28-
retries=0,
28+
retries=0, # We should remove this parameter and let the transport level handle it
2929
):
3030
assert not (
3131
type_def and introspection
@@ -93,3 +93,13 @@ def _get_result(self, document, *args, **kwargs):
9393
retries_count += 1
9494

9595
raise RetryError(retries_count, last_exception)
96+
97+
def close(self):
98+
"""Close the client and it's underlying transport"""
99+
self.transport.close()
100+
101+
def __enter__(self):
102+
return self
103+
104+
def __exit__(self, *args):
105+
self.close()

gql/transport/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,12 @@ def execute(self, document):
2020
raise NotImplementedError(
2121
"Any Transport subclass must implement execute method"
2222
)
23+
24+
def close(self):
25+
"""Close the transport
26+
27+
This method doesn't have to be implemented unless the transport would benefit from it.
28+
This is currently used by the RequestsHTTPTransport transport to close the session's
29+
connection pool.
30+
"""
31+
pass

gql/transport/requests.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from graphql.execution import ExecutionResult
77
from graphql.language.ast import Document
88
from graphql.language.printer import print_ast
9+
from requests.adapters import HTTPAdapter, Retry
910
from requests.auth import AuthBase
1011
from requests.cookies import RequestsCookieJar
1112

@@ -27,6 +28,7 @@ def __init__(
2728
use_json=False, # type: bool
2829
timeout=None, # type: int
2930
verify=True, # type: bool
31+
retries=0, # type: int
3032
**kwargs # type: Any
3133
):
3234
"""Initialize the transport with the given request parameters.
@@ -40,6 +42,7 @@ def __init__(
4042
:param verify: Either a boolean, in which case it controls whether we verify
4143
the server's TLS certificate, or a string, in which case it must be a path
4244
to a CA bundle to use. (Default: True).
45+
:param retries: Pre-setup of the requests' Session for performing retries
4346
:param kwargs: Optional arguments that ``request`` takes. These can be seen at the :requests_: source code
4447
or the official :docs_:
4548
@@ -55,6 +58,21 @@ def __init__(
5558
self.verify = verify
5659
self.kwargs = kwargs
5760

61+
# Creating a session that can later be re-use to configure custom mechanisms
62+
self.session = requests.Session()
63+
64+
# If we specified some retries, we provide a predefined retry-logic
65+
if retries > 0:
66+
adapter = HTTPAdapter(
67+
max_retries=Retry(
68+
total=retries,
69+
backoff_factor=0.1,
70+
status_forcelist=[500, 502, 503, 504],
71+
)
72+
)
73+
for prefix in "http://", "https://":
74+
self.session.mount(prefix, adapter)
75+
5876
def execute(self, document, variable_values=None, timeout=None):
5977
# type: (Document, Dict, int) -> ExecutionResult
6078
"""Execute the provided document AST against the configured remote server.
@@ -82,7 +100,8 @@ def execute(self, document, variable_values=None, timeout=None):
82100
# Pass kwargs to requests post method
83101
post_args.update(self.kwargs)
84102

85-
response = requests.post(self.url, **post_args) # type: ignore
103+
# Using the created session to perform requests
104+
response = self.session.post(self.url, **post_args) # type: ignore
86105
try:
87106
result = response.json()
88107
if not isinstance(result, dict):
@@ -96,3 +115,7 @@ def execute(self, document, variable_values=None, timeout=None):
96115
"Server did not return a GraphQL result", response=response
97116
)
98117
return ExecutionResult(errors=result.get("errors"), data=result.get("data"))
118+
119+
def close(self):
120+
"""Closing the transport by closing the inner session"""
121+
self.session.close()

tests/test_client.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import mock
44
import pytest
5+
from urllib3.exceptions import NewConnectionError
6+
57
from graphql import build_ast_schema, parse
68

79
from gql import Client, gql
@@ -56,10 +58,46 @@ def test_retries(execute_mock):
5658

5759
with pytest.raises(Exception):
5860
client.execute(query)
59-
61+
client.close()
6062
assert execute_mock.call_count == expected_retries
6163

6264

65+
@mock.patch("urllib3.connection.HTTPConnection._new_conn")
66+
def test_retries_on_transport(execute_mock):
67+
"""Testing retries on the transport level
68+
69+
This forces us to override low-level APIs because the retry mechanism on the urllib3 (which
70+
uses requests) is pretty low-level itself.
71+
"""
72+
expected_retries = 3
73+
execute_mock.side_effect = NewConnectionError(
74+
"Should be HTTPConnection", "Fake connection error"
75+
)
76+
transport = RequestsHTTPTransport(
77+
url="http://localhost:9999", retries=expected_retries,
78+
)
79+
client = Client(transport=transport)
80+
81+
query = gql(
82+
"""
83+
{
84+
myFavoriteFilm: film(id:"RmlsbToz") {
85+
id
86+
title
87+
episodeId
88+
}
89+
}
90+
"""
91+
)
92+
with client: # We're using the client as context manager
93+
with pytest.raises(Exception):
94+
client.execute(query)
95+
96+
# This might look strange compared to the previous test, but making 3 retries
97+
# means you're actually doing 4 calls.
98+
assert execute_mock.call_count == expected_retries + 1
99+
100+
63101
def test_no_schema_exception():
64102
with pytest.raises(Exception) as exc_info:
65103
client = Client()
@@ -95,6 +133,7 @@ def test_execute_result_error():
95133

96134
with pytest.raises(Exception) as exc_info:
97135
client.execute(failing_query)
136+
client.close()
98137
assert 'Cannot query field "id" on type "Continent".' in str(exc_info.value)
99138

100139

@@ -108,6 +147,7 @@ def test_http_transport_raise_for_status_error(http_transport_query):
108147

109148
with pytest.raises(Exception) as exc_info:
110149
client.execute(http_transport_query)
150+
client.close()
111151
assert "400 Client Error: Bad Request for url" in str(exc_info.value)
112152

113153

@@ -122,6 +162,7 @@ def test_http_transport_verify_error(http_transport_query):
122162
)
123163
with pytest.warns(Warning) as record:
124164
client.execute(http_transport_query)
165+
client.close() # We could have written `with client:` on top of the `with pytest...` instead
125166
assert len(record) == 1
126167
assert "Unverified HTTPS request is being made to host" in str(record[0].message)
127168

@@ -150,4 +191,5 @@ def test_gql():
150191
"""
151192
)
152193
result = client.execute(query)
194+
client.close()
153195
assert result["user"] is None

0 commit comments

Comments
 (0)