Skip to content

Commit bcbee0a

Browse files
committed
Address review: reuse property helpers and align timeout with Java client
1 parent 6c52148 commit bcbee0a

3 files changed

Lines changed: 88 additions & 82 deletions

File tree

mkdocs/docs/configuration.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -418,24 +418,29 @@ When server-side planning returns `storage-credentials` on a completed plan, PyI
418418
The REST Catalog uses `requests` with no retries and no timeout by default, so transient
419419
5xx / network failures bubble up immediately and slow servers can hang the client indefinitely.
420420
Set the `rest.client.*` catalog properties to opt in to a request timeout and a retry policy.
421+
The property names mirror the Java REST client so a single catalog configuration can serve both.
421422

422423
```yaml
423424
catalog:
424425
default:
425426
uri: http://rest-catalog/ws/
426-
rest.client.request-timeout: 60 # seconds, applied to the whole request
427-
rest.client.max-retries: 5 # number of retry attempts on transient failures
428-
rest.client.retry-backoff-factor: 1.0 # exponential backoff between retries
427+
rest.client.connection-timeout-ms: 5000 # milliseconds, time allowed to establish a connection
428+
rest.client.socket-timeout-ms: 60000 # milliseconds, time allowed between bytes once connected
429+
rest.client.max-retries: 5 # number of retry attempts on transient failures
430+
rest.client.retry-backoff-factor: 1.0 # exponential backoff between retries
429431
```
430432

431433
| Key | Example | Description |
432434
| --------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
433-
| rest.client.request-timeout | 60 | Timeout in seconds, applied as a single value to the whole request. Must be a positive number. |
435+
| rest.client.connection-timeout-ms | 5000 | Time to establish a connection, in milliseconds. Must be a positive number. |
436+
| rest.client.socket-timeout-ms | 60000 | Time allowed between bytes once a connection is established, in milliseconds. Must be a positive number. |
434437
| rest.client.max-retries | 5 | Number of retry attempts for transient failures. Must be non-negative. |
435438
| 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. |
436439

437-
Retries are applied to idempotent methods only (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) and to the
438-
transient HTTP status codes `429`, `500`, `502`, `503`, `504`. Other failures are not retried.
440+
`requests` cannot split the connection and socket timeouts, so the two values are summed and applied
441+
as a single request timeout (floored to whole seconds). Retries are applied to idempotent methods
442+
only (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) and to the transient HTTP status codes `429`, `500`,
443+
`502`, `503`, `504`. Other failures are not retried.
439444

440445
#### Headers in REST Catalog
441446

pyiceberg/catalog/rest/__init__.py

Lines changed: 32 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,11 @@
1919
import logging
2020
import time
2121
from collections import deque
22-
from collections.abc import Callable, Mapping
22+
from collections.abc import Mapping
2323
from enum import Enum
2424
from typing import (
2525
TYPE_CHECKING,
2626
Any,
27-
TypeVar,
2827
)
2928
from urllib.parse import quote, unquote
3029

@@ -98,7 +97,13 @@
9897
from pyiceberg.typedef import EMPTY_DICT, UTF8, IcebergBaseModel, Identifier, Properties
9998
from pyiceberg.types import transform_dict_value_to_str
10099
from pyiceberg.utils.deprecated import deprecation_message
101-
from pyiceberg.utils.properties import get_first_property_value, get_header_properties, property_as_bool, property_as_int
100+
from pyiceberg.utils.properties import (
101+
get_first_property_value,
102+
get_header_properties,
103+
property_as_bool,
104+
property_as_float,
105+
property_as_int,
106+
)
102107
from pyiceberg.view import View
103108
from pyiceberg.view.metadata import ViewMetadata, ViewVersion
104109

