Skip to content

Commit 36053c7

Browse files
committed
Address review feedback: flat rest.client.* connection properties
- Rename the connection/retry/timeout config to flat rest.client.* keys (rest.client.request-timeout, rest.client.max-retries, rest.client.retry-backoff-factor), aligning naming with the Java HTTPClient while keeping seconds and a backoff-factor option. - Use requests' DEFAULT_RETRIES and drop the None branch in the adapter constructor. - Extract a helper to remove the repeated parse-and-validate logic across the three properties. - Retry idempotent PUT and DELETE in addition to GET/HEAD/OPTIONS.
1 parent 9a799c6 commit 36053c7

3 files changed

Lines changed: 93 additions & 76 deletions

File tree

‎mkdocs/docs/configuration.md‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -352,25 +352,24 @@ catalog:
352352

353353
The REST Catalog uses `requests` with no retries and no timeout by default, so transient
354354
5xx / network failures bubble up immediately and slow servers can hang the client indefinitely.
355-
Set a `connection:` block on the catalog to opt in to a per-request timeout and a retry policy.
355+
Set the `rest.client.*` catalog properties to opt in to a request timeout and a retry policy.
356356

357357
```yaml
358358
catalog:
359359
default:
360360
uri: http://rest-catalog/ws/
361-
connection:
362-
timeout: 60 # seconds, applied to every HTTP call
363-
retries: 5 # number of retry attempts on transient failures
364-
backoff-factor: 1.0 # exponential backoff between retries
361+
rest.client.request-timeout: 60 # seconds, applied to the whole request
362+
rest.client.max-retries: 5 # number of retry attempts on transient failures
363+
rest.client.retry-backoff-factor: 1.0 # exponential backoff between retries
365364
```
366365

