Skip to content

Commit b97343a

Browse files
committed
PR #770 only auto-retry on 429 for now (#441, #764)
also automatically retrying on 5xx codes might interfere too much with soft-error handling features of existing poll loops also introduce HTTP status constants for more self-descriptive code
1 parent ea9f6ee commit b97343a

File tree

7 files changed

+96
-32
lines changed

7 files changed

+96
-32
lines changed

openeo/rest/_connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from openeo.rest import OpenEoApiError, OpenEoApiPlainError, OpenEoRestError
1414
from openeo.rest.auth.auth import NullAuth
1515
from openeo.util import ContextTimer, ensure_list, str_truncate, url_join
16-
from openeo.utils.http import session_with_retries
16+
from openeo.utils.http import HTTP_502_BAD_GATEWAY, session_with_retries
1717

1818
_log = logging.getLogger(__name__)
1919

@@ -174,7 +174,7 @@ def _raise_api_error(self, response: requests.Response):
174174
_log.warning(f"Failed to parse API error response: [{status_code}] {text!r} (headers: {response.headers})")
175175

176176
# TODO: eliminate this VITO-backend specific error massaging?
177-
if status_code == 502 and "Proxy Error" in text:
177+
if status_code == HTTP_502_BAD_GATEWAY and "Proxy Error" in text:
178178
error_message = (
179179
"Received 502 Proxy Error."
180180
" 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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@
8686
load_json_resource,
8787
rfc3339,
8888
)
89+
from openeo.utils.http import (
90+
HTTP_201_CREATED,
91+
HTTP_401_UNAUTHORIZED,
92+
HTTP_403_FORBIDDEN,
93+
)
8994
from openeo.utils.version import ComparableVersion
9095

9196
__all__ = ["Connection", "connect"]
@@ -676,7 +681,10 @@ def _request():
676681
# Initial request attempt
677682
return _request()
678683
except OpenEoApiError as api_exc:
679-
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+
):
680688
# Auth token expired: can we refresh?
681689
if isinstance(self.auth, OidcBearerAuth) and self._oidc_auth_renewer:
682690
msg = f"OIDC access token expired ({api_exc.http_status_code} {api_exc.code})."
@@ -1763,7 +1771,7 @@ def create_job(
17631771
)
17641772

17651773
self._preflight_validation(pg_with_metadata=pg_with_metadata, validate=validate)
1766-
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)
17671775

17681776
job_id = None
17691777
if "openeo-identifier" in response.headers:

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:

openeo/utils/http.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,29 @@
88
import requests.adapters
99
from urllib3.util import Retry
1010

11-
DEFAULT_RETRIES_TOTAL = 5
11+
# Commonly used subset of HTTP response status codes
12+
HTTP_100_CONTINUE = 100
13+
HTTP_200_OK = 200
14+
HTTP_201_CREATED = 201
15+
HTTP_202_ACCEPTED = 202
16+
HTTP_204_NO_CONTENT = 204
17+
HTTP_301_MOVED_PERMANENTLY = 301
18+
HTTP_302_FOUND = 302
19+
HTTP_400_BAD_REQUEST = 400
20+
HTTP_401_UNAUTHORIZED = 401
21+
HTTP_402_PAYMENT_REQUIRED = 402
22+
HTTP_403_FORBIDDEN = 403
23+
HTTP_404_NOT_FOUND = 404
24+
HTTP_408_REQUEST_TIMEOUT = 408
25+
HTTP_429_TOO_MANY_REQUESTS = 429
26+
HTTP_500_INTERNAL_SERVER_ERROR = 500
27+
HTTP_501_NOT_IMPLEMENTED = 501
28+
HTTP_502_BAD_GATEWAY = 502
29+
HTTP_503_SERVICE_UNAVAILABLE = 503
30+
HTTP_504_GATEWAY_TIMEOUT = 504
31+
32+
33+
DEFAULT_RETRIES_TOTAL = 3
1234

1335
# On `backoff_factor`: it influences how much to sleep according to the formula:
1436
# sleep = {backoff factor} * (2 ** ({consecutive errors - 1}))
@@ -20,10 +42,7 @@
2042

2143
DEFAULT_RETRY_FORCELIST = frozenset(
2244
[
23-
429, # Too Many Requests
24-
502, # Bad Gateway
25-
503, # Service Unavailable
26-
504, # Gateway Timeout
45+
HTTP_429_TOO_MANY_REQUESTS,
2746
]
2847
)
2948

