From d7b8619b91abddcdb018eb995dba7f945c4cb24f Mon Sep 17 00:00:00 2001 From: humbertonc Date: Thu, 16 Jul 2026 13:58:42 -0300 Subject: [PATCH 1/3] Add: Added timeout for request calls in Music AI --- musicai_sdk/client.py | 102 +++++++++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 30 deletions(-) diff --git a/musicai_sdk/client.py b/musicai_sdk/client.py index 9e794e4..2df6a98 100644 --- a/musicai_sdk/client.py +++ b/musicai_sdk/client.py @@ -12,6 +12,15 @@ get_user_agent, ) +# Sentinel used to distinguish "no per-call timeout given" (fall back to the +# instance default) from an explicit `timeout=None` (disable the timeout). +class _Unset: + def __repr__(self): + return "UNSET" + + +_UNSET = _Unset() + class MusicAiClient: def __init__( @@ -20,12 +29,14 @@ def __init__( job_monitor_interval=2, save_output_to_folder=True, user_agent=None, + timeout=None, ): self.api_key = api_key self.base_url = "https://api.music.ai/v1" self.job_monitor_interval = job_monitor_interval self.save_output_to_folder = save_output_to_folder self.user_agent = user_agent or get_user_agent() + self.timeout = timeout def get_headers(self): return { @@ -33,24 +44,38 @@ def get_headers(self): "User-Agent": self.user_agent, } - def upload_file(self, file_path): - response = requests.get(f"{self.base_url}/upload", headers=self.get_headers()) + def _request(self, method, url, timeout=_UNSET, **kwargs): + resolved_timeout = self.timeout if timeout is _UNSET else timeout + return requests.request(method, url, timeout=resolved_timeout, **kwargs) + + def upload_file(self, file_path, timeout=_UNSET): + response = self._request( + "get", + f"{self.base_url}/upload", + headers=self.get_headers(), + timeout=timeout, + ) if response.status_code // 100 != 2: raise HTTPError( f"Error uploading file: {response.status_code} {response.text}" ) upload_url = response.json()["uploadUrl"] with open(file_path, "rb") as file: - put_response = requests.put(upload_url, data=file.read()) + put_response = self._request( + "put", upload_url, data=file.read(), timeout=timeout + ) if put_response.status_code // 100 != 2: raise HTTPError( f"Error uploading file to provided URL: {put_response.status_code} {put_response.text}" ) return response.json()["downloadUrl"] - def get_job(self, job_id): - response = requests.get( - f"{self.base_url}/job/{job_id}", headers=self.get_headers() + def get_job(self, job_id, timeout=_UNSET): + response = self._request( + "get", + f"{self.base_url}/job/{job_id}", + headers=self.get_headers(), + timeout=timeout, ) if response.status_code // 100 != 2: raise HTTPError( @@ -58,9 +83,12 @@ def get_job(self, job_id): ) return response.json() - def get_job_status(self, job_id): - response = requests.get( - f"{self.base_url}/job/{job_id}/status", headers=self.get_headers() + def get_job_status(self, job_id, timeout=_UNSET): + response = self._request( + "get", + f"{self.base_url}/job/{job_id}/status", + headers=self.get_headers(), + timeout=timeout, ) if response.status_code // 100 != 2: raise HTTPError( @@ -68,7 +96,7 @@ def get_job_status(self, job_id): ) return response.json() - def list_jobs(self, filters=None): + def list_jobs(self, filters=None, timeout=_UNSET): params = {} if filters: @@ -84,7 +112,9 @@ def list_jobs(self, filters=None): if query_string else f"{self.base_url}/job" ) - response = requests.get(url, headers=self.get_headers()) + response = self._request( + "get", url, headers=self.get_headers(), timeout=timeout + ) if response.status_code // 100 != 2: raise HTTPError( @@ -93,7 +123,7 @@ def list_jobs(self, filters=None): return response.json() - def add_job(self, job_name, workflow_slug, params, **options): + def add_job(self, job_name, workflow_slug, params, timeout=_UNSET, **options): data = { "name": job_name, "workflow": workflow_slug, @@ -106,8 +136,12 @@ def add_job(self, job_name, workflow_slug, params, **options): if "copy_results_to" in options: data["copyResultsTo"] = options["copy_results_to"] - response = requests.post( - f"{self.base_url}/job", headers=self.get_headers(), json=data + response = self._request( + "post", + f"{self.base_url}/job", + headers=self.get_headers(), + json=data, + timeout=timeout, ) if response.status_code // 100 != 2: raise HTTPError( @@ -115,9 +149,12 @@ def add_job(self, job_name, workflow_slug, params, **options): ) return response.json() - def delete_job(self, job_id): - response = requests.delete( - f"{self.base_url}/job/{job_id}", headers=self.get_headers() + def delete_job(self, job_id, timeout=_UNSET): + response = self._request( + "delete", + f"{self.base_url}/job/{job_id}", + headers=self.get_headers(), + timeout=timeout, ) if response.status_code // 100 != 2: raise HTTPError( @@ -125,7 +162,7 @@ def delete_job(self, job_id): ) return response.json() - def list_workflows(self, filters=None): + def list_workflows(self, filters=None, timeout=_UNSET): params = {} if filters: @@ -141,7 +178,9 @@ def list_workflows(self, filters=None): if query_string else f"{self.base_url}/workflow" ) - response = requests.get(url, headers=self.get_headers()) + response = self._request( + "get", url, headers=self.get_headers(), timeout=timeout + ) if response.status_code // 100 != 2: raise HTTPError( @@ -150,16 +189,19 @@ def list_workflows(self, filters=None): return response.json() - def wait_for_job_completion(self, id): + def wait_for_job_completion(self, id, timeout=_UNSET): while True: - job = self.get_job_status(id) + job = self.get_job_status(id, timeout=timeout) if job["status"] in ["SUCCEEDED", "FAILED"]: - return self.get_job(id) + return self.get_job(id, timeout=timeout) time.sleep(self.job_monitor_interval), - def get_application_info(self): - response = requests.get( - f"{self.base_url}/application", headers=self.get_headers() + def get_application_info(self, timeout=_UNSET): + response = self._request( + "get", + f"{self.base_url}/application", + headers=self.get_headers(), + timeout=timeout, ) if response.status_code // 100 != 2: raise HTTPError( @@ -167,9 +209,9 @@ def get_application_info(self): ) return response.json() - def download_job_results(self, job_id_or_job_data, output_dir): + def download_job_results(self, job_id_or_job_data, output_dir, timeout=_UNSET): job = ( - self.get_job(job_id_or_job_data) + self.get_job(job_id_or_job_data, timeout=timeout) if isinstance(job_id_or_job_data, str) else job_id_or_job_data ) @@ -192,7 +234,7 @@ def download_job_results(self, job_id_or_job_data, output_dir): else extract_name_from_url(value) ) download_destination = os.path.join(output_dir, file_name) - self.download_file(value, download_destination) + self.download_file(value, download_destination, timeout=timeout) download_result[result] = download_destination result_json_downloads[result] = f"./{file_name}" @@ -202,9 +244,9 @@ def download_job_results(self, job_id_or_job_data, output_dir): return download_result - def download_file(self, url, file_destination): + def download_file(self, url, file_destination, timeout=_UNSET): os.makedirs(os.path.dirname(file_destination), exist_ok=True) - response = requests.get(url) + response = self._request("get", url, timeout=timeout) if response.status_code // 100 != 2: raise HTTPError( f"Error downloading file: {response.status_code} {response.text}" From 243238c34780c9e7ef3e2caf1983eefbf5772785 Mon Sep 17 00:00:00 2001 From: humbertonc Date: Fri, 17 Jul 2026 11:08:44 -0300 Subject: [PATCH 2/3] Add: Integration tests for new timeout functionality --- tests/test_client.py | 61 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index 9d4644a..cea49e7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -241,4 +241,63 @@ def test_get_application_info(self, client): assert isinstance(result, dict) assert "id" in result assert re.match(UUID_REGEX, result["id"]) - assert "name" in result + assert "name" in result + + def test_request_with_per_call_timeout(self, client): + """Test that a per-call timeout still allows successful requests""" + # Act + result = client.get_application_info(timeout=30) + + # Assert + assert isinstance(result, dict) + assert "id" in result + assert re.match(UUID_REGEX, result["id"]) + + def test_request_with_client_timeout(self, client): + """Test that the client-level timeout is applied to requests""" + # Arrange + client.timeout = 30 + + # Act + result = client.get_application_info() + + # Assert + assert isinstance(result, dict) + assert "id" in result + assert re.match(UUID_REGEX, result["id"]) + + def test_short_per_call_timeout_raises(self, client): + """Test that an overly short per-call timeout raises Timeout""" + with pytest.raises(requests.exceptions.Timeout): + client.get_application_info(timeout=0.0001) + + def test_short_client_timeout_raises(self, client): + """Test that an overly short client-level timeout raises Timeout""" + # Arrange + client.timeout = 0.0001 + + # Act / Assert + with pytest.raises(requests.exceptions.Timeout): + client.get_application_info() + + def test_per_call_timeout_overrides_client_timeout(self, client): + """Test that an explicit per-call timeout overrides the client default""" + # Arrange — client default would time out immediately + client.timeout = 0.0001 + + # Act — per-call timeout disables the timeout + result = client.get_application_info(timeout=None) + + # Assert + assert isinstance(result, dict) + assert "id" in result + assert re.match(UUID_REGEX, result["id"]) + + def test_per_call_short_timeout_overrides_client_timeout(self, client): + """Test that a short per-call timeout overrides a generous client default""" + # Arrange + client.timeout = 30 + + # Act / Assert + with pytest.raises(requests.exceptions.Timeout): + client.get_application_info(timeout=0.0001) From 135e7e916d3d3a5ac44c424dfc2a51abde06f9cc Mon Sep 17 00:00:00 2001 From: humbertonc Date: Fri, 17 Jul 2026 11:09:51 -0300 Subject: [PATCH 3/3] Add: Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index da8d5f4..c0d1551 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "musicai-sdk" -version = "1.0.3" +version = "1.0.4" description = "Python SDK for Music.AI" authors = ["Music.ai Support "] license = "MIT"