Skip to content

Commit 89cf0ba

Browse files
committed
Merge branch 'issue441-retry-after-429'
2 parents 63306de + 7271870 commit 89cf0ba

File tree

11 files changed

+468
-14
lines changed

11 files changed

+468
-14
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- `openeo.testing.io.TestDataLoader`: unit test utility to compactly load (and optionally preprocess) tests data (text/JSON/...)
13+
- `openeo.Connection`: automatically retry API requests on `429 Too Many Requests` HTTP errors, with appropriate delay if possible ([#441](https://github.com/Open-EO/openeo-python-client/issues/441))
1314

1415
### Changed
1516

docs/conf.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
'sphinx.ext.viewcode',
4242
'sphinx.ext.doctest',
4343
'myst_parser',
44+
"sphinx.ext.intersphinx",
4445
]
4546

4647
import sphinx_autodoc_typehints
@@ -194,3 +195,13 @@
194195
author, 'openeo', 'One line description of project.',
195196
'Miscellaneous'),
196197
]
198+
199+
200+
# Mapping for external documentation
201+
intersphinx_mapping = {
202+
"python": ("https://docs.python.org/3", None),
203+
"numpy": ("https://numpy.org/doc/stable/", None),
204+
"xarray": ("https://docs.xarray.dev/en/stable/", None),
205+
"pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None),
206+
"urllib3": ("https://urllib3.readthedocs.io/en/stable/", None),
207+
}

openeo/extra/job_management/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
import shapely.errors
3030
import shapely.geometry.base
3131
import shapely.wkt
32-
from requests.adapters import HTTPAdapter, Retry
32+
from requests.adapters import HTTPAdapter
33+
from urllib3.util import Retry
3334

