Skip to content

Commit 6b964c0

Browse files
authored
Python: Allow custom httpx client timeout when not using custom client (#12379)
### Motivation and Context Currently, if one is using the OpenAPI plugin within SK, and not providing a custom httpx client, they are hit with a hard-coded 5 second timeout. Devs need a way to be able to customize the timeout. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description Provide a way to customize the timeout used during API calls. - Closes #12373 - Adds unit tests <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄
1 parent 3baaee2 commit 6b964c0

File tree

6 files changed

+76
-13
lines changed

6 files changed

+76
-13
lines changed
Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
from semantic_kernel.utils.feature_stage_decorator import experimental
43

5-
6-
@experimental
74
class RestApiRunOptions:
85
"""The options for running the REST API operation."""
96

10-
def __init__(self, server_url_override=None, api_host_url=None) -> None:
11-
"""Initialize the REST API operation run options."""
12-
self.server_url_override: str = server_url_override
13-
self.api_host_url: str = api_host_url
7+
def __init__(
8+
self, server_url_override: str | None = None, api_host_url: str | None = None, timeout: float | None = None
9+
) -> None:
10+
"""Initialize the REST API operation run options.
11+
12+
Args:
13+
server_url_override: The server URL override, if any.
14+
api_host_url: The API host URL, if any.
15+
timeout: The timeout for the operation, if any.
16+
"""
17+
self.server_url_override: str | None = server_url_override
18+
self.api_host_url: str | None = api_host_url
19+
self.timeout: float | None = timeout

python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@
1111
OperationSelectionPredicateContext,
1212
)
1313
from semantic_kernel.kernel_pydantic import KernelBaseModel
14-
from semantic_kernel.utils.feature_stage_decorator import experimental
1514

1615
AuthCallbackType = Callable[..., Awaitable[Any]]
1716

1817

19-
@experimental
2018
class OpenAPIFunctionExecutionParameters(KernelBaseModel):
2119
"""OpenAPI function execution parameters."""
2220

@@ -29,6 +27,9 @@ class OpenAPIFunctionExecutionParameters(KernelBaseModel):
2927
enable_payload_namespacing: bool = False
3028
operations_to_exclude: list[str] = Field(default_factory=list, description="The operationId(s) to exclude")
3129
operation_selection_predicate: Callable[[OperationSelectionPredicateContext], bool] | None = None
30+
timeout: float | None = Field(
31+
None, description="Default timeout in seconds for HTTP requests. Uses httpx default (5 seconds) if None."
32+
)
3233

3334
def model_post_init(self, __context: Any) -> None:
3435
"""Post initialization method for the model."""

python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import logging
44
from typing import TYPE_CHECKING, Any
5-
from urllib.parse import urlparse
65

76
from semantic_kernel.connectors.openapi_plugin.const import OperationExtensions
87
from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation
@@ -146,9 +145,14 @@ async def run_openapi_operation(
146145

147146
options = RestApiRunOptions(
148147
server_url_override=(
149-
urlparse(execution_parameters.server_url_override) if execution_parameters else None
148+
execution_parameters.server_url_override
149+
if execution_parameters and execution_parameters.server_url_override is not None
150+
else None
150151
),
151152
api_host_url=Uri(document_uri).get_left_part() if document_uri is not None else None,
153+
timeout=execution_parameters.timeout
154+
if execution_parameters and execution_parameters.timeout is not None
155+
else None,
152156
)
153157

154158
return await runner.run_operation(operation, kernel_arguments, options)

python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ async def run_operation(
159159
)
160160
headers["Content-Type"] = self._get_first_response_media_type(responses)
161161

162+
timeout = options.timeout if options and hasattr(options, "timeout") and options.timeout is not None else None
163+
162164
async def fetch():
163165
async def make_request(client: httpx.AsyncClient):
164166
merged_headers = client.headers.copy()
@@ -174,7 +176,7 @@ async def make_request(client: httpx.AsyncClient):
174176

175177
if hasattr(self, "http_client") and self.http_client is not None:
176178
return await make_request(self.http_client)
177-
async with httpx.AsyncClient(timeout=5) as client:
179+
async with httpx.AsyncClient(timeout=timeout) as client:
178180
return await make_request(client)
179181

180182
return await fetch()

python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
RestApiParameter,
99
RestApiParameterLocation,
1010
)
11+
from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions
1112
from semantic_kernel.connectors.openapi_plugin.openapi_manager import (
1213
_create_function_from_operation,
1314
create_functions_from_openapi,
1415
)
16+
from semantic_kernel.connectors.openapi_plugin.openapi_runner import OpenApiRunner
1517
from semantic_kernel.exceptions import FunctionExecutionException
1618
from semantic_kernel.functions.kernel_function_decorator import kernel_function
1719
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
@@ -222,3 +224,49 @@ async def test_create_functions_from_openapi_raises_exception(mock_parse):
222224
create_functions_from_openapi(plugin_name="test_plugin", openapi_document_path="test_openapi_document_path")
223225

