From 6096c156de58352e2b25b55a1df60d44c46f857e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:42:22 +0000 Subject: [PATCH 1/8] Initial plan From 55ffbb035aa49a53fc0ebd9a79c794d3624915d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:46:18 +0000 Subject: [PATCH 2/8] Print human-readable error messages with suggestions when API calls fail Co-authored-by: VsevolodX <79542055+VsevolodX@users.noreply.github.com> --- src/py/mat3ra/api_client/settings.py | 41 +++++++++++++++++ src/py/mat3ra/api_client/utils/http.py | 12 ++++- tests/py/unit/test_httpBase.py | 61 ++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/py/mat3ra/api_client/settings.py diff --git a/src/py/mat3ra/api_client/settings.py b/src/py/mat3ra/api_client/settings.py new file mode 100644 index 0000000..fe4562a --- /dev/null +++ b/src/py/mat3ra/api_client/settings.py @@ -0,0 +1,41 @@ +from typing import Dict, Tuple + +# Mapping of HTTP status codes to (display_text, suggestion) +HTTP_ERROR_MAP: Dict[int, Tuple[str, str]] = { + 400: ( + "Bad Request", + "Check your request parameters and data.", + ), + 401: ( + "Unauthorized", + "Check your authentication token or log in again.", + ), + 403: ( + "Forbidden", + "You do not have permission to access this resource. Set the correct project or check your account permissions.", + ), + 404: ( + "Not Found", + "The requested resource does not exist. Check the ID or path.", + ), + 409: ( + "Conflict", + "A resource with this identifier already exists.", + ), + 422: ( + "Unprocessable Entity", + "The request data is invalid. Check your input.", + ), + 429: ( + "Too Many Requests", + "You have exceeded your quota. Update your quota or try again later.", + ), + 500: ( + "Internal Server Error", + "An error occurred on the server. Contact support if the problem persists.", + ), + 503: ( + "Service Unavailable", + "The service is temporarily unavailable. Try again later.", + ), +} diff --git a/src/py/mat3ra/api_client/utils/http.py b/src/py/mat3ra/api_client/utils/http.py index 73b80de..076431d 100644 --- a/src/py/mat3ra/api_client/utils/http.py +++ b/src/py/mat3ra/api_client/utils/http.py @@ -1,6 +1,8 @@ import requests import urllib.parse +from mat3ra.api_client.settings import HTTP_ERROR_MAP + class BaseConnection(object): """ @@ -32,7 +34,15 @@ def request(self, method, url, params=None, data=None, headers=None): params (dict): URL parameters to append to the URL. """ self.response = self.session.request(method=method.lower(), url=url, params=params, data=data, headers=headers) - self.response.raise_for_status() + try: + self.response.raise_for_status() + except requests.HTTPError as e: + status_code = self.response.status_code + display_text, suggestion = HTTP_ERROR_MAP.get(status_code, ("HTTP Error", "")) + message = f"Error {status_code}: {display_text}." + if suggestion: + message += f" {suggestion}" + raise requests.HTTPError(message, response=self.response) from e def get_response(self): """ diff --git a/tests/py/unit/test_httpBase.py b/tests/py/unit/test_httpBase.py index be3a8dc..84fb1fd 100644 --- a/tests/py/unit/test_httpBase.py +++ b/tests/py/unit/test_httpBase.py @@ -8,6 +8,14 @@ API_VERSION_2 = "2018-10-2" HTTP_STATUS_UNAUTHORIZED = 401 HTTP_REASON_UNAUTHORIZED = "Unauthorized" +HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 +HTTP_REASON_INTERNAL_SERVER_ERROR = "Internal Server Error" +HTTP_STATUS_TOO_MANY_REQUESTS = 429 +HTTP_REASON_TOO_MANY_REQUESTS = "Too Many Requests" +HTTP_STATUS_FORBIDDEN = 403 +HTTP_REASON_FORBIDDEN = "Forbidden" +HTTP_STATUS_UNKNOWN = 418 +HTTP_REASON_UNKNOWN = "I'm a Teapot" EMPTY_CONTENT = "" TEST_ENTITY_ID = "28FMvD5knJZZx452H" EMPTY_USERNAME = "" @@ -41,3 +49,56 @@ def test_raise_http_error(self, mock_request): with self.assertRaises(HTTPError): conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) conn.request("POST", "login", data={"username": EMPTY_USERNAME, "password": EMPTY_PASSWORD}) + + @mock.patch("requests.sessions.Session.request") + def test_http_error_message_known_status(self, mock_request): + mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_UNAUTHORIZED, + reason=HTTP_REASON_UNAUTHORIZED) + with self.assertRaises(HTTPError) as ctx: + conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) + conn.request("POST", "login", data={"username": EMPTY_USERNAME, "password": EMPTY_PASSWORD}) + self.assertIn("Error 401", str(ctx.exception)) + self.assertIn("Unauthorized", str(ctx.exception)) + self.assertIn("authentication token", str(ctx.exception)) + + @mock.patch("requests.sessions.Session.request") + def test_http_error_message_500(self, mock_request): + mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_INTERNAL_SERVER_ERROR, + reason=HTTP_REASON_INTERNAL_SERVER_ERROR) + with self.assertRaises(HTTPError) as ctx: + conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) + conn.request("POST", "jobs/id/submit") + self.assertIn("Error 500", str(ctx.exception)) + self.assertIn("Internal Server Error", str(ctx.exception)) + self.assertIn("Contact support", str(ctx.exception)) + + @mock.patch("requests.sessions.Session.request") + def test_http_error_message_429_quota(self, mock_request): + mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_TOO_MANY_REQUESTS, + reason=HTTP_REASON_TOO_MANY_REQUESTS) + with self.assertRaises(HTTPError) as ctx: + conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) + conn.request("POST", "materials") + self.assertIn("Error 429", str(ctx.exception)) + self.assertIn("quota", str(ctx.exception)) + + @mock.patch("requests.sessions.Session.request") + def test_http_error_message_403_project(self, mock_request): + mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_FORBIDDEN, + reason=HTTP_REASON_FORBIDDEN) + with self.assertRaises(HTTPError) as ctx: + conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) + conn.request("GET", "workflows") + self.assertIn("Error 403", str(ctx.exception)) + self.assertIn("project", str(ctx.exception)) + + @mock.patch("requests.sessions.Session.request") + def test_http_error_message_unknown_status(self, mock_request): + mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_UNKNOWN, + reason=HTTP_REASON_UNKNOWN) + with self.assertRaises(HTTPError) as ctx: + conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) + conn.request("GET", "materials") + self.assertIn("Error 418", str(ctx.exception)) + self.assertIn("HTTP Error", str(ctx.exception)) + From 65eb99cf05d5aa38f7fbb184252ce2825878dd16 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 5 Mar 2026 22:09:03 -0800 Subject: [PATCH 3/8] update: set messages --- src/py/mat3ra/api_client/settings.py | 10 +++++++--- src/py/mat3ra/api_client/utils/http.py | 13 ++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/py/mat3ra/api_client/settings.py b/src/py/mat3ra/api_client/settings.py index fe4562a..6cc53b4 100644 --- a/src/py/mat3ra/api_client/settings.py +++ b/src/py/mat3ra/api_client/settings.py @@ -8,15 +8,19 @@ ), 401: ( "Unauthorized", - "Check your authentication token or log in again.", + "Check your authentication credentials or run `mat3ra login` to re-authenticate.", + ), + 402: ( + "Payment Required", + "Insufficient account balance. Please make a payment or reduce the job time limit.", ), 403: ( "Forbidden", - "You do not have permission to access this resource. Set the correct project or check your account permissions.", + "You do not have permission. Ensure the correct project is set and your account has the required permissions.", ), 404: ( "Not Found", - "The requested resource does not exist. Check the ID or path.", + "The requested resource does not exist. Verify the ID or path, and ensure the correct project is set.", ), 409: ( "Conflict", diff --git a/src/py/mat3ra/api_client/utils/http.py b/src/py/mat3ra/api_client/utils/http.py index 076431d..32862ed 100644 --- a/src/py/mat3ra/api_client/utils/http.py +++ b/src/py/mat3ra/api_client/utils/http.py @@ -4,6 +4,15 @@ from mat3ra.api_client.settings import HTTP_ERROR_MAP +def _extract_server_message(response: requests.Response) -> str: + """Extract human-readable message from a JSEND-formatted error response body.""" + try: + body = response.json() + return body.get("data", {}).get("message") or body.get("message") or "" + except Exception: + return "" + + class BaseConnection(object): """ Base connection class to inherit from. This class should not be instantiated directly. @@ -39,7 +48,9 @@ def request(self, method, url, params=None, data=None, headers=None): except requests.HTTPError as e: status_code = self.response.status_code display_text, suggestion = HTTP_ERROR_MAP.get(status_code, ("HTTP Error", "")) - message = f"Error {status_code}: {display_text}." + server_message = _extract_server_message(self.response) + detail = server_message or display_text + message = f"Error {status_code}: {detail}." if suggestion: message += f" {suggestion}" raise requests.HTTPError(message, response=self.response) from e From 4c96ec7cd6bd9b1a34c047443689d66bbac234d1 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Thu, 5 Mar 2026 22:19:58 -0800 Subject: [PATCH 4/8] update: only message --- src/py/mat3ra/api_client/utils/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/mat3ra/api_client/utils/http.py b/src/py/mat3ra/api_client/utils/http.py index 32862ed..a04b164 100644 --- a/src/py/mat3ra/api_client/utils/http.py +++ b/src/py/mat3ra/api_client/utils/http.py @@ -53,7 +53,7 @@ def request(self, method, url, params=None, data=None, headers=None): message = f"Error {status_code}: {detail}." if suggestion: message += f" {suggestion}" - raise requests.HTTPError(message, response=self.response) from e + raise requests.HTTPError(message, response=self.response) from None def get_response(self): """ From ac46a845c4c6829dc50fa6957c983ee6c5c5718b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 6 Mar 2026 16:36:26 -0800 Subject: [PATCH 5/8] update: fixes --- src/py/mat3ra/api_client/utils/http.py | 2 +- tests/py/unit/test_httpBase.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/mat3ra/api_client/utils/http.py b/src/py/mat3ra/api_client/utils/http.py index a04b164..297f45c 100644 --- a/src/py/mat3ra/api_client/utils/http.py +++ b/src/py/mat3ra/api_client/utils/http.py @@ -45,7 +45,7 @@ def request(self, method, url, params=None, data=None, headers=None): self.response = self.session.request(method=method.lower(), url=url, params=params, data=data, headers=headers) try: self.response.raise_for_status() - except requests.HTTPError as e: + except requests.HTTPError: status_code = self.response.status_code display_text, suggestion = HTTP_ERROR_MAP.get(status_code, ("HTTP Error", "")) server_message = _extract_server_message(self.response) diff --git a/tests/py/unit/test_httpBase.py b/tests/py/unit/test_httpBase.py index 84fb1fd..1c997c4 100644 --- a/tests/py/unit/test_httpBase.py +++ b/tests/py/unit/test_httpBase.py @@ -59,7 +59,7 @@ def test_http_error_message_known_status(self, mock_request): conn.request("POST", "login", data={"username": EMPTY_USERNAME, "password": EMPTY_PASSWORD}) self.assertIn("Error 401", str(ctx.exception)) self.assertIn("Unauthorized", str(ctx.exception)) - self.assertIn("authentication token", str(ctx.exception)) + self.assertIn("authentication credentials", str(ctx.exception)) @mock.patch("requests.sessions.Session.request") def test_http_error_message_500(self, mock_request): From 32cf0862c5f22ec58b915f328ddbe59448d4b2fb Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 9 Mar 2026 15:49:34 -0700 Subject: [PATCH 6/8] update: remove redundand --- src/py/mat3ra/api_client/settings.py | 45 -------------------------- src/py/mat3ra/api_client/utils/http.py | 7 +--- 2 files changed, 1 insertion(+), 51 deletions(-) delete mode 100644 src/py/mat3ra/api_client/settings.py diff --git a/src/py/mat3ra/api_client/settings.py b/src/py/mat3ra/api_client/settings.py deleted file mode 100644 index 6cc53b4..0000000 --- a/src/py/mat3ra/api_client/settings.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Dict, Tuple - -# Mapping of HTTP status codes to (display_text, suggestion) -HTTP_ERROR_MAP: Dict[int, Tuple[str, str]] = { - 400: ( - "Bad Request", - "Check your request parameters and data.", - ), - 401: ( - "Unauthorized", - "Check your authentication credentials or run `mat3ra login` to re-authenticate.", - ), - 402: ( - "Payment Required", - "Insufficient account balance. Please make a payment or reduce the job time limit.", - ), - 403: ( - "Forbidden", - "You do not have permission. Ensure the correct project is set and your account has the required permissions.", - ), - 404: ( - "Not Found", - "The requested resource does not exist. Verify the ID or path, and ensure the correct project is set.", - ), - 409: ( - "Conflict", - "A resource with this identifier already exists.", - ), - 422: ( - "Unprocessable Entity", - "The request data is invalid. Check your input.", - ), - 429: ( - "Too Many Requests", - "You have exceeded your quota. Update your quota or try again later.", - ), - 500: ( - "Internal Server Error", - "An error occurred on the server. Contact support if the problem persists.", - ), - 503: ( - "Service Unavailable", - "The service is temporarily unavailable. Try again later.", - ), -} diff --git a/src/py/mat3ra/api_client/utils/http.py b/src/py/mat3ra/api_client/utils/http.py index 297f45c..c91de1e 100644 --- a/src/py/mat3ra/api_client/utils/http.py +++ b/src/py/mat3ra/api_client/utils/http.py @@ -1,8 +1,6 @@ import requests import urllib.parse -from mat3ra.api_client.settings import HTTP_ERROR_MAP - def _extract_server_message(response: requests.Response) -> str: """Extract human-readable message from a JSEND-formatted error response body.""" @@ -47,12 +45,9 @@ def request(self, method, url, params=None, data=None, headers=None): self.response.raise_for_status() except requests.HTTPError: status_code = self.response.status_code - display_text, suggestion = HTTP_ERROR_MAP.get(status_code, ("HTTP Error", "")) server_message = _extract_server_message(self.response) - detail = server_message or display_text + detail = server_message or "HTTP Error" message = f"Error {status_code}: {detail}." - if suggestion: - message += f" {suggestion}" raise requests.HTTPError(message, response=self.response) from None def get_response(self): From c2d2a46bb95d732b6666f238340a0d1774aefc3c Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 9 Mar 2026 15:51:24 -0700 Subject: [PATCH 7/8] update: test --- tests/py/unit/test_httpBase.py | 64 ++++++---------------------------- 1 file changed, 10 insertions(+), 54 deletions(-) diff --git a/tests/py/unit/test_httpBase.py b/tests/py/unit/test_httpBase.py index 1c997c4..1765318 100644 --- a/tests/py/unit/test_httpBase.py +++ b/tests/py/unit/test_httpBase.py @@ -7,19 +7,9 @@ API_VERSION_1 = "2018-10-1" API_VERSION_2 = "2018-10-2" HTTP_STATUS_UNAUTHORIZED = 401 -HTTP_REASON_UNAUTHORIZED = "Unauthorized" -HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 -HTTP_REASON_INTERNAL_SERVER_ERROR = "Internal Server Error" -HTTP_STATUS_TOO_MANY_REQUESTS = 429 -HTTP_REASON_TOO_MANY_REQUESTS = "Too Many Requests" -HTTP_STATUS_FORBIDDEN = 403 -HTTP_REASON_FORBIDDEN = "Forbidden" HTTP_STATUS_UNKNOWN = 418 -HTTP_REASON_UNKNOWN = "I'm a Teapot" EMPTY_CONTENT = "" -TEST_ENTITY_ID = "28FMvD5knJZZx452H" -EMPTY_USERNAME = "" -EMPTY_PASSWORD = "" +SERVER_MESSAGE = "Custom server error message" class HTTPBaseUnitTest(EndpointBaseUnitTest): @@ -44,58 +34,24 @@ def test_preamble_version(self): @mock.patch("requests.sessions.Session.request") def test_raise_http_error(self, mock_request): - mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_UNAUTHORIZED, - reason=HTTP_REASON_UNAUTHORIZED) + mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_UNAUTHORIZED) with self.assertRaises(HTTPError): conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) - conn.request("POST", "login", data={"username": EMPTY_USERNAME, "password": EMPTY_PASSWORD}) + conn.request("POST", "login") @mock.patch("requests.sessions.Session.request") - def test_http_error_message_known_status(self, mock_request): - mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_UNAUTHORIZED, - reason=HTTP_REASON_UNAUTHORIZED) + def test_http_error_message_with_server_message(self, mock_request): + response_body = {"message": SERVER_MESSAGE} + mock_request.return_value = self.mock_response(response_body, HTTP_STATUS_UNAUTHORIZED) with self.assertRaises(HTTPError) as ctx: conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) - conn.request("POST", "login", data={"username": EMPTY_USERNAME, "password": EMPTY_PASSWORD}) + conn.request("POST", "login") self.assertIn("Error 401", str(ctx.exception)) - self.assertIn("Unauthorized", str(ctx.exception)) - self.assertIn("authentication credentials", str(ctx.exception)) + self.assertIn(SERVER_MESSAGE, str(ctx.exception)) @mock.patch("requests.sessions.Session.request") - def test_http_error_message_500(self, mock_request): - mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_INTERNAL_SERVER_ERROR, - reason=HTTP_REASON_INTERNAL_SERVER_ERROR) - with self.assertRaises(HTTPError) as ctx: - conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) - conn.request("POST", "jobs/id/submit") - self.assertIn("Error 500", str(ctx.exception)) - self.assertIn("Internal Server Error", str(ctx.exception)) - self.assertIn("Contact support", str(ctx.exception)) - - @mock.patch("requests.sessions.Session.request") - def test_http_error_message_429_quota(self, mock_request): - mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_TOO_MANY_REQUESTS, - reason=HTTP_REASON_TOO_MANY_REQUESTS) - with self.assertRaises(HTTPError) as ctx: - conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) - conn.request("POST", "materials") - self.assertIn("Error 429", str(ctx.exception)) - self.assertIn("quota", str(ctx.exception)) - - @mock.patch("requests.sessions.Session.request") - def test_http_error_message_403_project(self, mock_request): - mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_FORBIDDEN, - reason=HTTP_REASON_FORBIDDEN) - with self.assertRaises(HTTPError) as ctx: - conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) - conn.request("GET", "workflows") - self.assertIn("Error 403", str(ctx.exception)) - self.assertIn("project", str(ctx.exception)) - - @mock.patch("requests.sessions.Session.request") - def test_http_error_message_unknown_status(self, mock_request): - mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_UNKNOWN, - reason=HTTP_REASON_UNKNOWN) + def test_http_error_message_without_server_message(self, mock_request): + mock_request.return_value = self.mock_response(EMPTY_CONTENT, HTTP_STATUS_UNKNOWN) with self.assertRaises(HTTPError) as ctx: conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) conn.request("GET", "materials") From ccba6e1d2305aa3a7774a45850f0c4d252f6949e Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 9 Mar 2026 15:53:58 -0700 Subject: [PATCH 8/8] update: test --- tests/py/unit/test_httpBase.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/py/unit/test_httpBase.py b/tests/py/unit/test_httpBase.py index 1765318..82c92ed 100644 --- a/tests/py/unit/test_httpBase.py +++ b/tests/py/unit/test_httpBase.py @@ -1,3 +1,4 @@ +import json from unittest import mock from mat3ra.api_client.utils.http import Connection @@ -10,6 +11,7 @@ HTTP_STATUS_UNKNOWN = 418 EMPTY_CONTENT = "" SERVER_MESSAGE = "Custom server error message" +SERVER_ERROR_RESPONSE = json.dumps({"message": SERVER_MESSAGE}) class HTTPBaseUnitTest(EndpointBaseUnitTest): @@ -41,8 +43,7 @@ def test_raise_http_error(self, mock_request): @mock.patch("requests.sessions.Session.request") def test_http_error_message_with_server_message(self, mock_request): - response_body = {"message": SERVER_MESSAGE} - mock_request.return_value = self.mock_response(response_body, HTTP_STATUS_UNAUTHORIZED) + mock_request.return_value = self.mock_response(SERVER_ERROR_RESPONSE, HTTP_STATUS_UNAUTHORIZED) with self.assertRaises(HTTPError) as ctx: conn = Connection(self.host, self.port, version=API_VERSION_1, secure=True) conn.request("POST", "login")