Skip to content

Commit a191652

Browse files
feat(gooddata-sdk): retry on HTTP 429 with Retry-After support
Adds GoodDataApiClientRetryConfig, applied to both the generated api-client (via Configuration.retries) and the direct requests.post in _do_post_request (via a Session with HTTPAdapter). Defaults: 10 retries on 429, backoff_factor=0.5, backoff_max=60s; Retry-After honoured automatically. jira: STL-2767 risk: low
1 parent e1d6ad4 commit a191652

3 files changed

Lines changed: 154 additions & 3 deletions

File tree

packages/gooddata-sdk/src/gooddata_sdk/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@
280280
CatalogUserDataFilterRelationships,
281281
)
282282
from gooddata_sdk.catalog.workspace.entity_model.workspace import CatalogWorkspace
283-
from gooddata_sdk.client import GoodDataApiClient
283+
from gooddata_sdk.client import GoodDataApiClient, GoodDataApiClientRetryConfig
284284
from gooddata_sdk.compute.compute_to_sdk_converter import ComputeToSdkConverter
285285
from gooddata_sdk.compute.model.attribute import Attribute
286286
from gooddata_sdk.compute.model.base import ExecModelEntity, ObjId

packages/gooddata-sdk/src/gooddata_sdk/client.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,101 @@
33

44
from __future__ import annotations
55

6+
import logging
67
import os
8+
from dataclasses import dataclass, field
79
from pathlib import Path
810

911
import gooddata_api_client as api_client
1012
import requests
1113
from gooddata_api_client import apis
14+
from requests.adapters import HTTPAdapter
15+
from urllib3.exceptions import MaxRetryError
16+
from urllib3.util.retry import Retry
1217

1318
from gooddata_sdk import __version__
1419
from gooddata_sdk.utils import HttpMethod
1520

21+
logger = logging.getLogger(__name__)
22+
1623
USER_AGENT = f"gooddata-python-sdk/{__version__}"
1724

25+
DEFAULT_RETRY_ALLOWED_METHODS: frozenset[str] = frozenset(
26+
["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE", "POST", "PATCH"]
27+
)
28+
29+
30+
@dataclass(frozen=True)
31+
class GoodDataApiClientRetryConfig:
32+
"""Retry policy for transient HTTP failures.
33+
34+
The same policy is applied to both transport paths:
35+
- the generated `gooddata-api-client` (via `urllib3` `Retry`)
36+
- the direct `requests`-based POST in `GoodDataApiClient._do_post_request`
37+
(via `HTTPAdapter` mounted on a `Session`)
38+
39+
`Retry-After` from the server is honoured automatically; `backoff_factor`
40+
only applies when that header is absent.
41+
"""
42+
43+
max_retries: int = 10
44+
backoff_factor: float = 0.5
45+
backoff_max: float = 60.0
46+
status_forcelist: tuple[int, ...] = (429,)
47+
allowed_methods: frozenset[str] = field(default_factory=lambda: DEFAULT_RETRY_ALLOWED_METHODS)
48+
49+
50+
class _LoggingRetry(Retry):
51+
"""Retry that logs each rate-limit hit and final exhaustion.
52+
53+
Logs at WARNING when a configured status (HTTP 429 by default) is
54+
received and a retry is scheduled, and at ERROR when retries are
55+
exhausted. Other retry causes (connection errors, redirects, etc.)
56+
are left to urllib3's own logging.
57+
"""
58+
59+
def increment( # type: ignore[override]
60+
self,
61+
method=None,
62+
url=None,
63+
response=None,
64+
error=None,
65+
_pool=None,
66+
_stacktrace=None,
67+
):
68+
if response is not None and response.status in self.status_forcelist:
69+
logger.warning(
70+
"GoodData API rate-limited: %s %s -> %s; Retry-After=%s; retries left=%s",
71+
method,
72+
url,
73+
response.status,
74+
response.headers.get("Retry-After"),
75+
self.total,
76+
)
77+
try:
78+
return super().increment(method, url, response, error, _pool, _stacktrace)
79+
except MaxRetryError:
80+
logger.error(
81+
"GoodData API rate-limit retries exhausted: %s %s -> %s",
82+
method,
83+
url,
84+
response.status,
85+
)
86+
raise
87+
return super().increment(method, url, response, error, _pool, _stacktrace)
88+
89+
90+
def _build_urllib3_retry(retry_config: GoodDataApiClientRetryConfig) -> Retry:
91+
return _LoggingRetry(
92+
total=retry_config.max_retries,
93+
backoff_factor=retry_config.backoff_factor,
94+
backoff_max=retry_config.backoff_max,
95+
status_forcelist=retry_config.status_forcelist,
96+
allowed_methods=retry_config.allowed_methods,
97+
respect_retry_after_header=True,
98+
raise_on_status=False,
99+
)
100+
18101