@@ -278,7 +283,8 @@ class ScanPlanningMode(Enum):
278283
SIGV4_SERVICE = "rest.signing-name"
279284
SIGV4_MAX_RETRIES = "rest.sigv4.max-retries"
280285
SIGV4_MAX_RETRIES_DEFAULT = 10
281-
REST_CLIENT_REQUEST_TIMEOUT = "rest.client.request-timeout"
286+
REST_CLIENT_CONNECTION_TIMEOUT_MS = "rest.client.connection-timeout-ms"
287+
REST_CLIENT_SOCKET_TIMEOUT_MS = "rest.client.socket-timeout-ms"
282288
REST_CLIENT_MAX_RETRIES = "rest.client.max-retries"
283289
REST_CLIENT_RETRY_BACKOFF_FACTOR = "rest.client.retry-backoff-factor"
284290
# Hard-coded internally so users cannot misconfigure the retry policy
@@ -453,29 +459,6 @@ class ListViewsResponse(IcebergBaseModel):
453459
_PLANNING_RESPONSE_ADAPTER = TypeAdapter(PlanningResponse)
454460

455461

456-
_T = TypeVar("_T", int, float)
457-
458-
459-
def _parse_connection_property(
460-
properties: Properties,
461-
property_name: str,
462-
converter: Callable[[Any], _T],
463-
type_description: str,
464-
is_invalid: Callable[[_T], bool],
465-
range_description: str,
466-
) -> _T | None:
467-
raw_value = properties.get(property_name)
468-
if raw_value is None:
469-
return None
470-
try:
471-
value = converter(raw_value)
472-
except (TypeError, ValueError) as e:
473-
raise ValueError(f"`{property_name}` must be {type_description}, got: {raw_value!r}") from e
474-
if is_invalid(value):
475-
raise ValueError(f"`{property_name}` must be {range_description}, got: {value}")
476-
return value
477-
478-
479462
class _RetryTimeoutHTTPAdapter(HTTPAdapter):
480463
"""HTTPAdapter that applies a default per-request timeout.
481464
@@ -508,37 +491,30 @@ def _create_connection_adapter(properties: Properties) -> _RetryTimeoutHTTPAdapt
508491
Returns None when no connection properties are supplied, leaving the default
509492
Session behavior unchanged. Raises ValueError on invalid input.
510493
"""
511-
if not any(
512-
property_name in properties
513-
for property_name in (REST_CLIENT_REQUEST_TIMEOUT, REST_CLIENT_MAX_RETRIES, REST_CLIENT_RETRY_BACKOFF_FACTOR)
514-
):
515-
return None
494+
connection_timeout_ms = property_as_int(properties, REST_CLIENT_CONNECTION_TIMEOUT_MS)
495+
if connection_timeout_ms is not None and connection_timeout_ms <= 0:
496+
raise ValueError(f"`{REST_CLIENT_CONNECTION_TIMEOUT_MS}` must be a positive number, got: {connection_timeout_ms}")
516497

517-
timeout = _parse_connection_property(
518-
properties,
519-
REST_CLIENT_REQUEST_TIMEOUT,
520-
float,
521-
"a number",
522-
lambda value: value <= 0,
523-
"a positive number",
524-
)
498+
socket_timeout_ms = property_as_int(properties, REST_CLIENT_SOCKET_TIMEOUT_MS)
499+
if socket_timeout_ms is not None and socket_timeout_ms <= 0:
500+
raise ValueError(f"`{REST_CLIENT_SOCKET_TIMEOUT_MS}` must be a positive number, got: {socket_timeout_ms}")
525501

526-
retries = _parse_connection_property(
527-
properties,
528-
REST_CLIENT_MAX_RETRIES,
529-
int,
530-
"an integer",
531-
lambda value: value < 0,
532-
"non-negative",
533-
)
534-
backoff_factor = _parse_connection_property(
535-
properties,
536-
REST_CLIENT_RETRY_BACKOFF_FACTOR,
537-
float,
538-
"a number",
539-
lambda value: value < 0,
540-
"non-negative",
541-
)
502+
retries = property_as_int(properties, REST_CLIENT_MAX_RETRIES)
503+
if retries is not None and retries < 0:
504+
raise ValueError(f"`{REST_CLIENT_MAX_RETRIES}` must be non-negative, got: {retries}")
505+
506+
backoff_factor = property_as_float(properties, REST_CLIENT_RETRY_BACKOFF_FACTOR)
507+
if backoff_factor is not None and backoff_factor < 0:
508+
raise ValueError(f"`{REST_CLIENT_RETRY_BACKOFF_FACTOR}` must be non-negative, got: {backoff_factor}")
509+
510+
if all(value is None for value in (connection_timeout_ms, socket_timeout_ms, retries, backoff_factor)):
511+
return None
512+
513+
# requests uses a single timeout and cannot split connect vs socket, so follow the Java client
514+
# and sum the two (milliseconds), flooring to whole seconds.
515+
timeout: float | None = None
516+
if connection_timeout_ms is not None or socket_timeout_ms is not None:
517+
timeout = ((connection_timeout_ms or 0) + (socket_timeout_ms or 0)) // 1000
542518

