From 7f0b65030231348f0e5a8413c5fa8d9393c4070b Mon Sep 17 00:00:00 2001 From: hamarg <147648269+hamarg@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:47:54 -0700 Subject: [PATCH 1/5] feat: enable custom timeout in get_httpx_client_kwargs --- .../uipath/platform/common/_http_config.py | 4 ++- .../orchestrator/_attachments_service.py | 30 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/common/_http_config.py b/packages/uipath-platform/src/uipath/platform/common/_http_config.py index a367db7a5..8cd3f00b3 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_http_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_http_config.py @@ -54,14 +54,16 @@ def create_ssl_context(cafile: str): def get_httpx_client_kwargs( headers: Dict[str, str] | None = None, + timeout: float = 30.0, ) -> Dict[str, Any]: """Get standardized httpx client configuration. Args: headers: Optional headers to merge with platform headers (e.g. licensing). Caller headers take priority on key conflicts. + timeout: Request timeout in seconds. Defaults to 30.0. """ - client_kwargs: Dict[str, Any] = {"follow_redirects": True, "timeout": 30.0} + client_kwargs: Dict[str, Any] = {"follow_redirects": True, "timeout": timeout} ca_bundle = get_ca_bundle_path() client_kwargs["verify"] = create_ssl_context(ca_bundle) if ca_bundle else False diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py index 5d4c192b5..58b03bd31 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py @@ -451,6 +451,7 @@ def upload( content: str | bytes, folder_key: str | None = None, folder_path: str | None = None, + timeout: float = 30.0, ) -> uuid.UUID: ... @overload @@ -461,6 +462,7 @@ def upload( source_path: str, folder_key: str | None = None, folder_path: str | None = None, + timeout: float = 30.0, ) -> uuid.UUID: ... @traced( @@ -476,6 +478,7 @@ def upload( source_path: str | None = None, folder_key: str | None = None, folder_path: str | None = None, + timeout: float = 30.0, ) -> uuid.UUID: """Upload a file or content to UiPath as an attachment. @@ -488,6 +491,7 @@ def upload( source_path (str | None): The local path of the file to upload. folder_key (str | None): The key of the folder. Override the default one set in the SDK config. folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + timeout (float): Request timeout in seconds. Defaults to 30.0. Returns: uuid.UUID: The UUID of the created attachment. @@ -559,7 +563,9 @@ def upload( "PUT", upload_uri, headers=headers, content=file_content ) else: - with httpx.Client(**get_httpx_client_kwargs()) as client: + with httpx.Client( + **get_httpx_client_kwargs(timeout=timeout) + ) as client: client.put(upload_uri, headers=headers, content=file_content) else: # Upload from memory @@ -570,7 +576,9 @@ def upload( if result["BlobFileAccess"]["RequiresAuth"]: self.request("PUT", upload_uri, headers=headers, content=content) else: - with httpx.Client(**get_httpx_client_kwargs()) as client: + with httpx.Client( + **get_httpx_client_kwargs(timeout=timeout) + ) as client: client.put(upload_uri, headers=headers, content=content) return attachment_key @@ -583,6 +591,7 @@ async def upload_async( content: str | bytes, folder_key: str | None = None, folder_path: str | None = None, + timeout: float = 30.0, ) -> uuid.UUID: ... @overload @@ -593,6 +602,7 @@ async def upload_async( source_path: str, folder_key: str | None = None, folder_path: str | None = None, + timeout: float = 30.0, ) -> uuid.UUID: ... @traced( @@ -608,6 +618,7 @@ async def upload_async( source_path: str | None = None, folder_key: str | None = None, folder_path: str | None = None, + timeout: float = 30.0, ) -> uuid.UUID: """Upload a file or content to UiPath as an attachment asynchronously. @@ -620,6 +631,7 @@ async def upload_async( source_path (str | None): The local path of the file to upload. folder_key (str | None): The key of the folder. Override the default one set in the SDK config. folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + timeout (float): Request timeout in seconds. Defaults to 30.0. Returns: uuid.UUID: The UUID of the created attachment. @@ -695,8 +707,12 @@ async def main(): "PUT", upload_uri, headers=headers, content=file_content ) else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - client.put(upload_uri, headers=headers, content=file_content) + async with httpx.AsyncClient( + **get_httpx_client_kwargs(timeout=timeout) + ) as client: + await client.put( + upload_uri, headers=headers, content=file_content + ) else: # Upload from memory # Convert string to bytes if needed @@ -708,8 +724,10 @@ async def main(): "PUT", upload_uri, headers=headers, content=content ) else: - with httpx.Client(**get_httpx_client_kwargs()) as client: - client.put(upload_uri, headers=headers, content=content) + async with httpx.AsyncClient( + **get_httpx_client_kwargs(timeout=timeout) + ) as client: + await client.put(upload_uri, headers=headers, content=content) return attachment_key From 46528b424e527fca20b38c44b3d1e9427bd98b71 Mon Sep 17 00:00:00 2001 From: hamarg <147648269+hamarg@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:02:16 -0700 Subject: [PATCH 2/5] feat: add timeout to requires auth path --- .../orchestrator/_attachments_service.py | 26 ++++++++++++++++--- .../tests/services/test_http_config.py | 19 ++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py index 58b03bd31..3600f1ed6 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py @@ -560,7 +560,11 @@ def upload( file_content = file.read() if result["BlobFileAccess"]["RequiresAuth"]: self.request( - "PUT", upload_uri, headers=headers, content=file_content + "PUT", + upload_uri, + headers=headers, + content=file_content, + timeout=timeout, ) else: with httpx.Client( @@ -574,7 +578,13 @@ def upload( content = content.encode("utf-8") if result["BlobFileAccess"]["RequiresAuth"]: - self.request("PUT", upload_uri, headers=headers, content=content) + self.request( + "PUT", + upload_uri, + headers=headers, + content=content, + timeout=timeout, + ) else: with httpx.Client( **get_httpx_client_kwargs(timeout=timeout) @@ -704,7 +714,11 @@ async def main(): file_content = file.read() if result["BlobFileAccess"]["RequiresAuth"]: await self.request_async( - "PUT", upload_uri, headers=headers, content=file_content + "PUT", + upload_uri, + headers=headers, + content=file_content, + timeout=timeout, ) else: async with httpx.AsyncClient( @@ -721,7 +735,11 @@ async def main(): if result["BlobFileAccess"]["RequiresAuth"]: await self.request_async( - "PUT", upload_uri, headers=headers, content=content + "PUT", + upload_uri, + headers=headers, + content=content, + timeout=timeout, ) else: async with httpx.AsyncClient( diff --git a/packages/uipath-platform/tests/services/test_http_config.py b/packages/uipath-platform/tests/services/test_http_config.py index 628d69a59..c05800b2f 100644 --- a/packages/uipath-platform/tests/services/test_http_config.py +++ b/packages/uipath-platform/tests/services/test_http_config.py @@ -98,3 +98,22 @@ def test_no_headers_key_when_empty(self) -> None: ): result = get_httpx_client_kwargs(headers={}) assert "headers" not in result + + +class TestGetHttpxClientKwargsTimeout: + """Tests for timeout parameter in get_httpx_client_kwargs().""" + + def test_default_timeout(self) -> None: + """Default timeout is 30.0 seconds.""" + result = get_httpx_client_kwargs() + assert result["timeout"] == 30.0 + + def test_custom_timeout(self) -> None: + """Custom timeout value is passed through.""" + result = get_httpx_client_kwargs(timeout=12.5) + assert result["timeout"] == 12.5 + + def test_zero_timeout(self) -> None: + """Zero timeout is valid.""" + result = get_httpx_client_kwargs(timeout=0) + assert result["timeout"] == 0 From 4478bcb830eec00f1890b6425e09c492148ac4a6 Mon Sep 17 00:00:00 2001 From: hamarg <147648269+hamarg@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:06:19 -0700 Subject: [PATCH 3/5] fix: reformat file --- .../src/uipath/platform/orchestrator/_attachments_service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py index 3600f1ed6..e1b6bc9fc 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py @@ -586,9 +586,7 @@ def upload( timeout=timeout, ) else: - with httpx.Client( - **get_httpx_client_kwargs(timeout=timeout) - ) as client: + with httpx.Client(**get_httpx_client_kwargs(timeout=timeout)) as client: client.put(upload_uri, headers=headers, content=content) return attachment_key From fcabeee66b681c12d6475d664a1cb78ea4f7e8ed Mon Sep 17 00:00:00 2001 From: hamarg <147648269+hamarg@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:22:33 -0700 Subject: [PATCH 4/5] feat: add tests --- .../services/test_attachments_service.py | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/packages/uipath-platform/tests/services/test_attachments_service.py b/packages/uipath-platform/tests/services/test_attachments_service.py index 8e7b6aaa0..e8150b84d 100644 --- a/packages/uipath-platform/tests/services/test_attachments_service.py +++ b/packages/uipath-platform/tests/services/test_attachments_service.py @@ -128,6 +128,27 @@ def blob_uri_response() -> dict[str, Any]: } +@pytest.fixture +def blob_uri_response_requires_auth(base_url: str) -> dict[str, Any]: + """Provides a mock response for blob access requests that require auth. + + Returns: + Dict[str, Any]: A mock API response with blob storage access details requiring auth. + """ + return { + "Id": "12345678-1234-1234-1234-123456789012", + "Name": "test_file.txt", + "BlobFileAccess": { + "Uri": f"{base_url}/blob-storage/test-blob", + "Headers": { + "Keys": ["x-ms-blob-type", "Content-Type"], + "Values": ["BlockBlob", "application/octet-stream"], + }, + "RequiresAuth": True, + }, + } + + class TestAttachmentsService: """Test suite for the AttachmentsService class.""" @@ -1207,3 +1228,183 @@ def test_attachments_service_conforms_to_attachments_protocol( conforming: AttachmentsProtocol = service assert isinstance(conforming, AttachmentsProtocol) + + +class TestAttachmentsServiceRequiresAuth: + """Tests for uploads when RequiresAuth is True (uses self.request with timeout).""" + + def test_upload_with_content_requires_auth( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + blob_uri_response_requires_auth: dict[str, Any], + ) -> None: + """Test uploading with content when RequiresAuth is True. + + This exercises the code path where self.request() is called with timeout. + """ + content = "Test content in memory" + file_name = "text_content.txt" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response_requires_auth, + ) + + httpx_mock.add_response( + url=blob_uri_response_requires_auth["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + attachment_key = service.upload( + name=file_name, + content=content, + timeout=60.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response_requires_auth["Id"]) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + upload_request = requests[1] + assert upload_request.method == "PUT" + assert upload_request.content == content.encode("utf-8") + + def test_upload_with_file_path_requires_auth( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_file: Tuple[str, str, str], + blob_uri_response_requires_auth: dict[str, Any], + ) -> None: + """Test uploading from file path when RequiresAuth is True. + + This exercises the code path where self.request() is called with timeout. + """ + content, file_name, file_path = temp_file + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response_requires_auth, + ) + + httpx_mock.add_response( + url=blob_uri_response_requires_auth["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + attachment_key = service.upload( + name=file_name, + source_path=file_path, + timeout=45.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response_requires_auth["Id"]) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + upload_request = requests[1] + assert upload_request.method == "PUT" + + @pytest.mark.asyncio + async def test_upload_async_with_content_requires_auth( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + blob_uri_response_requires_auth: dict[str, Any], + ) -> None: + """Test async uploading with content when RequiresAuth is True. + + This exercises the code path where self.request_async() is called with timeout. + """ + content = "Test content in memory async" + file_name = "text_content_async.txt" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response_requires_auth, + ) + + httpx_mock.add_response( + url=blob_uri_response_requires_auth["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + attachment_key = await service.upload_async( + name=file_name, + content=content, + timeout=90.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response_requires_auth["Id"]) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + upload_request = requests[1] + assert upload_request.method == "PUT" + assert upload_request.content == content.encode("utf-8") + + @pytest.mark.asyncio + async def test_upload_async_with_file_path_requires_auth( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_file: Tuple[str, str, str], + blob_uri_response_requires_auth: dict[str, Any], + ) -> None: + """Test async uploading from file path when RequiresAuth is True. + + This exercises the code path where self.request_async() is called with timeout. + """ + content, file_name, file_path = temp_file + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response_requires_auth, + ) + + httpx_mock.add_response( + url=blob_uri_response_requires_auth["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + attachment_key = await service.upload_async( + name=file_name, + source_path=file_path, + timeout=120.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response_requires_auth["Id"]) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + upload_request = requests[1] + assert upload_request.method == "PUT" From c1271d89936637fe88815f953150844b6a763e91 Mon Sep 17 00:00:00 2001 From: hamarg <147648269+hamarg@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:37:08 -0700 Subject: [PATCH 5/5] feat: add more tests --- .../services/test_attachments_service.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/packages/uipath-platform/tests/services/test_attachments_service.py b/packages/uipath-platform/tests/services/test_attachments_service.py index e8150b84d..7a060ccd2 100644 --- a/packages/uipath-platform/tests/services/test_attachments_service.py +++ b/packages/uipath-platform/tests/services/test_attachments_service.py @@ -1230,6 +1230,149 @@ def test_attachments_service_conforms_to_attachments_protocol( assert isinstance(conforming, AttachmentsProtocol) +class TestAttachmentsServiceTimeout: + """Tests for custom timeout parameter in upload methods.""" + + def test_upload_with_custom_timeout( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test that custom timeout is accepted in sync upload.""" + content = "Test content" + file_name = "test.txt" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + # Pass custom timeout - this exercises the timeout parameter path + attachment_key = service.upload( + name=file_name, + content=content, + timeout=120.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response["Id"]) + + def test_upload_with_file_path_custom_timeout( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_file: Tuple[str, str, str], + blob_uri_response: dict[str, Any], + ) -> None: + """Test that custom timeout is accepted in sync upload from file.""" + content, file_name, file_path = temp_file + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + attachment_key = service.upload( + name=file_name, + source_path=file_path, + timeout=90.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response["Id"]) + + @pytest.mark.asyncio + async def test_upload_async_with_custom_timeout( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test that custom timeout is accepted in async upload.""" + content = "Test content async" + file_name = "test_async.txt" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + attachment_key = await service.upload_async( + name=file_name, + content=content, + timeout=150.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response["Id"]) + + @pytest.mark.asyncio + async def test_upload_async_with_file_path_custom_timeout( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_file: Tuple[str, str, str], + blob_uri_response: dict[str, Any], + ) -> None: + """Test that custom timeout is accepted in async upload from file.""" + content, file_name, file_path = temp_file + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + attachment_key = await service.upload_async( + name=file_name, + source_path=file_path, + timeout=180.0, + ) + + assert attachment_key == uuid.UUID(blob_uri_response["Id"]) + + class TestAttachmentsServiceRequiresAuth: """Tests for uploads when RequiresAuth is True (uses self.request with timeout)."""