19102
class GoodDataApiClient:
20103
"""Provide access to metadata and afm services."""
@@ -28,6 +111,7 @@ def __init__(
28111
executions_cancellable: bool = False,
29112
ssl_ca_cert: str | None = None,
30113
proxy: str | None = None,
114+
retry_config: GoodDataApiClientRetryConfig | None = None,
31115
) -> None:
32116
"""Take url, token for connecting to GoodData.CN.
33117
@@ -44,6 +128,10 @@ def __init__(
44128
`proxy` is optional URL of an HTTP(S) proxy (e.g. ``http://proxy:8080``).
45129
When not set, the standard ``HTTPS_PROXY`` / ``https_proxy`` / ``HTTP_PROXY`` /
46130
``http_proxy`` environment variables are checked automatically.
131+
132+
`retry_config` controls retry behaviour for transient HTTP failures
133+
(HTTP 429 by default). When omitted, sensible defaults are used and
134+
``Retry-After`` is honoured automatically.
47135
"""
48136
self._hostname = host
49137
self._token = token
@@ -68,7 +156,11 @@ def __init__(
68156
or None
69157
)
70158

159+
self._retry_config = retry_config or GoodDataApiClientRetryConfig()
160+
self._retry = _build_urllib3_retry(self._retry_config)
161+
71162
self._api_config = api_client.Configuration(host=host, ssl_ca_cert=ssl_ca_cert)
163+
self._api_config.retries = self._retry
72164
if proxy:
73165
self._api_config.proxy = proxy
74166
self._api_client = api_client.ApiClient(
@@ -83,6 +175,11 @@ def __init__(
83175
self._api_client.default_headers[header_name] = header_value
84176
self._api_client.user_agent = user_agent
85177

178+
self._session = requests.Session()
179+
adapter = HTTPAdapter(max_retries=_build_urllib3_retry(self._retry_config))
180+
self._session.mount("http://", adapter)
181+
self._session.mount("https://", adapter)
182+
86183
self._entities_api = apis.EntitiesApi(self._api_client)
87184
self._layout_api = apis.LayoutApi(self._api_client)
88185
self._actions_api = apis.ActionsApi(self._api_client)
@@ -110,7 +207,7 @@ def _do_post_request(
110207
if not self._hostname.endswith("/"):
111208
endpoint = f"/{endpoint}"
112209

113-
response = requests.post(
210+
response = self._session.post(
114211
url=f"{self._hostname}{endpoint}",
115212
headers={
116213
"Content-Type": content_type,

packages/gooddata-sdk/tests/test_client.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import os
33
from unittest import mock
44

5-
from gooddata_sdk import GoodDataApiClient, GoodDataSdk
5+
from gooddata_sdk import GoodDataApiClient, GoodDataApiClientRetryConfig, GoodDataSdk
6+
from urllib3.util.retry import Retry
67

78

89
def test_http_headers_precedence():
@@ -74,3 +75,56 @@ def test_sdk_create_picks_up_env_proxy(self):
7475
def test_sdk_create_no_proxy_when_env_empty(self):
7576
sdk = GoodDataSdk.create("https://example.com", "token")
7677
assert sdk.client._api_config.proxy is None
78+
79+
80+
class TestGoodDataApiClientRetryConfig:
81+
"""Retry/back-off config propagates to both transport paths."""
82+
83+
def test_defaults_match_public_rate_limit_contract(self):
84+
c = GoodDataApiClient("https://example.com", "token")
85+
retry = c._api_config.retries
86+
assert isinstance(retry, Retry)
87+
assert retry.total == 10
88+
assert retry.backoff_factor == 0.5
89+
assert 429 in retry.status_forcelist
90+
assert retry.respect_retry_after_header is True
91+
for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
92+
assert method in retry.allowed_methods
93+
94+
def test_custom_retry_config_overrides_defaults(self):
95+
cfg = GoodDataApiClientRetryConfig(
96+
max_retries=3,
97+
backoff_factor=2.0,
98+
status_forcelist=(429, 503),
99+
allowed_methods=frozenset(["GET", "HEAD"]),
100+
)
101+
c = GoodDataApiClient("https://example.com", "token", retry_config=cfg)
102+
retry = c._api_config.retries
103+
assert retry.total == 3
104+
assert retry.backoff_factor == 2.0
105+
assert retry.status_forcelist == (429, 503)
106+
assert retry.allowed_methods == frozenset(["GET", "HEAD"])
107+
108+
def test_session_adapter_uses_same_retry_policy(self):
109+
c = GoodDataApiClient("https://example.com", "token")
110+
for scheme in ("http://", "https://"):
111+
adapter = c._session.get_adapter(scheme + "example.com")
112+
assert adapter.max_retries.total == c._retry_config.max_retries
113+
assert adapter.max_retries.status_forcelist == c._retry_config.status_forcelist
114+
115+
def test_sdk_wraps_client_with_custom_retry_config(self):
116+
cfg = GoodDataApiClientRetryConfig(max_retries=2)
117+
client = GoodDataApiClient("https://example.com", "token", retry_config=cfg)
118+
sdk = GoodDataSdk(client)
119+
assert sdk.client._api_config.retries.total == 2
120+
121+
def test_rate_limit_hit_logs_warning(self):
122+
c = GoodDataApiClient("https://example.com", "token")
123+
retry = c._api_config.retries
124+
fake_response = mock.Mock(status=429, headers={"Retry-After": "2"})
125+
with mock.patch("gooddata_sdk.client.logger") as mock_logger:
126+
retry.increment(method="POST", url="/api/v1/entities/users", response=fake_response)
127+
assert mock_logger.warning.called
128+
fmt = mock_logger.warning.call_args.args[0]
129+
assert "rate-limited" in fmt
130+
assert mock_logger.error.called is False

0 commit comments

Comments
 (0)