diff --git a/src/codeocean/__init__.py b/src/codeocean/__init__.py index 7650029..affb630 100644 --- a/src/codeocean/__init__.py +++ b/src/codeocean/__init__.py @@ -1 +1,2 @@ from codeocean.client import CodeOcean # noqa: F401 +from codeocean.error import Error # noqa: F401 diff --git a/src/codeocean/client.py b/src/codeocean/client.py index e3b3b6c..33a42ba 100644 --- a/src/codeocean/client.py +++ b/src/codeocean/client.py @@ -5,10 +5,12 @@ from requests_toolbelt.sessions import BaseUrlSession from typing import Optional from urllib3.util import Retry +import requests from codeocean.capsule import Capsules from codeocean.computation import Computations from codeocean.data_asset import DataAssets +from codeocean.error import Error @dataclass @@ -45,11 +47,15 @@ def __post_init__(self): }) if self.agent_id: self.session.headers.update({"Agent-Id": self.agent_id}) - self.session.hooks["response"] = [ - lambda response, *args, **kwargs: response.raise_for_status() - ] + self.session.hooks["response"] = [self._error_handler] self.session.mount(self.domain, TCPKeepAliveAdapter(max_retries=self.retries)) self.capsules = Capsules(client=self.session) self.computations = Computations(client=self.session) self.data_assets = DataAssets(client=self.session) + + def _error_handler(self, response, *args, **kwargs): + try: + response.raise_for_status() + except requests.HTTPError as err: + raise Error(err) from err diff --git a/src/codeocean/error.py b/src/codeocean/error.py new file mode 100644 index 0000000..d6e5e36 --- /dev/null +++ b/src/codeocean/error.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +import requests + + +class Error(Exception): + """ + Represents an HTTP error with additional context extracted from the response. + + Attributes: + http_err (requests.HTTPError): The HTTP error object. + status_code (int): The HTTP status code of the error response. + message (str): A message describing the error, extracted from the response body. + data (Any): If the response body is json, this attribute contains the json object; otherwise, it is None. + + Args: + err (requests.HTTPError): The HTTP error object. + """ + def __init__(self, err: requests.HTTPError): + self.http_err = err + self.status_code = err.response.status_code + self.message = "An error occurred." + self.data = None + + try: + self.data = err.response.json() + if isinstance(self.data, dict): + self.message = self.data.get("message", self.message) + except Exception: + # response wasn't JSON – fall back to text + self.message = err.response.text + + super().__init__(self.message) + + def __str__(self) -> str: + msg = str(self.http_err) + msg += f"\n\nMessage: {self.message}" + if self.data: + msg += "\n\nData:\n" + json.dumps(self.data, indent=2) + return msg diff --git a/tests/test_error.py b/tests/test_error.py new file mode 100644 index 0000000..b1a06de --- /dev/null +++ b/tests/test_error.py @@ -0,0 +1,140 @@ +import unittest +from unittest.mock import Mock +import requests + +from codeocean.error import Error + + +class TestError(unittest.TestCase): + """Test cases for the Error exception class.""" + + def test_error_is_exception_subclass(self): + """Test that Error is a subclass of Exception.""" + self.assertTrue(issubclass(Error, Exception)) + + def test_error_with_json_dict(self): + """Test Error creation with JSON dict response containing message.""" + # Create mock HTTPError and response + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = {"message": "Custom error message", "datasets": [{"id": "123", "name": "tv"}]} + + mock_http_error = Mock(spec=requests.HTTPError) + mock_http_error.response = mock_response + + # Create Error instance + error = Error(mock_http_error) + + # Verify attributes + self.assertEqual(error.status_code, 400) + self.assertEqual(error.message, "Custom error message") + self.assertEqual(error.data, {"message": "Custom error message", "datasets": [{"id": "123", "name": "tv"}]}) + + def test_error_with_json_dict_no_message(self): + """Test Error creation with JSON dict response without message field.""" + # Create mock HTTPError and response + mock_response = Mock() + mock_response.status_code = 500 + mock_response.json.return_value = {"error": "some other field"} + + mock_http_error = Mock(spec=requests.HTTPError) + mock_http_error.response = mock_response + + # Create Error instance + error = Error(mock_http_error) + + # Verify attributes + self.assertEqual(error.status_code, 500) + self.assertEqual(error.message, "An error occurred.") + self.assertEqual(error.data, {"error": "some other field"}) + + def test_error_with_json_list(self): + """Test Error creation with JSON list response.""" + # Create mock HTTPError and response + mock_response = Mock() + mock_response.status_code = 403 + mock_response.json.return_value = [{"field": "error1"}, {"field": "error2"}] + + mock_http_error = Mock(spec=requests.HTTPError) + mock_http_error.response = mock_response + + # Create Error instance + error = Error(mock_http_error) + + # Verify attributes + self.assertEqual(error.status_code, 403) + self.assertEqual(error.message, "An error occurred.") + self.assertEqual(error.data, [{"field": "error1"}, {"field": "error2"}]) + + def test_error_with_non_json_response(self): + """Test Error creation when response is not JSON.""" + # Create mock HTTPError and response + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.side_effect = Exception("Not JSON") + mock_response.text = "Page not found" + + mock_http_error = Mock(spec=requests.HTTPError) + mock_http_error.response = mock_response + + # Create Error instance + error = Error(mock_http_error) + + # Verify attributes + self.assertEqual(error.status_code, 404) + self.assertEqual(error.message, "Page not found") + self.assertIsNone(error.data) + + def test_error_str_method_with_data(self): + """Test Error __str__ method when data is present.""" + # Create mock HTTPError and response + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = {"message": "Validation failed", "errors": ["field1", "field2"]} + + mock_http_error = Mock(spec=requests.HTTPError) + mock_http_error.response = mock_response + mock_http_error.__str__ = Mock(return_value="400 Client Error: Bad Request for url: http://example.com") + + # Create Error instance + error = Error(mock_http_error) + + # Test __str__ method + error_str = str(error) + + # Verify the string contains expected components + self.assertEqual(error_str, """400 Client Error: Bad Request for url: http://example.com + +Message: Validation failed + +Data: +{ + "message": "Validation failed", + "errors": [ + "field1", + "field2" + ] +}""") + + def test_error_str_method_without_data(self): + """Test Error __str__ method when data is None.""" + # Create mock HTTPError and response + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.side_effect = Exception("Not JSON") + mock_response.text = "Page not found" + + mock_http_error = Mock(spec=requests.HTTPError) + mock_http_error.response = mock_response + mock_http_error.__str__ = Mock(return_value="404 Client Error: Not Found for url: http://example.com") + + # Create Error instance + error = Error(mock_http_error) + + # Test __str__ method + error_str = str(error) + + # Verify the string contains expected components + self.assertEqual(error_str, """404 Client Error: Not Found for url: http://example.com + +Message: Page not found""")