543519
return _RetryTimeoutHTTPAdapter(
544520
timeout=timeout,

tests/catalog/test_rest.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@
3737
EMPTY_BODY_SHA256,
3838
OAUTH2_SERVER_URI,
3939
PAGE_SIZE,
40+
REST_CLIENT_CONNECTION_TIMEOUT_MS,
4041
REST_CLIENT_MAX_RETRIES,
41-
REST_CLIENT_REQUEST_TIMEOUT,
4242
REST_CLIENT_RETRY_BACKOFF_FACTOR,
43+
REST_CLIENT_SOCKET_TIMEOUT_MS,
4344
SIGV4_MAX_RETRIES,
4445
SIGV4_MAX_RETRIES_DEFAULT,
4546
SNAPSHOT_LOADING_MODE,
@@ -2300,17 +2301,18 @@ def test_session_with_connection_timeout_and_retries(rest_mock: Mocker) -> None:
23002301
catalog_properties = {
23012302
"uri": TEST_URI,
23022303
"token": TEST_TOKEN,
2303-
REST_CLIENT_REQUEST_TIMEOUT: 60,
2304-
REST_CLIENT_MAX_RETRIES: 5,
2305-
REST_CLIENT_RETRY_BACKOFF_FACTOR: 1.0,
2304+
REST_CLIENT_CONNECTION_TIMEOUT_MS: "5000",
2305+
REST_CLIENT_SOCKET_TIMEOUT_MS: "60000",
2306+
REST_CLIENT_MAX_RETRIES: "5",
2307+
REST_CLIENT_RETRY_BACKOFF_FACTOR: "1.0",
23062308
}
2307-
catalog = RestCatalog("rest", **catalog_properties) # type: ignore
2309+
catalog = RestCatalog("rest", **catalog_properties)
23082310

23092311
https_adapter = catalog._session.adapters["https://"]
23102312
http_adapter = catalog._session.adapters["http://"]
23112313
assert isinstance(https_adapter, _RetryTimeoutHTTPAdapter)
23122314
assert https_adapter is http_adapter
2313-
assert https_adapter._timeout == 60.0
2315+
assert https_adapter._timeout == 65 # (5000 + 60000) ms floored to whole seconds
23142316
assert https_adapter.max_retries.total == 5
23152317
assert https_adapter.max_retries.backoff_factor == 1.0
23162318
# Internal retry policy: transient codes and idempotent methods only.
@@ -2323,13 +2325,26 @@ def test_session_with_connection_timeout_only(rest_mock: Mocker) -> None:
23232325
catalog_properties = {
23242326
"uri": TEST_URI,
23252327
"token": TEST_TOKEN,
2326-
REST_CLIENT_REQUEST_TIMEOUT: "30",
2328+
REST_CLIENT_CONNECTION_TIMEOUT_MS: "5000",
2329+
}
2330+
catalog = RestCatalog("rest", **catalog_properties)
2331+
adapter = catalog._session.adapters["https://"]
2332+
assert isinstance(adapter, _RetryTimeoutHTTPAdapter)
2333+
assert adapter._timeout == 5 # 5000 ms floored to whole seconds
2334+
# Default retry policy (total=0) is a no-op when only a timeout is configured.
2335+
assert adapter.max_retries.total == 0
2336+
2337+
2338+
def test_session_with_socket_timeout_only(rest_mock: Mocker) -> None:
2339+
catalog_properties = {
2340+
"uri": TEST_URI,
2341+
"token": TEST_TOKEN,
2342+
REST_CLIENT_SOCKET_TIMEOUT_MS: "60000",
23272343
}
23282344
catalog = RestCatalog("rest", **catalog_properties)
23292345
adapter = catalog._session.adapters["https://"]
23302346
assert isinstance(adapter, _RetryTimeoutHTTPAdapter)
2331-
assert adapter._timeout == 30.0
2332-
# Default retry policy (total=0) is a no-op when only timeout is configured.
2347+
assert adapter._timeout == 60 # 60000 ms floored to whole seconds
23332348
assert adapter.max_retries.total == 0
23342349

23352350

@@ -2390,12 +2405,12 @@ def test_session_retries_on_transient_5xx_then_succeeds() -> None:
23902405
with _local_rest_server_503_then_200(num_failures=3) as server:
23912406
catalog = RestCatalog(
23922407
"rest",
2393-
**{ # type: ignore
2408+
**{
23942409
"uri": f"http://127.0.0.1:{server['port']}/",
23952410
"token": TEST_TOKEN,
23962411
# backoff-factor=0 keeps the test fast; retries=3 covers three 503s + the eventual 200.
2397-
REST_CLIENT_MAX_RETRIES: 3,
2398-
REST_CLIENT_RETRY_BACKOFF_FACTOR: 0,
2412+
REST_CLIENT_MAX_RETRIES: "3",
2413+
REST_CLIENT_RETRY_BACKOFF_FACTOR: "0",
23992414
},
24002415
)
24012416
assert catalog.list_namespaces() == [("foo",)]
@@ -2409,11 +2424,11 @@ def test_session_exhausted_retries_surfaces_typed_exception() -> None:
24092424
with _local_rest_server_503_then_200(num_failures=100) as server:
24102425
catalog = RestCatalog(
24112426
"rest",
2412-
**{ # type: ignore
2427+
**{
24132428
"uri": f"http://127.0.0.1:{server['port']}/",
24142429
"token": TEST_TOKEN,
2415-
REST_CLIENT_MAX_RETRIES: 2,
2416-
REST_CLIENT_RETRY_BACKOFF_FACTOR: 0,
2430+
REST_CLIENT_MAX_RETRIES: "2",
2431+
REST_CLIENT_RETRY_BACKOFF_FACTOR: "0",
24172432
},
24182433
)
24192434
with pytest.raises(ServiceUnavailableError):
@@ -2426,20 +2441,30 @@ def test_session_with_invalid_connection_timeout_raises(rest_mock: Mocker) -> No
24262441
catalog_properties = {
24272442
"uri": TEST_URI,
24282443
"token": TEST_TOKEN,
2429-
REST_CLIENT_REQUEST_TIMEOUT: -1,
2444+
REST_CLIENT_CONNECTION_TIMEOUT_MS: "-1",
24302445
}
2431-
with pytest.raises(ValueError, match="`rest.client.request-timeout` must be a positive number"):
2432-
RestCatalog("rest", **catalog_properties) # type: ignore
2446+
with pytest.raises(ValueError, match="`rest.client.connection-timeout-ms` must be a positive number"):
2447+
RestCatalog("rest", **catalog_properties)
2448+
2449+
2450+
def test_session_with_invalid_socket_timeout_raises(rest_mock: Mocker) -> None:
2451+
catalog_properties = {
2452+
"uri": TEST_URI,
2453+
"token": TEST_TOKEN,
2454+
REST_CLIENT_SOCKET_TIMEOUT_MS: "0",
2455+
}
2456+
with pytest.raises(ValueError, match="`rest.client.socket-timeout-ms` must be a positive number"):
2457+
RestCatalog("rest", **catalog_properties)
24332458

24342459

24352460
def test_session_with_invalid_connection_retries_raises(rest_mock: Mocker) -> None:
24362461
catalog_properties = {
24372462
"uri": TEST_URI,
24382463
"token": TEST_TOKEN,
2439-
REST_CLIENT_MAX_RETRIES: -1,
2464+
REST_CLIENT_MAX_RETRIES: "-1",
24402465
}
24412466
with pytest.raises(ValueError, match="`rest.client.max-retries` must be non-negative"):
2442-
RestCatalog("rest", **catalog_properties) # type: ignore
2467+
RestCatalog("rest", **catalog_properties)
24432468

24442469

24452470
def test_rest_catalog_with_basic_auth_type(rest_mock: Mocker) -> None:

0 commit comments

Comments
 (0)