3435
from openeo import BatchJob, Connection
3536
from openeo.internal.processes.parse import (
@@ -284,7 +285,7 @@ def _make_resilient(connection):
284285
503 Service Unavailable
285286
504 Gateway Timeout
286287
"""
287-
# TODO: refactor this helper out of this class and unify with `openeo_driver.util.http.requests_with_retry`
288+
# TODO: migrate this to now built-in retry configuration of `Connection` or `openeo.util.http.retry_adapter`?
288289
status_forcelist = [500, 502, 503, 504]
289290
retries = Retry(
290291
total=MAX_RETRIES,

openeo/rest/_connection.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
from typing import Iterable, Optional, Union
66

77
import requests
8+
import urllib3.util
89
from requests import Response
910
from requests.auth import AuthBase
1011

1112
import openeo
1213
from openeo.rest import OpenEoApiError, OpenEoApiPlainError, OpenEoRestError
1314
from openeo.rest.auth.auth import NullAuth
1415
from openeo.util import ContextTimer, ensure_list, str_truncate, url_join
16+
from openeo.utils.http import HTTP_502_BAD_GATEWAY, session_with_retries
1517

1618
_log = logging.getLogger(__name__)
1719

@@ -26,15 +28,22 @@ class RestApiConnection:
2628
def __init__(
2729
self,
2830
root_url: str,
31+
*,
2932
auth: Optional[AuthBase] = None,
3033
session: Optional[requests.Session] = None,
3134
default_timeout: Optional[int] = None,
3235
slow_response_threshold: Optional[float] = None,
36+
retry: Union[urllib3.util.Retry, dict, bool, None] = None,
3337
):
3438
self._root_url = root_url
3539
self._auth = None
3640
self.auth = auth or NullAuth()
37-
self.session = session or requests.Session()
41+
if session:
42+
self.session = session
43+
elif retry is not False:
44+
self.session = session_with_retries(retry=retry)
45+
else:
46+
self.session = requests.Session()
3847
self.default_timeout = default_timeout or DEFAULT_TIMEOUT
3948
self.default_headers = {
4049
"User-Agent": "openeo-python-client/{cv} {py}/{pv} {pl}".format(
@@ -165,7 +174,7 @@ def _raise_api_error(self, response: requests.Response):
165174
_log.warning(f"Failed to parse API error response: [{status_code}] {text!r} (headers: {response.headers})")
166175

167176
# TODO: eliminate this VITO-backend specific error massaging?
168-
if status_code == 502 and "Proxy Error" in text:
177+
if status_code == HTTP_502_BAD_GATEWAY and "Proxy Error" in text:
169178
error_message = (
170179
"Received 502 Proxy Error."
171180
" This typically happens when a synchronous openEO processing request takes too long and is aborted."

openeo/rest/_testing.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from openeo import Connection, DataCube
1919
from openeo.rest.vectorcube import VectorCube
20+
from openeo.utils.http import HTTP_201_CREATED, HTTP_202_ACCEPTED, HTTP_204_NO_CONTENT
2021

2122
OPENEO_BACKEND = "https://openeo.test/"
2223

@@ -209,7 +210,7 @@ def _handle_post_jobs(self, request, context):
209210
for field in self.extra_job_metadata_fields:
210211
job_data[field] = post_data.get(field)
211212
self.batch_jobs[job_id] = job_data
212-
context.status_code = 201
213+
context.status_code = HTTP_201_CREATED
213214
context.headers["openeo-identifier"] = job_id
214215

215216
def _get_job_id(self, request) -> str:
@@ -232,7 +233,7 @@ def _handle_post_job_results(self, request, context):
232233
self.batch_jobs[job_id]["status"] = self._get_job_status(
233234
job_id=job_id, current_status=self.batch_jobs[job_id]["status"]
234235
)
235-
context.status_code = 202
236+
context.status_code = HTTP_202_ACCEPTED
236237

237238
def _handle_get_job(self, request, context):
238239
"""Handler of `GET /job/{job_id}` (get batch job status and metadata)."""
@@ -270,7 +271,7 @@ def _handle_delete_job_results(self, request, context):
270271
job_id = self._get_job_id(request)
271272
self.batch_jobs[job_id]["status"] = "canceled"
272273
self._forced_job_status[job_id] = "canceled"
273-
context.status_code = 204
274+
context.status_code = HTTP_204_NO_CONTENT
274275

275276
def _handle_get_job_result_asset(self, request, context):
276277
"""Handler of `GET /job/{job_id}/results/result.data` (get batch job result asset)."""

openeo/rest/connection.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
import requests
3131
import shapely.geometry.base
32+
import urllib3.util
3233
from requests.auth import AuthBase, HTTPBasicAuth
3334

3435
import openeo
@@ -85,6 +86,11 @@
8586
load_json_resource,
8687
rfc3339,
8788
)
89+
from openeo.utils.http import (
90+
HTTP_201_CREATED,
91+
HTTP_401_UNAUTHORIZED,
92+
HTTP_403_FORBIDDEN,
93+
)
8894
from openeo.utils.version import ComparableVersion
8995

9096
__all__ = ["Connection", "connect"]
@@ -114,6 +120,16 @@ class Connection(RestApiConnection):
114120
optional :class:`OidcAuthenticator` object to use for renewing OIDC tokens.
115121
:param auth: Optional ``requests.auth.AuthBase`` object to use for requests.
116122
Usage of this parameter is deprecated, use the specific authentication methods instead.
123+
:param retry: general request retry settings, can be specified as:
124+
125+
- :py:class:`urllib3.util.Retry` object
126+
or a dictionary with corresponding keyword arguments
127+
(e.g. ``total``, ``backoff_factor``, ``status_forcelist``, ...)
128+
- ``None`` (default) to use default openEO-oriented retry settings
129+
- ``False`` to disable retrying requests
130+
131+
.. versionchanged:: 0.41.0
132+
Added ``retry`` argument.
117133
"""
118134

119135
_MINIMUM_API_VERSION = ComparableVersion("1.0.0")
@@ -130,6 +146,7 @@ def __init__(
130146
refresh_token_store: Optional[RefreshTokenStore] = None,
131147
oidc_auth_renewer: Optional[OidcAuthenticator] = None,
132148
auth: Optional[AuthBase] = None,
149+
retry: Union[urllib3.util.Retry, dict, bool, None] = None,
133150
):
134151
if "://" not in url:
135152
url = "https://" + url
@@ -139,6 +156,7 @@ def __init__(
139156
root_url=self.version_discovery(url, session=session, timeout=default_timeout),
140157
auth=auth, session=session, default_timeout=default_timeout,
141158
slow_response_threshold=slow_response_threshold,
159+
retry=retry,
142160
)
143161

144162
# Initial API version check.
@@ -663,7 +681,10 @@ def _request():
663681
# Initial request attempt
664682
return _request()
665683
except OpenEoApiError as api_exc:
666-
if api_exc.http_status_code in {401, 403} and api_exc.code == "TokenInvalid":
684+
if (
685+
api_exc.http_status_code in {HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN}
686+
and api_exc.code == "TokenInvalid"
687+
):
667688
# Auth token expired: can we refresh?
668689
if isinstance(self.auth, OidcBearerAuth) and self._oidc_auth_renewer:
669690
msg = f"OIDC access token expired ({api_exc.http_status_code} {api_exc.code})."
@@ -1750,7 +1771,7 @@ def create_job(
17501771
)
17511772

17521773
self._preflight_validation(pg_with_metadata=pg_with_metadata, validate=validate)
1753-
response = self.post("/jobs", json=pg_with_metadata, expected_status=201)
1774+
response = self.post("/jobs", json=pg_with_metadata, expected_status=HTTP_201_CREATED)
17541775

17551776
job_id = None
17561777
if "openeo-identifier" in response.headers:
@@ -1885,6 +1906,7 @@ def connect(
18851906
session: Optional[requests.Session] = None,
18861907
default_timeout: Optional[int] = None,
18871908
auto_validate: bool = True,
1909+
retry: Union[urllib3.util.Retry, dict, bool, None] = None,
18881910
) -> Connection:
18891911
"""
18901912
This method is the entry point to OpenEO.
@@ -1904,9 +1926,19 @@ def connect(
19041926
:param auth_options: Options/arguments specific to the authentication type
19051927
:param default_timeout: default timeout (in seconds) for requests
19061928
:param auto_validate: toggle to automatically validate process graphs before execution
1929+
:param retry: general request retry settings, can be specified as:
19071930
1908-
.. versionadded:: 0.24.0
1909-
added ``auto_validate`` argument
1931+
- :py:class:`urllib3.util.Retry` object
1932+
or a dictionary with corresponding keyword arguments
1933+
(e.g. ``total``, ``backoff_factor``, ``status_forcelist``, ...)
1934+
- ``None`` (default) to use default openEO-oriented retry settings
1935+
- ``False`` to disable retrying requests
1936+
1937+
.. versionchanged:: 0.24.0
1938+
Added ``auto_validate`` argument
1939+
1940+
.. versionchanged:: 0.41.0
1941+
Added ``retry`` argument.
19101942
"""
19111943

19121944
def _config_log(message):
@@ -1931,7 +1963,13 @@ def _config_log(message):
19311963

19321964
if not url:
19331965
raise OpenEoClientException("No openEO back-end URL given or known to connect to.")
1934-
connection = Connection(url, session=session, default_timeout=default_timeout, auto_validate=auto_validate)
1966+
connection = Connection(
1967+
url,
1968+
session=session,
1969+
default_timeout=default_timeout,
1970+
auto_validate=auto_validate,
1971+
retry=retry,
1972+
)
19351973

19361974
auth_type = auth_type.lower() if isinstance(auth_type, str) else auth_type
19371975
if auth_type in {None, False, 'null', 'none'}:

openeo/rest/job.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
from openeo.rest.models.general import LogsResponse
2828
from openeo.rest.models.logs import log_level_name
2929
from openeo.util import ensure_dir
30+
from openeo.utils.http import (
31+
HTTP_408_REQUEST_TIMEOUT,
32+
HTTP_429_TOO_MANY_REQUESTS,
33+
HTTP_500_INTERNAL_SERVER_ERROR,
34+
HTTP_501_NOT_IMPLEMENTED,
35+
HTTP_502_BAD_GATEWAY,
36+
HTTP_503_SERVICE_UNAVAILABLE,
37+
HTTP_504_GATEWAY_TIMEOUT,
38+
)
3039

3140
if typing.TYPE_CHECKING:
3241
# Imports for type checking only (circular import issue at runtime).
@@ -37,7 +46,16 @@
3746

3847
DEFAULT_JOB_RESULTS_FILENAME = "job-results.json"
3948
MAX_RETRIES_PER_RANGE = 3
40-
RETRIABLE_STATUSCODES = [408, 429, 500, 501, 502, 503, 504]
49+
RETRIABLE_STATUSCODES = [
50+
HTTP_408_REQUEST_TIMEOUT,
51+
HTTP_429_TOO_MANY_REQUESTS,
52+
HTTP_500_INTERNAL_SERVER_ERROR,
53+
HTTP_501_NOT_IMPLEMENTED,
54+
HTTP_502_BAD_GATEWAY,
55+
HTTP_503_SERVICE_UNAVAILABLE,
56+
HTTP_504_GATEWAY_TIMEOUT,
57+
]
58+
4159

4260
class BatchJob:
4361
"""
@@ -313,7 +331,7 @@ def soft_error(message: str):
313331
soft_error("Connection error while polling job status: {e}".format(e=e))
314332
continue
315333
except OpenEoApiPlainError as e:
316-
if e.http_status_code in [502, 503]:
334+
if e.http_status_code in [HTTP_502_BAD_GATEWAY, HTTP_503_SERVICE_UNAVAILABLE]:
317335
soft_error("Service availability error while polling job status: {e}".format(e=e))
318336
continue
319337
else:

0 commit comments

Comments
 (0)