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+ connect = 0 ,
94+ read = 0 ,
95+ other = 0 ,
96+ status = retry_config .max_retries ,
97+ backoff_factor = retry_config .backoff_factor ,
98+ backoff_max = retry_config .backoff_max ,
99+ status_forcelist = retry_config .status_forcelist ,
100+ allowed_methods = retry_config .allowed_methods ,
101+ respect_retry_after_header = True ,
102+ raise_on_status = False ,
103+ )
104+
18105
19106class GoodDataApiClient :
20107 """Provide access to metadata and afm services."""
@@ -28,6 +115,7 @@ def __init__(
28115 executions_cancellable : bool = False ,
29116 ssl_ca_cert : str | None = None ,
30117 proxy : str | None = None ,
118+ retry_config : GoodDataApiClientRetryConfig | None = None ,
31119 ) -> None :
32120 """Take url, token for connecting to GoodData.CN.
33121
@@ -44,6 +132,10 @@ def __init__(
44132 `proxy` is optional URL of an HTTP(S) proxy (e.g. ``http://proxy:8080``).
45133 When not set, the standard ``HTTPS_PROXY`` / ``https_proxy`` / ``HTTP_PROXY`` /
46134 ``http_proxy`` environment variables are checked automatically.
135+
136+ `retry_config` controls retry behaviour for transient HTTP failures
137+ (HTTP 429 by default). When omitted, sensible defaults are used and
138+ ``Retry-After`` is honoured automatically.
47139 """
48140 self ._hostname = host
49141 self ._token = token
@@ -68,7 +160,11 @@ def __init__(
68160 or None
69161 )
70162
163+ self ._retry_config = retry_config or GoodDataApiClientRetryConfig ()
164+ self ._retry = _build_urllib3_retry (self ._retry_config )
165+
71166 self ._api_config = api_client .Configuration (host = host , ssl_ca_cert = ssl_ca_cert )
167+ self ._api_config .retries = self ._retry
72168 if proxy :
73169 self ._api_config .proxy = proxy
74170 self ._api_client = api_client .ApiClient (
@@ -83,6 +179,11 @@ def __init__(
83179 self ._api_client .default_headers [header_name ] = header_value
84180 self ._api_client .user_agent = user_agent
85181
182+ self ._session = requests .Session ()
183+ adapter = HTTPAdapter (max_retries = _build_urllib3_retry (self ._retry_config ))
184+ self ._session .mount ("http://" , adapter )
185+ self ._session .mount ("https://" , adapter )
186+
86187 self ._entities_api = apis .EntitiesApi (self ._api_client )
87188 self ._layout_api = apis .LayoutApi (self ._api_client )
88189 self ._actions_api = apis .ActionsApi (self ._api_client )
@@ -110,7 +211,7 @@ def _do_post_request(
110211 if not self ._hostname .endswith ("/" ):
111212 endpoint = f"/{ endpoint } "
112213
113- response = requests .post (
214+ response = self . _session .post (
114215 url = f"{ self ._hostname } { endpoint } " ,
115216 headers = {
116217 "Content-Type" : content_type ,
0 commit comments