Skip to content

Commit f63ac8b

Browse files
jooolaapricote
andauthored
feat: implement retry policy (#430)
Implement a retry handler to retry requests based on an internal retry policy. The retry policy will evolve over time to cover more use cases, but for now we want to start simple, and maybe someday allow users to tweak the retry policy. Related to hetznercloud/hcloud-go#470 BEGIN_COMMIT_OVERRIDE feat: retry requests when the api gateway errors (#430) feat: retry requests when the network timed outs (#430) feat: retry requests when the rate limit was reached (#430) feat: retry requests when the api returns a conflict error (#430) END_COMMIT_OVERRIDE --------- Co-authored-by: Julian Tölle <[email protected]>
1 parent c32a615 commit f63ac8b

File tree

2 files changed

+130
-46
lines changed

2 files changed

+130
-46
lines changed

hcloud/_client.py

Lines changed: 88 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import time
4+
from http import HTTPStatus
45
from random import uniform
56
from typing import Protocol
67

@@ -78,7 +79,33 @@ def func(retries: int) -> float:
7879

7980

8081
class Client:
81-
"""Base Client for accessing the Hetzner Cloud API"""
82+
"""
83+
Client for the Hetzner Cloud API.
84+
85+
The Hetzner Cloud API reference is available at https://docs.hetzner.cloud.
86+
87+
**Retry mechanism**
88+
89+
The :attr:`Client.request` method will retry failed requests that match certain criteria. The
90+
default retry interval is defined by an exponential backoff algorithm truncated to 60s
91+
with jitter. The default maximal number of retries is 5.
92+
93+
The following rules define when a request can be retried:
94+
95+
- When the client returned a network timeout error.
96+
- When the API returned an HTTP error, with the status code:
97+
98+
- ``502`` Bad Gateway
99+
- ``504`` Gateway Timeout
100+
101+
- When the API returned an application error, with the code:
102+
103+
- ``conflict``
104+
- ``rate_limit_exceeded``
105+
106+
Changes to the retry policy might occur between releases, and will not be considered
107+
breaking changes.
108+
"""
82109

83110
_version = __version__
84111
__user_agent_prefix = "hcloud-python"
@@ -256,50 +283,71 @@ def request( # type: ignore[no-untyped-def]
256283

257284
retries = 0
258285
while True:
259-
response = self._requests_session.request(
260-
method=method,
261-
url=url,
262-
headers=headers,
263-
**kwargs,
264-
)
265-
266-
correlation_id = response.headers.get("X-Correlation-Id")
267-
payload = {}
268286
try:
269-
if len(response.content) > 0:
270-
payload = response.json()
271-
except (TypeError, ValueError) as exc:
272-
raise APIException(
273-
code=response.status_code,
274-
message=response.reason,
275-
details={"content": response.content},
276-
correlation_id=correlation_id,
277-
) from exc
278-
279-
if not response.ok:
280-
if not payload or "error" not in payload:
281-
raise APIException(
282-
code=response.status_code,
283-
message=response.reason,
284-
details={"content": response.content},
285-
correlation_id=correlation_id,
286-
)
287-
288-
error: dict = payload["error"]
289-
290-
if (
291-
error["code"] == "rate_limit_exceeded"
292-
and retries < self._retry_max_retries
293-
):
287+
response = self._requests_session.request(
288+
method=method,
289+
url=url,
290+
headers=headers,
291+
**kwargs,
292+
)
293+
return self._read_response(response)
294+
except APIException as exception:
295+
if retries < self._retry_max_retries and self._retry_policy(exception):
294296
time.sleep(self._retry_interval(retries))
295297
retries += 1
296298
continue
297-
299+
raise
300+
except requests.exceptions.Timeout:
301+
if retries < self._retry_max_retries:
302+
time.sleep(self._retry_interval(retries))
303+
retries += 1
304+
continue
305+
raise
306+
307+
def _read_response(self, response: requests.Response) -> dict:
308+
correlation_id = response.headers.get("X-Correlation-Id")
309+
payload = {}
310+
try:
311+
if len(response.content) > 0:
312+
payload = response.json()
313+
except (TypeError, ValueError) as exc:
314+
raise APIException(
315+
code=response.status_code,
316+
message=response.reason,
317+
details={"content": response.content},
318+
correlation_id=correlation_id,
319+
) from exc
320+
321+
if not response.ok:
322+
if not payload or "error" not in payload:
298323
raise APIException(
299-
code=error["code"],
300-
message=error["message"],
301-
details=error.get("details"),
324+
code=response.status_code,
325+
message=response.reason,
326+
details={"content": response.content},
302327
correlation_id=correlation_id,
303328
)
304329

305-
return payload
330+
error: dict = payload["error"]
331+
raise APIException(
332+
code=error["code"],
333+
message=error["message"],
334+
details=error.get("details"),
335+
correlation_id=correlation_id,
336+
)
337+
338+
return payload
339+
340+
def _retry_policy(self, exception: APIException) -> bool:
341+
if isinstance(exception.code, str):
342+
return exception.code in (
343+
"rate_limit_exceeded",
344+
"conflict",
345+
)
346+
347+
if isinstance(exception.code, int):
348+
return exception.code in (
349+
HTTPStatus.BAD_GATEWAY,
350+
HTTPStatus.GATEWAY_TIMEOUT,
351+
)
352+
353+
return False

tests/unit/test_client.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,12 @@ def test_request_fails(self, client, fail_response):
109109

110110
def test_request_fails_correlation_id(self, client, response):
111111
response.headers["X-Correlation-Id"] = "67ed842dc8bc8673"
112-
response.status_code = 409
112+
response.status_code = 422
113113
response._content = json.dumps(
114114
{
115115
"error": {
116-
"code": "conflict",
117-
"message": "some conflict",
116+
"code": "service_error",
117+
"message": "Something crashed",
118118
}
119119
}
120120
).encode("utf-8")
@@ -125,11 +125,11 @@ def test_request_fails_correlation_id(self, client, response):
125125
"POST", "http://url.com", params={"argument": "value"}, timeout=2
126126
)
127127
error = exception_info.value
128-
assert error.code == "conflict"
129-
assert error.message == "some conflict"
128+
assert error.code == "service_error"
129+
assert error.message == "Something crashed"
130130
assert error.details is None
131131
assert error.correlation_id == "67ed842dc8bc8673"
132-
assert str(error) == "some conflict (conflict, 67ed842dc8bc8673)"
132+
assert str(error) == "Something crashed (service_error, 67ed842dc8bc8673)"
133133

134134
def test_request_500(self, client, fail_response):
135135
fail_response.status_code = 500
@@ -208,6 +208,42 @@ def test_request_limit_then_success(self, client, rate_limit_response):
208208
)
209209
assert client._requests_session.request.call_count == 2
210210

211+
@pytest.mark.parametrize(
212+
("exception", "expected"),
213+
[
214+
(
215+
APIException(code="rate_limit_exceeded", message="Error", details=None),
216+
True,
217+
),
218+
(
219+
APIException(code="conflict", message="Error", details=None),
220+
True,
221+
),
222+
(
223+
APIException(code=409, message="Conflict", details=None),
224+
False,
225+
),
226+
(
227+
APIException(code=429, message="Too Many Requests", details=None),
228+
False,
229+
),
230+
(
231+
APIException(code=502, message="Bad Gateway", details=None),
232+
True,
233+
),
234+
(
235+
APIException(code=503, message="Service Unavailable", details=None),
236+
False,
237+
),
238+
(
239+
APIException(code=504, message="Gateway Timeout", details=None),
240+
True,
241+
),
242+
],
243+
)
244+
def test_retry_policy(self, client, exception, expected):
245+
assert client._retry_policy(exception) == expected
246+
211247

212248
def test_constant_backoff_function():
213249
backoff = constant_backoff_function(interval=1.0)

0 commit comments

Comments
 (0)