tests/rest/test_job.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
from openeo.rest.job import BatchJob, ResultAsset
1919
from openeo.rest.models.general import Link
2020
from openeo.rest.models.logs import LogEntry
21+
from openeo.utils.http import (
22+
HTTP_402_PAYMENT_REQUIRED,
23+
HTTP_429_TOO_MANY_REQUESTS,
24+
HTTP_500_INTERNAL_SERVER_ERROR,
25+
HTTP_502_BAD_GATEWAY,
26+
HTTP_503_SERVICE_UNAVAILABLE,
27+
)
2128

2229
API_URL = "https://oeo.test"
2330

@@ -320,28 +327,35 @@ def test_execute_batch_with_excessive_soft_errors(con100, requests_mock, tmpdir,
320327
[
321328
( # Default retry settings
322329
None,
323-
[
324-
httpretty.Response(status=502, body="Bad Gateway"),
325-
httpretty.Response(status=504, body="Service Unavailable"),
326-
],
330+
[],
327331
contextlib.nullcontext(),
328-
[0.1, 23, 34],
332+
[23, 34],
329333
),
330-
(
331-
# Only retry on 429 (and fail on 500)
332-
{"status_forcelist": [429]},
333-
[
334-
httpretty.Response(status=500, body="Internal Server Error"),
335-
],
334+
( # Default config with a generic 500 error
335+
None,
336+
[httpretty.Response(status=HTTP_500_INTERNAL_SERVER_ERROR, body="Internal Server Error")],
336337
pytest.raises(OpenEoApiPlainError, match=re.escape("[500] Internal Server Error")),
337-
[0.1, 23],
338+
[23],
339+
),
340+
( # Default config with a 503 error (skipped by soft error feature of execute_batch poll loop)
341+
None,
342+
[httpretty.Response(status=HTTP_503_SERVICE_UNAVAILABLE, body="Service Unavailable")],
343+
contextlib.nullcontext(),
344+
[23, 12.34, 34],
338345
),
339346
(
340-
# No retry setup
347+
# Explicit status_forcelist with custom status code to retry
348+
{"status_forcelist": [HTTP_429_TOO_MANY_REQUESTS, HTTP_402_PAYMENT_REQUIRED]},
349+
[httpretty.Response(status=HTTP_402_PAYMENT_REQUIRED, body="Payment Required")],
350+
contextlib.nullcontext(),
351+
[23, 34],
352+
),
353+
(
354+
# No retry setup: also fail on 429
341355
False,
342356
[],
343357
pytest.raises(OpenEoApiPlainError, match=re.escape("[429] Too Many Requests")),
344-
[0.1],
358+
[],
345359
),
346360
],
347361
)
@@ -401,12 +415,17 @@ def test_execute_batch_retry_after_429_too_many_requests(
401415

402416
con = openeo.connect(API_URL, retry=retry_config)
403417

418+
max_poll_interval = 0.1
419+
connection_retry_interval = 12.34
404420
with mock.patch("time.sleep") as sleep_mock:
405421
job = con.load_collection("SENTINEL2").create_job()
406422
with expectation_context:
407-
job.start_and_wait(max_poll_interval=0.1)
423+
job.start_and_wait(max_poll_interval=max_poll_interval, connection_retry_interval=connection_retry_interval)
408424

409-
assert sleep_mock.call_args_list == dirty_equals.Contains(*(mock.call(s) for s in expected_sleeps))
425+
# Check retry related sleeps
426+
actual_sleeps = [args[0] for args, kwargs in sleep_mock.call_args_list]
427+
actual_sleeps = [s for s in actual_sleeps if s != max_poll_interval]
428+
assert actual_sleeps == expected_sleeps
410429

411430

412431
class LogGenerator:

tests/utils/test_http.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ def test_default_basic(self, time_sleep):
4040
(1, [], True),
4141
(2, [5], True),
4242
(3, [5, 10], True),
43-
(5, [5, 10, 20, 40], True),
44-
(6, [5, 10, 20, 40], False),
43+
(4, [5, 10], False),
4544
],
4645
)
4746
def test_default_multiple_attempts(self, time_sleep, fail_count, expected_sleeps, success):

0 commit comments

Comments
 (0)