diff --git a/src/runwayml/_base_client.py b/src/runwayml/_base_client.py index 5845965..daebc09 100644 --- a/src/runwayml/_base_client.py +++ b/src/runwayml/_base_client.py @@ -930,6 +930,38 @@ def _prepare_request( """ return None + def _send_external_multipart_request( + self, + *, + url: str, + data: Mapping[str, object], + files: HttpxRequestFiles, + timeout: float | Timeout | None, + ) -> httpx.Response: + request = httpx.Request("POST", self._prepare_url(url), data=data, files=files) + request.extensions = {**request.extensions, "timeout": Timeout(timeout).as_dict()} + + log.debug("Sending external HTTP Request: %s %s", request.method, request.url) + + try: + response = self._client.send(request, auth=None) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + return response + @overload def request( self, @@ -1509,6 +1541,38 @@ async def _prepare_request( """ return None + async def _send_external_multipart_request( + self, + *, + url: str, + data: Mapping[str, object], + files: HttpxRequestFiles, + timeout: float | Timeout | None, + ) -> httpx.Response: + request = httpx.Request("POST", self._prepare_url(url), data=data, files=files) + request.extensions = {**request.extensions, "timeout": Timeout(timeout).as_dict()} + + log.debug("Sending external HTTP Request: %s %s", request.method, request.url) + + try: + response = await self._client.send(request, auth=None) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) + return response + @overload async def request( self, diff --git a/src/runwayml/resources/uploads.py b/src/runwayml/resources/uploads.py index 27c2898..539dfa1 100644 --- a/src/runwayml/resources/uploads.py +++ b/src/runwayml/resources/uploads.py @@ -207,13 +207,13 @@ def create_ephemeral( form_data = _prepare_upload_data(upload_placeholder.fields, file_metadata) upload_timeout = _get_upload_timeout(timeout, self._client.timeout) - with httpx.Client(timeout=upload_timeout) as client: - response = client.post( - upload_placeholder.uploadUrl, - data=form_data, - files={"file": file_tuple}, - ) - _handle_upload_errors(response, upload_placeholder.uploadUrl) + response = self._client._send_external_multipart_request( + url=upload_placeholder.uploadUrl, + data=form_data, + files={"file": file_tuple}, + timeout=upload_timeout, + ) + _handle_upload_errors(response, upload_placeholder.uploadUrl) return UploadCreateEphemeralResponse(uri=upload_placeholder.runwayUri) @@ -289,13 +289,13 @@ async def create_ephemeral( form_data = _prepare_upload_data(upload_placeholder.fields, file_metadata) upload_timeout = _get_upload_timeout(timeout, self._client.timeout) - async with httpx.AsyncClient(timeout=upload_timeout) as client: - response = await client.post( - upload_placeholder.uploadUrl, - data=form_data, - files={"file": file_tuple}, - ) - _handle_upload_errors(response, upload_placeholder.uploadUrl) + response = await self._client._send_external_multipart_request( + url=upload_placeholder.uploadUrl, + data=form_data, + files={"file": file_tuple}, + timeout=upload_timeout, + ) + _handle_upload_errors(response, upload_placeholder.uploadUrl) return UploadCreateEphemeralResponse(uri=upload_placeholder.runwayUri) diff --git a/tests/api_resources/test_uploads.py b/tests/api_resources/test_uploads.py index 7086558..4af16cc 100644 --- a/tests/api_resources/test_uploads.py +++ b/tests/api_resources/test_uploads.py @@ -14,6 +14,7 @@ from runwayml import RunwayML, AsyncRunwayML base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "My API Key" sample_file = Path(__file__).parent.parent / "sample_file.txt" @@ -70,6 +71,40 @@ def test_create_ephemeral_accepts_valid_files( if file_type == "file_object": file.close() + def test_create_ephemeral_reuses_configured_http_client_for_storage_upload(self) -> None: + seen_urls: list[str] = [] + + def mock_handler(request: httpx.Request) -> httpx.Response: + request_url = str(request.url) + seen_urls.append(request_url) + + if request_url == f"{base_url}/v1/uploads": + assert request.headers["Authorization"] == f"Bearer {api_key}" + return httpx.Response( + 200, + json={ + "runwayUri": "runway://upload/test123", + "uploadUrl": "https://storage.example.com/upload", + "fields": {"key": "value"}, + }, + ) + + assert request_url == "https://storage.example.com/upload" + assert "Authorization" not in request.headers + assert "X-Runway-Version" not in request.headers + return httpx.Response(204) + + with RunwayML( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=httpx.MockTransport(handler=mock_handler)), + ) as client: + response = client.uploads.create_ephemeral(file=("test.txt", b"content")) + + assert response.uri == "runway://upload/test123" + assert seen_urls == [f"{base_url}/v1/uploads", "https://storage.example.com/upload"] + class TestAsyncUploads: @pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) @@ -123,3 +158,37 @@ async def test_create_ephemeral_accepts_valid_files( finally: if file_type == "file_object": file.close() + + async def test_create_ephemeral_reuses_configured_http_client_for_storage_upload(self) -> None: + seen_urls: list[str] = [] + + async def mock_handler(request: httpx.Request) -> httpx.Response: + request_url = str(request.url) + seen_urls.append(request_url) + + if request_url == f"{base_url}/v1/uploads": + assert request.headers["Authorization"] == f"Bearer {api_key}" + return httpx.Response( + 200, + json={ + "runwayUri": "runway://upload/test123", + "uploadUrl": "https://storage.example.com/upload", + "fields": {"key": "value"}, + }, + ) + + assert request_url == "https://storage.example.com/upload" + assert "Authorization" not in request.headers + assert "X-Runway-Version" not in request.headers + return httpx.Response(204) + + async with AsyncRunwayML( + base_url=base_url, + api_key=api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler=mock_handler)), + ) as client: + response = await client.uploads.create_ephemeral(file=("test.txt", b"content")) + + assert response.uri == "runway://upload/test123" + assert seen_urls == [f"{base_url}/v1/uploads", "https://storage.example.com/upload"]