This is an automated email from the ASF dual-hosted git repository.
yzheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris-tools.git
The following commit(s) were added to refs/heads/main by this push:
new 70b6353 [MCP] feat: add support for configurable HTTP timeout (#73)
70b6353 is described below
commit 70b6353941e92bd59c643a207fae21198d35520d
Author: Yong Zheng <[email protected]>
AuthorDate: Tue Nov 25 01:14:29 2025 -0600
[MCP] feat: add support for configurable HTTP timeout (#73)
* Added support for configurable HTTP timeout
* MCP: Add unit tests for all tools and refactors (#51)
* Added support for configurable HTTP timeout
* Fix lint
---------
Co-authored-by: Yufei Gu <[email protected]>
---
mcp-server/README.md | 4 ++
mcp-server/polaris_mcp/authorization.py | 4 +-
mcp-server/polaris_mcp/rest.py | 7 ++--
mcp-server/polaris_mcp/server.py | 34 +++++++++++++--
mcp-server/tests/test_authorization.py | 4 ++
mcp-server/tests/test_rest_tool.py | 7 ++--
mcp-server/tests/test_server.py | 74 ++++++++++++++++++++++++++++++++-
7 files changed, 121 insertions(+), 13 deletions(-)
diff --git a/mcp-server/README.md b/mcp-server/README.md
index 042b5e4..2dbb1bd 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -74,6 +74,10 @@ Please note: `--directory` specifies a local directory. It
is not needed when we
| `POLARIS_TOKEN_SCOPE` | OAuth scope
string. | _unset_
|
| `POLARIS_TOKEN_URL` | Optional
override for the token endpoint URL. |
`${POLARIS_BASE_URL}api/catalog/v1/oauth/tokens` |
| `POLARIS_TOKEN_REFRESH_BUFFER_SECONDS` | Minimum
remaining token lifetime before refreshing in seconds. | `60.0`
|
+| `POLARIS_HTTP_TIMEOUT_SECONDS` | Default
timeout in seconds for all HTTP requests. | `30.0`
|
+| `POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS` | Timeout in
seconds for establishing HTTP connections. | `30.0`
|
+| `POLARIS_HTTP_READ_TIMEOUT_SECONDS` | Timeout in
seconds for reading HTTP responses. | `30.0`
|
+
When OAuth variables are supplied, the server automatically acquires and
refreshes tokens using the client credentials flow; otherwise a static bearer
token is used if provided.
diff --git a/mcp-server/polaris_mcp/authorization.py
b/mcp-server/polaris_mcp/authorization.py
index 0545ab4..2b61894 100644
--- a/mcp-server/polaris_mcp/authorization.py
+++ b/mcp-server/polaris_mcp/authorization.py
@@ -60,12 +60,14 @@ class
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
scope: Optional[str],
http: urllib3.PoolManager,
refresh_buffer_seconds: float,
+ timeout: urllib3.Timeout,
) -> None:
self._token_endpoint = token_endpoint
self._client_id = client_id
self._client_secret = client_secret
self._scope = scope
self._http = http
+ self._timeout = timeout
self._lock = threading.Lock()
self._cached: Optional[tuple[str, float]] = None # (token,
expires_at_epoch)
self._refresh_buffer_seconds = max(refresh_buffer_seconds, 0.0)
@@ -102,7 +104,7 @@ class
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
self._token_endpoint,
body=encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
- timeout=urllib3.Timeout(connect=20.0, read=20.0),
+ timeout=self._timeout,
)
if response.status != 200:
diff --git a/mcp-server/polaris_mcp/rest.py b/mcp-server/polaris_mcp/rest.py
index 530b8c6..8c9728d 100644
--- a/mcp-server/polaris_mcp/rest.py
+++ b/mcp-server/polaris_mcp/rest.py
@@ -31,9 +31,6 @@ from polaris_mcp.authorization import AuthorizationProvider,
none
from polaris_mcp.base import JSONDict, ToolExecutionResult
-DEFAULT_TIMEOUT = urllib3.Timeout(connect=30.0, read=30.0)
-
-
def encode_path_segment(value: str) -> str:
"""URL-encode a string for safe use as an HTTP path component."""
@@ -150,6 +147,7 @@ class PolarisRestTool:
base_url: str,
default_path_prefix: str,
http: urllib3.PoolManager,
+ timeout: urllib3.Timeout,
authorization_provider: Optional[AuthorizationProvider] = None,
) -> None:
self._name = name
@@ -158,6 +156,7 @@ class PolarisRestTool:
self._path_prefix = _normalize_prefix(default_path_prefix)
self._http = http
self._authorization = authorization_provider or none()
+ self._timeout = timeout
@property
def name(self) -> str:
@@ -254,7 +253,7 @@ class PolarisRestTool:
target_uri,
body=body_text.encode("utf-8") if body_text is not None else None,
headers=header_values,
- timeout=DEFAULT_TIMEOUT,
+ timeout=self._timeout,
)
response_body = response.data.decode("utf-8") if response.data else ""
diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py
index ea7a346..5e5ebdc 100644
--- a/mcp-server/polaris_mcp/server.py
+++ b/mcp-server/polaris_mcp/server.py
@@ -24,7 +24,7 @@ from __future__ import annotations
import logging
import logging.config
import os
-from typing import Any, Mapping, MutableMapping, Sequence
+from typing import Any, Mapping, MutableMapping, Sequence, Optional
from urllib.parse import urljoin, urlparse
import urllib3
@@ -62,6 +62,7 @@ OUTPUT_SCHEMA = {
"additionalProperties": True,
}
DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS = 60.0
+DEFAULT_HTTP_TIMEOUT = 30.0
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
@@ -85,8 +86,9 @@ logger = logging.getLogger(__name__)
def create_server() -> FastMCP:
"""Construct a FastMCP server with Polaris tools."""
base_url = _resolve_base_url()
+ timeout = _resolve_http_timeout()
http = urllib3.PoolManager()
- authorization_provider = _resolve_authorization_provider(base_url, http)
+ authorization_provider = _resolve_authorization_provider(base_url, http,
timeout)
catalog_rest = PolarisRestTool(
name="polaris.rest.catalog",
description="Shared REST delegate for catalog operations",
@@ -94,6 +96,7 @@ def create_server() -> FastMCP:
default_path_prefix="api/catalog/v1/",
http=http,
authorization_provider=authorization_provider,
+ timeout=timeout,
)
management_rest = PolarisRestTool(
name="polaris.rest.management",
@@ -102,6 +105,7 @@ def create_server() -> FastMCP:
default_path_prefix="api/management/v1/",
http=http,
authorization_provider=authorization_provider,
+ timeout=timeout,
)
policy_rest = PolarisRestTool(
name="polaris.rest.policy",
@@ -110,6 +114,7 @@ def create_server() -> FastMCP:
default_path_prefix="api/catalog/polaris/v1/",
http=http,
authorization_provider=authorization_provider,
+ timeout=timeout,
)
table_tool = PolarisTableTool(rest_client=catalog_rest)
@@ -434,8 +439,30 @@ def _validate_base_url(value: str) -> str:
return value
+def _resolve_http_timeout() -> urllib3.Timeout:
+ def parse_timeout(raw: Optional[str]) -> Optional[float]:
+ try:
+ return float(raw.strip()) if raw and raw.strip() else None
+ except ValueError:
+ return None
+
+ default_timeout = parse_timeout(os.getenv("POLARIS_HTTP_TIMEOUT_SECONDS"))
+ connect_timeout = (
+ parse_timeout(os.getenv("POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS"))
+ or default_timeout
+ or DEFAULT_HTTP_TIMEOUT
+ )
+ read_timeout = (
+ parse_timeout(os.getenv("POLARIS_HTTP_READ_TIMEOUT_SECONDS"))
+ or default_timeout
+ or DEFAULT_HTTP_TIMEOUT
+ )
+
+ return urllib3.Timeout(connect=connect_timeout, read=read_timeout)
+
+
def _resolve_authorization_provider(
- base_url: str, http: urllib3.PoolManager
+ base_url: str, http: urllib3.PoolManager, timeout: urllib3.Timeout
) -> AuthorizationProvider:
token = _resolve_token()
if token:
@@ -466,6 +493,7 @@ def _resolve_authorization_provider(
scope=scope,
http=http,
refresh_buffer_seconds=refresh_buffer_seconds,
+ timeout=timeout,
)
return none()
diff --git a/mcp-server/tests/test_authorization.py
b/mcp-server/tests/test_authorization.py
index e120de6..dfb0945 100644
--- a/mcp-server/tests/test_authorization.py
+++ b/mcp-server/tests/test_authorization.py
@@ -66,6 +66,7 @@ def test_client_credentials_fetches_and_caches_tokens(
scope=None,
http=http,
refresh_buffer_seconds=0.0,
+ timeout=mock.sentinel.timeout,
)
with mock.patch("time.time", return_value=now):
@@ -116,6 +117,7 @@ def test_client_credentials_refresh_buffer() -> None:
scope=None,
http=http,
refresh_buffer_seconds=refresh_buffer,
+ timeout=mock.sentinel.timeout,
)
# Initial valid token
@@ -177,6 +179,7 @@ def test_client_credentials_rejects_invalid_responses(
scope=None,
http=http,
refresh_buffer_seconds=0.0,
+ timeout=mock.sentinel.timeout,
)
with pytest.raises(RuntimeError, match=expected_message):
@@ -194,6 +197,7 @@ def test_client_credentials_errors_on_non_200_status() ->
None:
scope=None,
http=http,
refresh_buffer_seconds=0.0,
+ timeout=mock.sentinel.timeout,
)
with pytest.raises(RuntimeError, match="500"):
diff --git a/mcp-server/tests/test_rest_tool.py
b/mcp-server/tests/test_rest_tool.py
index ba108ea..cbd3682 100644
--- a/mcp-server/tests/test_rest_tool.py
+++ b/mcp-server/tests/test_rest_tool.py
@@ -27,7 +27,7 @@ from unittest import mock
import pytest
from urllib3._collections import HTTPHeaderDict
-from polaris_mcp.rest import DEFAULT_TIMEOUT, PolarisRestTool
+from polaris_mcp.rest import PolarisRestTool
def _build_response(
@@ -59,6 +59,7 @@ def _create_tool() -> tuple[PolarisRestTool, mock.Mock,
mock.Mock]:
default_path_prefix="api/catalog/v1/",
http=http,
authorization_provider=auth,
+ timeout=mock.sentinel.timeout,
)
return tool, http, auth
@@ -97,7 +98,7 @@ def test_call_builds_request_and_metadata_with_json_body() ->
None:
expected_url,
body=b'{"name": "analytics"}',
headers=expected_headers,
- timeout=DEFAULT_TIMEOUT,
+ timeout=mock.sentinel.timeout,
)
auth.authorization_header.assert_not_called()
@@ -148,7 +149,7 @@ def
test_call_uses_authorization_provider_and_handles_plain_text() -> None:
expected_url,
body=b"payload",
headers=expected_headers,
- timeout=DEFAULT_TIMEOUT,
+ timeout=mock.sentinel.timeout,
)
auth.authorization_header.assert_called_once()
diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py
index 06e4f1d..906908c 100644
--- a/mcp-server/tests/test_server.py
+++ b/mcp-server/tests/test_server.py
@@ -210,7 +210,7 @@ class TestAuthorizationProviderResolution:
mock.patch.dict(os.environ, {}, clear=True),
):
provider = server._resolve_authorization_provider(
- "https://base/", fake_http
+ "https://base/", fake_http, mock.sentinel.timeout
)
assert isinstance(provider, server.StaticAuthorizationProvider)
@@ -237,7 +237,7 @@ class TestAuthorizationProviderResolution:
) as mock_factory,
):
provider = server._resolve_authorization_provider(
- "https://base/", fake_http
+ "https://base/", fake_http, mock.sentinel.timeout
)
assert provider is fake_provider
@@ -248,4 +248,74 @@ class TestAuthorizationProviderResolution:
scope="scope",
http=fake_http,
refresh_buffer_seconds=60.0,
+ timeout=mock.sentinel.timeout,
)
+
+
+class TestServerConfiguration:
+ def test_resolve_http_timeout_defaults(self) -> None:
+ with mock.patch.dict(os.environ, {}, clear=True):
+ timeout = server._resolve_http_timeout()
+ assert timeout.connect_timeout == server.DEFAULT_HTTP_TIMEOUT
+ assert timeout.read_timeout == server.DEFAULT_HTTP_TIMEOUT
+
+ def test_resolve_http_timeout_global(self) -> None:
+ with mock.patch.dict(
+ os.environ, {"POLARIS_HTTP_TIMEOUT_SECONDS": "60.0"}, clear=True
+ ):
+ timeout = server._resolve_http_timeout()
+ assert timeout.connect_timeout == 60.0
+ assert timeout.read_timeout == 60.0
+
+ def test_resolve_http_timeout_specific_overrides(self) -> None:
+ with mock.patch.dict(
+ os.environ,
+ {
+ "POLARIS_HTTP_TIMEOUT_SECONDS": "60.0",
+ "POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS": "10.0",
+ "POLARIS_HTTP_READ_TIMEOUT_SECONDS": "120.0",
+ },
+ clear=True,
+ ):
+ timeout = server._resolve_http_timeout()
+ assert timeout.connect_timeout == 10.0
+ assert timeout.read_timeout == 120.0
+
+ def test_resolve_http_timeout_partial_overrides(self) -> None:
+ with mock.patch.dict(
+ os.environ,
+ {
+ "POLARIS_HTTP_TIMEOUT_SECONDS": "60.0",
+ "POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS": "10.0",
+ },
+ clear=True,
+ ):
+ timeout = server._resolve_http_timeout()
+ assert timeout.connect_timeout == 10.0
+ assert timeout.read_timeout == 60.0
+
+ with mock.patch.dict(
+ os.environ,
+ {
+ "POLARIS_HTTP_TIMEOUT_SECONDS": "60.0",
+ "POLARIS_HTTP_READ_TIMEOUT_SECONDS": "120.0",
+ },
+ clear=True,
+ ):
+ timeout = server._resolve_http_timeout()
+ assert timeout.connect_timeout == 60.0
+ assert timeout.read_timeout == 120.0
+
+ def test_resolve_http_timeout_invalid_values(self) -> None:
+ with mock.patch.dict(
+ os.environ,
+ {
+ "POLARIS_HTTP_TIMEOUT_SECONDS": "invalid",
+ "POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS": "bad",
+ "POLARIS_HTTP_READ_TIMEOUT_SECONDS": "N/A",
+ },
+ clear=True,
+ ):
+ timeout = server._resolve_http_timeout()
+ assert timeout.connect_timeout == server.DEFAULT_HTTP_TIMEOUT
+ assert timeout.read_timeout == server.DEFAULT_HTTP_TIMEOUT