33
44from __future__ import annotations
55
6+ import logging
67import os
8+ from dataclasses import dataclass , field
79from pathlib import Path
810
911import gooddata_api_client as api_client
1012import requests
1113from 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
1318from gooddata_sdk import __version__
1419from gooddata_sdk .utils import HttpMethod
1520
21+ logger = logging .getLogger (__name__ )
22+
1623USER_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
19102class 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 ,
0 commit comments