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..e1b6bc9fc 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. @@ -556,10 +560,16 @@ 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(**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 @@ -568,9 +578,15 @@ 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()) 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 +599,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 +610,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 +626,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 +639,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. @@ -692,11 +712,19 @@ 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: - 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 @@ -705,11 +733,17 @@ 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: - 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 diff --git a/packages/uipath-platform/tests/services/test_attachments_service.py b/packages/uipath-platform/tests/services/test_attachments_service.py index 8e7b6aaa0..7a060ccd2 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,326 @@ def test_attachments_service_conforms_to_attachments_protocol( conforming: AttachmentsProtocol = service 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).""" + + 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" 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