Skip to content

Commit 8898c38

Browse files
nejchmax-wittig
authored andcommitted
feat(client): add retry handling to GraphQL client
1 parent 3235c48 commit 8898c38

File tree

6 files changed

+113
-38
lines changed

6 files changed

+113
-38
lines changed

gitlab/client.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
try:
2828
import gql
29+
import gql.transport.exceptions
2930
import graphql
3031
import httpx
3132

@@ -746,7 +747,9 @@ def http_request(
746747
if 200 <= result.status_code < 300:
747748
return result.response
748749

749-
if retry.handle_retry_on_status(result):
750+
if retry.handle_retry_on_status(
751+
result.status_code, result.headers, result.reason
752+
):
750753
continue
751754

752755
error_message = result.content
@@ -1329,4 +1332,28 @@ def execute(
13291332
self, request: Union[str, graphql.Source], *args: Any, **kwargs: Any
13301333
) -> Any:
13311334
parsed_document = self._gql(request)
1332-
return self._client.execute(parsed_document, *args, **kwargs)
1335+
retry = utils.Retry(
1336+
max_retries=3, obey_rate_limit=True, retry_transient_errors=False
1337+
)
1338+
1339+
while True:
1340+
try:
1341+
result = self._client.execute(parsed_document, *args, **kwargs)
1342+
except gql.transport.exceptions.TransportServerError as e:
1343+
if retry.handle_retry_on_status(
1344+
status_code=e.code, headers=self._transport.response_headers
1345+
):
1346+
continue
1347+
1348+
if e.code == 401:
1349+
raise gitlab.exceptions.GitlabAuthenticationError(
1350+
response_code=e.code,
1351+
error_message=str(e),
1352+
)
1353+
1354+
raise gitlab.exceptions.GitlabHttpError(
1355+
response_code=e.code,
1356+
error_message=str(e),
1357+
)
1358+
1359+
return result

gitlab/utils.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@
66
import traceback
77
import urllib.parse
88
import warnings
9-
from typing import Any, Callable, Dict, Iterator, Literal, Optional, Tuple, Type, Union
9+
from typing import (
10+
Any,
11+
Callable,
12+
Dict,
13+
Iterator,
14+
Literal,
15+
MutableMapping,
16+
Optional,
17+
Tuple,
18+
Type,
19+
Union,
20+
)
1021

1122
import requests
1223

1324
from gitlab import const, types
14-
from gitlab._backends import requests_backend
1525

1626

1727
class _StdoutStream:
@@ -100,33 +110,40 @@ def __init__(
100110
self.retry_transient_errors = retry_transient_errors
101111

102112
def _retryable_status_code(
103-
self,
104-
result: requests_backend.RequestsResponse,
113+
self, status_code: Optional[int], reason: str = ""
105114
) -> bool:
106-
if result.status_code == 429 and self.obey_rate_limit:
115+
if status_code == 429 and self.obey_rate_limit:
107116
return True
108117

109118
if not self.retry_transient_errors:
110119
return False
111-
if result.status_code in const.RETRYABLE_TRANSIENT_ERROR_CODES:
120+
if status_code in const.RETRYABLE_TRANSIENT_ERROR_CODES:
112121
return True
113-
if result.status_code == 409 and "Resource lock" in result.reason:
122+
if status_code == 409 and "Resource lock" in reason:
114123
return True
115124

116125
return False
117126

118-
def handle_retry_on_status(self, result: requests_backend.RequestsResponse) -> bool:
119-
if not self._retryable_status_code(result):
127+
def handle_retry_on_status(
128+
self,
129+
status_code: Optional[int],
130+
headers: Optional[MutableMapping[str, str]] = None,
131+
reason: str = "",
132+
) -> bool:
133+
if not self._retryable_status_code(status_code, reason):
120134
return False
121135

136+
if headers is None:
137+
headers = {}
138+
122139
# Response headers documentation:
123140
# https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html#response-headers
124141
if self.max_retries == -1 or self.cur_retries < self.max_retries:
125142
wait_time = 2**self.cur_retries * 0.1
126-
if "Retry-After" in result.headers:
127-
wait_time = int(result.headers["Retry-After"])
128-
elif "RateLimit-Reset" in result.headers:
129-
wait_time = int(result.headers["RateLimit-Reset"]) - time.time()
143+
if "Retry-After" in headers:
144+
wait_time = int(headers["Retry-After"])
145+
elif "RateLimit-Reset" in headers:
146+
wait_time = int(headers["RateLimit-Reset"]) - time.time()
130147
self.cur_retries += 1
131148
time.sleep(wait_time)
132149
return True

requirements-lint.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mypy==1.11.2
88
pylint==3.2.7
99
pytest==8.3.2
1010
responses==0.25.3
11+
respx==0.21.1
1112
types-PyYAML==6.0.12.20240808
1213
types-requests==2.32.0.20240907
1314
types-setuptools==74.1.0.20240907

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ pytest-github-actions-annotate-failures==0.2.0
77
pytest==8.3.2
88
PyYaml==6.0.2
99
responses==0.25.3
10+
respx==0.21.1
1011
wheel==0.44.0

tests/unit/test_graphql.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import httpx
12
import pytest
3+
import respx
24

35
import gitlab
46

57

8+
@pytest.fixture
9+
def gl_gql() -> gitlab.GraphQL:
10+
return gitlab.GraphQL("https://gitlab.example.com")
11+
12+
613
def test_import_error_includes_message(monkeypatch: pytest.MonkeyPatch):
714
monkeypatch.setattr(gitlab.client, "_GQL_INSTALLED", False)
815
with pytest.raises(ImportError, match="GraphQL client could not be initialized"):
@@ -12,3 +19,42 @@ def test_import_error_includes_message(monkeypatch: pytest.MonkeyPatch):
1219
def test_graphql_as_context_manager_exits():
1320
with gitlab.GraphQL() as gl:
1421
assert isinstance(gl, gitlab.GraphQL)
22+
23+
24+
def test_graphql_retries_on_429_response(
25+
gl_gql: gitlab.GraphQL, respx_mock: respx.MockRouter
26+
):
27+
url = "https://gitlab.example.com/api/graphql"
28+
responses = [
29+
httpx.Response(429, headers={"retry-after": "1"}),
30+
httpx.Response(
31+
200, json={"data": {"currentUser": {"id": "gid://gitlab/User/1"}}}
32+
),
33+
]
34+
respx_mock.post(url).mock(side_effect=responses)
35+
gl_gql.execute("query {currentUser {id}}")
36+
37+
38+
def test_graphql_raises_when_max_retries_exceeded(
39+
gl_gql: gitlab.GraphQL, respx_mock: respx.MockRouter
40+
):
41+
url = "https://gitlab.example.com/api/graphql"
42+
responses = [
43+
httpx.Response(502),
44+
httpx.Response(502),
45+
httpx.Response(502),
46+
httpx.Response(502),
47+
httpx.Response(502),
48+
]
49+
respx_mock.post(url).mock(side_effect=responses)
50+
with pytest.raises(gitlab.GitlabHttpError):
51+
gl_gql.execute("query {currentUser {id}}")
52+
53+
54+
def test_graphql_raises_on_401_response(
55+
gl_gql: gitlab.GraphQL, respx_mock: respx.MockRouter
56+
):
57+
url = "https://gitlab.example.com/api/graphql"
58+
respx_mock.post(url).mock(return_value=httpx.Response(401))
59+
with pytest.raises(gitlab.GitlabAuthenticationError):
60+
gl_gql.execute("query {currentUser {id}}")

tests/unit/test_retry.py

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,24 @@
22
from unittest import mock
33

44
import pytest
5-
import requests
65

76
from gitlab import utils
8-
from gitlab._backends import requests_backend
97

108

119
def test_handle_retry_on_status_ignores_unknown_status_code():
1210
retry = utils.Retry(max_retries=1, retry_transient_errors=True)
13-
response = requests.Response()
14-
response.status_code = 418
15-
backend_response = requests_backend.RequestsResponse(response)
16-
17-
assert retry.handle_retry_on_status(backend_response) is False
11+
assert retry.handle_retry_on_status(418) is False
1812

1913

2014
def test_handle_retry_on_status_accepts_retry_after_header(
2115
monkeypatch: pytest.MonkeyPatch,
2216
):
2317
mock_sleep = mock.Mock()
2418
monkeypatch.setattr(time, "sleep", mock_sleep)
25-
2619
retry = utils.Retry(max_retries=1)
27-
response = requests.Response()
28-
response.status_code = 429
29-
response.headers["Retry-After"] = "1"
30-
backend_response = requests_backend.RequestsResponse(response)
20+
headers = {"Retry-After": "1"}
3121

32-
assert retry.handle_retry_on_status(backend_response) is True
22+
assert retry.handle_retry_on_status(429, headers=headers) is True
3323
assert isinstance(mock_sleep.call_args[0][0], int)
3424

3525

@@ -40,19 +30,12 @@ def test_handle_retry_on_status_accepts_ratelimit_reset_header(
4030
monkeypatch.setattr(time, "sleep", mock_sleep)
4131

4232
retry = utils.Retry(max_retries=1)
43-
response = requests.Response()
44-
response.status_code = 429
45-
response.headers["RateLimit-Reset"] = str(int(time.time() + 1))
46-
backend_response = requests_backend.RequestsResponse(response)
33+
headers = {"RateLimit-Reset": str(int(time.time() + 1))}
4734

48-
assert retry.handle_retry_on_status(backend_response) is True
35+
assert retry.handle_retry_on_status(429, headers=headers) is True
4936
assert isinstance(mock_sleep.call_args[0][0], float)
5037

5138

5239
def test_handle_retry_on_status_returns_false_when_max_retries_reached():
5340
retry = utils.Retry(max_retries=0)
54-
response = requests.Response()
55-
response.status_code = 429
56-
backend_response = requests_backend.RequestsResponse(response)
57-
58-
assert retry.handle_retry_on_status(backend_response) is False
41+
assert retry.handle_retry_on_status(429) is False

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy