Skip to content

Commit 3c9497f

Browse files
committed
feat(python-sdk): make REST transport retries configurable via TenacityConfig
1 parent 3b04fae commit 3c9497f

File tree

2 files changed

+85
-92
lines changed

2 files changed

+85
-92
lines changed

sdks/python/hatchet_sdk/clients/rest/tenacity_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
R = TypeVar("R")
2222

2323
# Pattern to extract HTTP method from exception reason
24-
_METHOD_PATTERN = re.compile(r"method=(\w+)", re.IGNORECASE)
24+
_METHOD_PATTERN = re.compile(r"\bmethod=(\w+)\b", re.IGNORECASE)
2525

2626

2727
def tenacity_retry(func: Callable[P, R], config: TenacityConfig) -> Callable[P, R]:

sdks/python/tests/test_tenacity_transport_retry.py

Lines changed: 84 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
1. Default behavior: RestTransportError is NOT retried (even for GET)
55
2. Opt-in behavior: RestTransportError retried for configured methods only
66
3. Existing HTTP error retry behavior unchanged
7+
4. Method extraction from exception reason strings
78
"""
89

910
import pytest
@@ -17,81 +18,50 @@
1718
RestTransportError,
1819
ServiceException,
1920
)
20-
from hatchet_sdk.clients.rest.tenacity_utils import tenacity_should_retry
21+
from hatchet_sdk.clients.rest.tenacity_utils import (
22+
_extract_method_from_reason,
23+
tenacity_should_retry,
24+
)
2125
from hatchet_sdk.config import TenacityConfig
2226

2327
# --- Default behavior tests (transport errors NOT retried) ---
2428

2529

26-
def test_default__rest_transport_error_not_retried() -> None:
27-
"""By default, RestTransportError should NOT be retried."""
28-
exc = RestTransportError(status=0, reason="method=GET\nurl=http://test")
29-
config = TenacityConfig()
30-
assert tenacity_should_retry(exc, config) is False
31-
32-
33-
def test_default__rest_timeout_error_not_retried() -> None:
34-
"""By default, RestTimeoutError should NOT be retried."""
35-
exc = RestTimeoutError(status=0, reason="method=GET\nurl=http://test")
36-
config = TenacityConfig()
37-
assert tenacity_should_retry(exc, config) is False
38-
39-
40-
def test_default__rest_connection_error_not_retried() -> None:
41-
"""By default, RestConnectionError should NOT be retried."""
42-
exc = RestConnectionError(status=0, reason="method=GET\nurl=http://test")
43-
config = TenacityConfig()
44-
assert tenacity_should_retry(exc, config) is False
45-
46-
47-
def test_default__rest_tls_error_not_retried() -> None:
48-
"""By default, RestTLSError should NOT be retried."""
49-
exc = RestTLSError(status=0, reason="method=GET\nurl=http://test")
50-
config = TenacityConfig()
51-
assert tenacity_should_retry(exc, config) is False
52-
53-
54-
def test_default__rest_protocol_error_not_retried() -> None:
55-
"""By default, RestProtocolError should NOT be retried."""
56-
exc = RestProtocolError(status=0, reason="method=GET\nurl=http://test")
30+
@pytest.mark.parametrize(
31+
"exc_class",
32+
[RestTransportError, RestTimeoutError],
33+
ids=["base-class", "subclass"],
34+
)
35+
def test_default__transport_errors_not_retried(exc_class: type) -> None:
36+
"""By default, RestTransportError and subclasses should not be retried."""
37+
exc = exc_class(status=0, reason="method=GET\nurl=http://test")
5738
config = TenacityConfig()
5839
assert tenacity_should_retry(exc, config) is False
5940

6041

6142
# --- Opt-in behavior tests (transport errors retried for allowed methods) ---
6243

6344

64-
def test_optin__get_request_retried() -> None:
65-
"""When enabled, GET requests with transport errors should be retried."""
66-
exc = RestTimeoutError(status=0, reason="method=GET\nurl=http://test")
67-
config = TenacityConfig(retry_transport_errors=True)
68-
assert tenacity_should_retry(exc, config) is True
69-
70-
71-
def test_optin__delete_request_retried() -> None:
72-
"""When enabled, DELETE requests with transport errors should be retried."""
73-
exc = RestConnectionError(status=0, reason="method=DELETE\nurl=http://test")
45+
@pytest.mark.parametrize(
46+
"method",
47+
["GET", "DELETE"],
48+
ids=["get", "delete"],
49+
)
50+
def test_optin__idempotent_methods_retried(method: str) -> None:
51+
"""When enabled, GET and DELETE requests with transport errors should be retried."""
52+
exc = RestTimeoutError(status=0, reason=f"method={method}\nurl=http://test")
7453
config = TenacityConfig(retry_transport_errors=True)
7554
assert tenacity_should_retry(exc, config) is True
7655

7756

78-
def test_optin__post_request_not_retried() -> None:
79-
"""POST requests should NOT be retried even when transport retry is enabled."""
80-
exc = RestTimeoutError(status=0, reason="method=POST\nurl=http://test")
81-
config = TenacityConfig(retry_transport_errors=True)
82-
assert tenacity_should_retry(exc, config) is False
83-
84-
85-
def test_optin__put_request_not_retried() -> None:
86-
"""PUT requests should NOT be retried even when transport retry is enabled."""
87-
exc = RestConnectionError(status=0, reason="method=PUT\nurl=http://test")
88-
config = TenacityConfig(retry_transport_errors=True)
89-
assert tenacity_should_retry(exc, config) is False
90-
91-
92-
def test_optin__patch_request_not_retried() -> None:
93-
"""PATCH requests should NOT be retried even when transport retry is enabled."""
94-
exc = RestProtocolError(status=0, reason="method=PATCH\nurl=http://test")
57+
@pytest.mark.parametrize(
58+
"method",
59+
["POST", "PUT", "PATCH"],
60+
ids=["post", "put", "patch"],
61+
)
62+
def test_optin__non_idempotent_methods_not_retried(method: str) -> None:
63+
"""Non-idempotent requests should not be retried even when transport retry is enabled."""
64+
exc = RestTimeoutError(status=0, reason=f"method={method}\nurl=http://test")
9565
config = TenacityConfig(retry_transport_errors=True)
9666
assert tenacity_should_retry(exc, config) is False
9767

@@ -101,65 +71,88 @@ def test_optin__custom_methods_list() -> None:
10171
exc = RestTimeoutError(status=0, reason="method=POST\nurl=http://test")
10272
config = TenacityConfig(
10373
retry_transport_errors=True,
104-
retry_transport_methods=["POST"], # allow POST explicitly
74+
retry_transport_methods=["POST"],
10575
)
10676
assert tenacity_should_retry(exc, config) is True
10777

10878

109-
def test_optin__custom_methods_excludes_get() -> None:
110-
"""Custom retry_transport_methods can exclude GET."""
79+
def test_optin__custom_methods_excludes_default() -> None:
80+
"""Custom retry_transport_methods can exclude default methods like GET."""
11181
exc = RestTimeoutError(status=0, reason="method=GET\nurl=http://test")
11282
config = TenacityConfig(
11383
retry_transport_errors=True,
114-
retry_transport_methods=["DELETE"], # only DELETE, not GET
84+
retry_transport_methods=["DELETE"],
11585
)
11686
assert tenacity_should_retry(exc, config) is False
11787

11888

11989
# --- Regression tests: existing HTTP error retry behavior unchanged ---
12090

12191

122-
def test_regression__service_exception_still_retried() -> None:
123-
"""ServiceException (5xx) should still be retried."""
124-
exc = ServiceException(status=500, reason="Internal Server Error")
125-
config = TenacityConfig()
126-
assert tenacity_should_retry(exc, config) is True
127-
128-
129-
def test_regression__not_found_exception_still_retried() -> None:
130-
"""NotFoundException (404) should still be retried."""
131-
exc = NotFoundException(status=404, reason="Not Found")
92+
@pytest.mark.parametrize(
93+
("exc", "desc"),
94+
[
95+
(ServiceException(status=500, reason="Internal Server Error"), "5xx"),
96+
(NotFoundException(status=404, reason="Not Found"), "404"),
97+
],
98+
ids=["service-exception", "not-found"],
99+
)
100+
def test_regression__http_errors_still_retried(exc: Exception, desc: str) -> None:
101+
"""ServiceException (5xx) and NotFoundException (404) should still be retried."""
132102
config = TenacityConfig()
133103
assert tenacity_should_retry(exc, config) is True
134104

135105

136-
def test_regression__service_exception_retried_without_config() -> None:
106+
def test_regression__backward_compat_no_config() -> None:
137107
"""ServiceException should be retried even without config (backward compat)."""
138108
exc = ServiceException(status=500, reason="Internal Server Error")
139-
# Call without config to test backward compatibility
140109
assert tenacity_should_retry(exc) is True
141110

142111

143-
# --- Edge cases ---
144-
145-
146-
def test_edge__missing_method_in_reason_not_retried() -> None:
147-
"""If method cannot be extracted from reason, should not retry."""
148-
exc = RestTimeoutError(status=0, reason="some error without method")
149-
config = TenacityConfig(retry_transport_errors=True)
150-
assert tenacity_should_retry(exc, config) is False
112+
# --- Unit tests for _extract_method_from_reason ---
113+
114+
115+
@pytest.mark.parametrize(
116+
("reason", "expected"),
117+
[
118+
("method=GET\nurl=http://test", "GET"),
119+
("method=POST\nurl=http://test", "POST"),
120+
("method=delete\nurl=http://test", "delete"),
121+
("prefix method=PUT suffix", "PUT"),
122+
("some error without method", None),
123+
("method=\nurl=http://test", None),
124+
("", None),
125+
(None, None),
126+
],
127+
ids=[
128+
"get-uppercase",
129+
"post-uppercase",
130+
"lowercase-preserved",
131+
"embedded-in-text",
132+
"no-method-field",
133+
"empty-method-value",
134+
"empty-string",
135+
"none",
136+
],
137+
)
138+
def test_extract_method__parses_reason(
139+
reason: str | None, expected: str | None
140+
) -> None:
141+
"""_extract_method_from_reason should correctly parse HTTP method from reason."""
142+
assert _extract_method_from_reason(reason) == expected
151143

152144

153-
def test_edge__empty_reason_not_retried() -> None:
154-
"""If reason is empty, should not retry."""
155-
exc = RestConnectionError(status=0, reason="")
156-
config = TenacityConfig(retry_transport_errors=True)
157-
assert tenacity_should_retry(exc, config) is False
145+
# --- Edge cases for retry behavior ---
158146

159147

160-
def test_edge__none_reason_not_retried() -> None:
161-
"""If reason is None, should not retry."""
162-
exc = RestTLSError(status=0, reason=None)
148+
@pytest.mark.parametrize(
149+
"reason",
150+
["some error without method", "", None],
151+
ids=["no-method-field", "empty-string", "none"],
152+
)
153+
def test_edge__unparseable_reason_not_retried(reason: str | None) -> None:
154+
"""If method cannot be extracted from reason, should not retry."""
155+
exc = RestTimeoutError(status=0, reason=reason)
163156
config = TenacityConfig(retry_transport_errors=True)
164157
assert tenacity_should_retry(exc, config) is False
165158

0 commit comments

Comments
 (0)