367-
| Key | Example | Description |
368-
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
369-
| connection.timeout | 60 | Per-request timeout in seconds. Must be a positive number. |
370-
| connection.retries | 5 | Number of retry attempts for transient failures. Must be non-negative. |
371-
| connection.backoff-factor | 1.0 | Backoff factor between retry attempts. Must be non-negative. See [`urllib3` Retry docs](https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html#urllib3.util.Retry) for the formula. |
366+
| Key | Example | Description |
367+
| --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
368+
| rest.client.request-timeout | 60 | Timeout in seconds, applied as a single value to the whole request. Must be a positive number. |
369+
| rest.client.max-retries | 5 | Number of retry attempts for transient failures. Must be non-negative. |
370+
| rest.client.retry-backoff-factor | 1.0 | Backoff factor between retry attempts. Must be non-negative. See [`urllib3` Retry docs](https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html#urllib3.util.Retry) for the formula. |
372371

373-
Retries are applied to idempotent methods only (`GET`, `HEAD`, `OPTIONS`) and to the
372+
Retries are applied to idempotent methods only (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) and to the
374373
transient HTTP status codes `429`, `500`, `502`, `503`, `504`. Other failures are not retried.
375374

376375
#### Headers in REST Catalog

‎pyiceberg/catalog/rest/__init__.py‎

Lines changed: 67 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@
1717
from __future__ import annotations
1818

1919
from collections import deque
20-
from collections.abc import Mapping
20+
from collections.abc import Callable, Mapping
2121
from enum import Enum
2222
from typing import (
2323
TYPE_CHECKING,
2424
Any,
25+
TypeVar,
2526
)
2627
from urllib.parse import quote, unquote
2728

2829
from pydantic import ConfigDict, Field, TypeAdapter, field_validator
2930
from requests import HTTPError, PreparedRequest, Response, Session
30-
from requests.adapters import HTTPAdapter
31+
from requests.adapters import DEFAULT_RETRIES, HTTPAdapter
3132
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
3233
from typing_extensions import override
3334
from urllib3.util.retry import Retry
@@ -258,14 +259,13 @@ class ScanPlanningMode(Enum):
258259
SIGV4_SERVICE = "rest.signing-name"
259260
SIGV4_MAX_RETRIES = "rest.sigv4.max-retries"
260261
SIGV4_MAX_RETRIES_DEFAULT = 10
261-
CONNECTION = "connection"
262-
CONNECTION_TIMEOUT = "timeout"
263-
CONNECTION_RETRIES = "retries"
264-
CONNECTION_BACKOFF_FACTOR = "backoff-factor"
262+
REST_CLIENT_REQUEST_TIMEOUT = "rest.client.request-timeout"
263+
REST_CLIENT_MAX_RETRIES = "rest.client.max-retries"
264+
REST_CLIENT_RETRY_BACKOFF_FACTOR = "rest.client.retry-backoff-factor"
265265
# Hard-coded internally so users cannot misconfigure the retry policy
266266
# (e.g. setting raise_on_status=False would swallow 4xx errors silently).
267267
_CONNECTION_RETRY_STATUS_FORCELIST = (429, 500, 502, 503, 504)
268-
_CONNECTION_RETRY_ALLOWED_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
268+
_CONNECTION_RETRY_ALLOWED_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
269269
EMPTY_BODY_SHA256: str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
270270
OAUTH2_SERVER_URI = "oauth2-server-uri"
271271
SNAPSHOT_LOADING_MODE = "snapshot-loading-mode"
@@ -403,6 +403,29 @@ class ListViewsResponse(IcebergBaseModel):
403403
_PLANNING_RESPONSE_ADAPTER = TypeAdapter(PlanningResponse)
404404

405405

406+
_T = TypeVar("_T", int, float)
407+
408+
409+
def _parse_connection_property(
410+
properties: Properties,
411+
property_name: str,
412+
converter: Callable[[Any], _T],
413+
type_description: str,
414+
is_invalid: Callable[[_T], bool],
415+
range_description: str,
416+
) -> _T | None:
417+
raw_value = properties.get(property_name)
418+
if raw_value is None:
419+
return None
420+
try:
421+
value = converter(raw_value)
422+
except (TypeError, ValueError) as e:
423+
raise ValueError(f"`{property_name}` must be {type_description}, got: {raw_value!r}") from e
424+
if is_invalid(value):
425+
raise ValueError(f"`{property_name}` must be {range_description}, got: {value}")
426+
return value
427+
428+
406429
class _RetryTimeoutHTTPAdapter(HTTPAdapter):
407430
"""HTTPAdapter that applies a default per-request timeout.
408431
@@ -411,12 +434,9 @@ class _RetryTimeoutHTTPAdapter(HTTPAdapter):
411434
The adapter applies `self._timeout` whenever a per-call timeout is not set.
412435
"""
413436

414-
def __init__(self, timeout: float | None = None, max_retries: Retry | int | None = None) -> None:
437+
def __init__(self, timeout: float | None = None, max_retries: Retry | int = DEFAULT_RETRIES) -> None:
415438
self._timeout = timeout
416-
if max_retries is not None:
417-
super().__init__(max_retries=max_retries)
418-
else:
419-
super().__init__()
439+
super().__init__(max_retries=max_retries)
420440

421441
def send(
422442
self,
@@ -433,51 +453,50 @@ def send(
433453

434454

435455
def _create_connection_adapter(properties: Properties) -> _RetryTimeoutHTTPAdapter | None:
436-
"""Build a connection adapter from the optional `connection.*` properties.
456+
"""Build a connection adapter from the optional `rest.client.*` properties.
437457
438-
Returns None when no `connection` block is supplied, leaving the default
458+
Returns None when no connection properties are supplied, leaving the default
439459
Session behavior unchanged. Raises ValueError on invalid input.
440460
"""
441-
connection_config = properties.get(CONNECTION)
442-
if not connection_config:
461+
if not any(
462+
property_name in properties
463+
for property_name in (REST_CLIENT_REQUEST_TIMEOUT, REST_CLIENT_MAX_RETRIES, REST_CLIENT_RETRY_BACKOFF_FACTOR)
464+
):
443465
return None
444-
if not isinstance(connection_config, dict):
445-
raise ValueError(f"`{CONNECTION}` must be a mapping, got: {type(connection_config).__name__}")
446466

447-
timeout: float | None = None
448-
if (raw_timeout := connection_config.get(CONNECTION_TIMEOUT)) is not None:
449-
try:
450-
timeout = float(raw_timeout)
451-
except (TypeError, ValueError) as e:
452-
raise ValueError(f"`{CONNECTION}.{CONNECTION_TIMEOUT}` must be a number, got: {raw_timeout!r}") from e
453-
if timeout <= 0:
454-
raise ValueError(f"`{CONNECTION}.{CONNECTION_TIMEOUT}` must be a positive number, got: {timeout}")
455-
456-
# `retries` and `backoff_factor` default to 0 (a no-op Retry) so the user can set only
457-
# one or the other without forcing the rest of the policy to be specified explicitly.
458-
retries = 0
459-
if (raw_retries := connection_config.get(CONNECTION_RETRIES)) is not None:
460-
try:
461-
retries = int(raw_retries)
462-
except (TypeError, ValueError) as e:
463-
raise ValueError(f"`{CONNECTION}.{CONNECTION_RETRIES}` must be an integer, got: {raw_retries!r}") from e
464-
if retries < 0:
465-
raise ValueError(f"`{CONNECTION}.{CONNECTION_RETRIES}` must be non-negative, got: {retries}")
466-
467-
backoff_factor = 0.0
468-
if (raw_backoff := connection_config.get(CONNECTION_BACKOFF_FACTOR)) is not None:
469-
try:
470-
backoff_factor = float(raw_backoff)
471-
except (TypeError, ValueError) as e:
472-
raise ValueError(f"`{CONNECTION}.{CONNECTION_BACKOFF_FACTOR}` must be a number, got: {raw_backoff!r}") from e
473-
if backoff_factor < 0:
474-
raise ValueError(f"`{CONNECTION}.{CONNECTION_BACKOFF_FACTOR}` must be non-negative, got: {backoff_factor}")
467+
timeout = _parse_connection_property(
468+
properties,
469+
REST_CLIENT_REQUEST_TIMEOUT,
470+
float,
471+
"a number",
472+
lambda value: value <= 0,
473+
"a positive number",
474+
)
475+
476+
retries = _parse_connection_property(
477+
properties,
478+
REST_CLIENT_MAX_RETRIES,
479+
int,
480+
"an integer",
481+
lambda value: value < 0,
482+
"non-negative",
483+
)
484+
backoff_factor = _parse_connection_property(
485+
properties,
486+
REST_CLIENT_RETRY_BACKOFF_FACTOR,
487+
float,
488+
"a number",
489+
lambda value: value < 0,
490+
"non-negative",
491+
)
475492

476493
return _RetryTimeoutHTTPAdapter(
477494
timeout=timeout,
478495
max_retries=Retry(
479-
total=retries,
480-
backoff_factor=backoff_factor,
496+
# `retries` and `backoff_factor` fall back to a no-op Retry when unset, so a user can
497+
# configure only one without having to specify the rest of the policy.
498+
total=retries if retries is not None else DEFAULT_RETRIES,
499+
backoff_factor=backoff_factor if backoff_factor is not None else 0.0,
481500
status_forcelist=list(_CONNECTION_RETRY_STATUS_FORCELIST),
482501
allowed_methods=_CONNECTION_RETRY_ALLOWED_METHODS,
483502
# Return the final response on retry exhaustion (instead of raising MaxRetryError)

‎tests/catalog/test_rest.py‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,12 @@
3333
import pyiceberg
3434
from pyiceberg.catalog import PropertiesUpdateSummary, load_catalog
3535
from pyiceberg.catalog.rest import (
36-
CONNECTION,
37-
CONNECTION_BACKOFF_FACTOR,
38-
CONNECTION_RETRIES,
39-
CONNECTION_TIMEOUT,
4036
DEFAULT_ENDPOINTS,
4137
EMPTY_BODY_SHA256,
4238
OAUTH2_SERVER_URI,
39+
REST_CLIENT_MAX_RETRIES,
40+
REST_CLIENT_REQUEST_TIMEOUT,
41+
REST_CLIENT_RETRY_BACKOFF_FACTOR,
4342
SIGV4_MAX_RETRIES,
4443
SIGV4_MAX_RETRIES_DEFAULT,
4544
SNAPSHOT_LOADING_MODE,
@@ -2036,11 +2035,9 @@ def test_session_with_connection_timeout_and_retries(rest_mock: Mocker) -> None:
20362035
catalog_properties = {
20372036
"uri": TEST_URI,
20382037
"token": TEST_TOKEN,
2039-
CONNECTION: {
2040-
CONNECTION_TIMEOUT: 60,
2041-
CONNECTION_RETRIES: 5,
2042-
CONNECTION_BACKOFF_FACTOR: 1.0,
2043-
},
2038+
REST_CLIENT_REQUEST_TIMEOUT: 60,
2039+
REST_CLIENT_MAX_RETRIES: 5,
2040+
REST_CLIENT_RETRY_BACKOFF_FACTOR: 1.0,
20442041
}
20452042
catalog = RestCatalog("rest", **catalog_properties) # type: ignore
20462043

@@ -2054,14 +2051,14 @@ def test_session_with_connection_timeout_and_retries(rest_mock: Mocker) -> None:
20542051
# Internal retry policy: transient codes and idempotent methods only.
20552052
assert https_adapter.max_retries.status_forcelist == [429, 500, 502, 503, 504]
20562053
allowed_methods = https_adapter.max_retries.allowed_methods or frozenset()
2057-
assert set(allowed_methods) == {"GET", "HEAD", "OPTIONS"}
2054+
assert set(allowed_methods) == {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}
20582055

20592056

20602057
def test_session_with_connection_timeout_only(rest_mock: Mocker) -> None:
20612058
catalog_properties = {
20622059
"uri": TEST_URI,
20632060
"token": TEST_TOKEN,
2064-
CONNECTION: {CONNECTION_TIMEOUT: "30"},
2061+
REST_CLIENT_REQUEST_TIMEOUT: "30",
20652062
}
20662063
catalog = RestCatalog("rest", **catalog_properties) # type: ignore
20672064
adapter = catalog._session.adapters["https://"]
@@ -2132,7 +2129,8 @@ def test_session_retries_on_transient_5xx_then_succeeds() -> None:
21322129
"uri": f"http://127.0.0.1:{server['port']}/",
21332130
"token": TEST_TOKEN,
21342131
# backoff-factor=0 keeps the test fast; retries=3 covers three 503s + the eventual 200.
2135-
CONNECTION: {CONNECTION_RETRIES: 3, CONNECTION_BACKOFF_FACTOR: 0},
2132+
REST_CLIENT_MAX_RETRIES: 3,
2133+
REST_CLIENT_RETRY_BACKOFF_FACTOR: 0,
21362134
},
21372135
)
21382136
assert catalog.list_namespaces() == [("foo",)]
@@ -2149,7 +2147,8 @@ def test_session_exhausted_retries_surfaces_typed_exception() -> None:
21492147
**{ # type: ignore
21502148
"uri": f"http://127.0.0.1:{server['port']}/",
21512149
"token": TEST_TOKEN,
2152-
CONNECTION: {CONNECTION_RETRIES: 2, CONNECTION_BACKOFF_FACTOR: 0},
2150+
REST_CLIENT_MAX_RETRIES: 2,
2151+
REST_CLIENT_RETRY_BACKOFF_FACTOR: 0,
21532152
},
21542153
)
21552154
with pytest.raises(ServiceUnavailableError):
@@ -2162,19 +2161,19 @@ def test_session_with_invalid_connection_timeout_raises(rest_mock: Mocker) -> No
21622161
catalog_properties = {
21632162
"uri": TEST_URI,
21642163
"token": TEST_TOKEN,
2165-
CONNECTION: {CONNECTION_TIMEOUT: -1},
2164+
REST_CLIENT_REQUEST_TIMEOUT: -1,
21662165
}
2167-
with pytest.raises(ValueError, match="`connection.timeout` must be a positive number"):
2166+
with pytest.raises(ValueError, match="`rest.client.request-timeout` must be a positive number"):
21682167
RestCatalog("rest", **catalog_properties) # type: ignore
21692168

21702169

21712170
def test_session_with_invalid_connection_retries_raises(rest_mock: Mocker) -> None:
21722171
catalog_properties = {
21732172
"uri": TEST_URI,
21742173
"token": TEST_TOKEN,
2175-
CONNECTION: {CONNECTION_RETRIES: -1},
2174+
REST_CLIENT_MAX_RETRIES: -1,
21762175
}
2177-
with pytest.raises(ValueError, match="`connection.retries` must be non-negative"):
2176+
with pytest.raises(ValueError, match="`rest.client.max-retries` must be non-negative"):
21782177
RestCatalog("rest", **catalog_properties) # type: ignore
21792178

21802179

0 commit comments

Comments
 (0)