224226
mock_parse.assert_called_once_with("test_openapi_document_path")
227+
228+
229+
async def test_run_operation_uses_timeout_from_run_options():
230+
minimal_openapi_spec = {
231+
"openapi": "3.0.0",
232+
"info": {"title": "Test", "version": "1.0.0"},
233+
"paths": {},
234+
}
235+
runner = OpenApiRunner(parsed_openapi_document=minimal_openapi_spec)
236+
operation = MagicMock()
237+
operation.method = "GET"
238+
operation.build_headers.return_value = {}
239+
operation.build_operation_payload.return_value = ("{}", None)
240+
operation.responses = {}
241+
operation.responses = {}
242+
operation.operation_id = "test_id"
243+
244+
desired_timeout = 3.3
245+
246+
with (
247+
patch("httpx.AsyncClient.__aenter__", new_callable=AsyncMock) as mock_enter,
248+
patch("httpx.AsyncClient.__aexit__", new_callable=AsyncMock),
249+
patch("httpx.AsyncClient.__init__", return_value=None) as mock_init,
250+
):
251+
mock_client = MagicMock()
252+
mock_enter.return_value = mock_client
253+
mock_response = MagicMock()
254+
mock_response.raise_for_status = MagicMock()
255+
mock_response.text = "FAKE_RESPONSE"
256+
mock_client.request = AsyncMock(return_value=mock_response)
257+
258+
operation.build_query_string.return_value = ""
259+
operation.server_url = "https://api.example.com"
260+
operation.path = "/test"
261+
operation.build_operation_url.return_value = "https://api.example.com/test"
262+
result = await runner.run_operation(
263+
operation=operation, arguments=None, options=RestApiRunOptions(timeout=desired_timeout)
264+
)
265+
266+
assert result == "FAKE_RESPONSE"
267+
found = False
268+
for call_args in mock_init.call_args_list:
269+
if "timeout" in call_args.kwargs and call_args.kwargs["timeout"] == desired_timeout:
270+
found = True
271+
break
272+
assert found, f"httpx.AsyncClient was not called with timeout={desired_timeout}"

python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
def test_initialization():
77
server_url_override = "http://example.com"
88
api_host_url = "http://example.com"
9+
timeout = 30.0
910

10-
rest_api_operation_run_options = RestApiRunOptions(server_url_override, api_host_url)
11+
rest_api_operation_run_options = RestApiRunOptions(server_url_override, api_host_url, timeout)
1112

1213
assert rest_api_operation_run_options.server_url_override == server_url_override
1314
assert rest_api_operation_run_options.api_host_url == api_host_url
15+
assert rest_api_operation_run_options.timeout == timeout
1416

1517

1618
def test_initialization_no_params():

0 commit comments

Comments
 (0)