From 4eb4f1e58b595d31565fc31025551962aee087bd Mon Sep 17 00:00:00 2001 From: Alberto Daniel Badia Date: Tue, 27 Jan 2026 17:15:28 -0300 Subject: [PATCH 1/3] IMPROVE: Add support_info and support_context for error results details --- src/marketdata/client.py | 24 +++++-- src/marketdata/exceptions.py | 75 ++++++++++++++++++++-- src/marketdata/resources/options/quotes.py | 6 +- src/marketdata/sdk_error.py | 28 ++++++-- src/tests/test_api_error.py | 5 +- src/tests/test_exceptions.py | 38 ++++++++--- src/tests/test_markets_status.py | 2 +- src/tests/test_options_quotes.py | 2 +- src/tests/test_stocks_candles.py | 2 +- src/tests/test_stocks_earnings.py | 2 +- src/tests/test_stocks_news.py | 2 +- src/tests/test_stocks_prices.py | 2 +- src/tests/test_stocks_quotes.py | 2 +- 13 files changed, 154 insertions(+), 36 deletions(-) diff --git a/src/marketdata/client.py b/src/marketdata/client.py index 355921a..a5ea160 100644 --- a/src/marketdata/client.py +++ b/src/marketdata/client.py @@ -106,23 +106,39 @@ def _validate_status(response: Response): retry_status_codes and isinstance(retry_status_codes, int) and retry_status_codes == response.status_code, - RequestError(f"Request failed with: {_get_response_errmsg(response)}"), + RequestError( + message=f"Request failed with: {_get_response_errmsg(response)}", + request=response.request, + response=response, + ), ), ( retry_status_codes and isinstance(retry_status_codes, Callable) and retry_status_codes(response.status_code), - RequestError(f"Request failed with: {_get_response_errmsg(response)}"), + RequestError( + message=f"Request failed with: {_get_response_errmsg(response)}", + request=response.request, + response=response, + ), ), ( retry_status_codes and isinstance(retry_status_codes, list) and response.status_code in retry_status_codes, - RequestError(f"Request failed with: {_get_response_errmsg(response)}"), + RequestError( + message=_get_response_errmsg(response), + request=response.request, + response=response, + ), ), ( raise_for_status and not _validate_status(response), - BadStatusCodeError(_get_response_errmsg(response)), + BadStatusCodeError( + message=_get_response_errmsg(response), + request=response.request, + response=response, + ), ), ] for condition, exc in conditions_to_error: diff --git a/src/marketdata/exceptions.py b/src/marketdata/exceptions.py index f717ecb..e210fe2 100644 --- a/src/marketdata/exceptions.py +++ b/src/marketdata/exceptions.py @@ -1,25 +1,88 @@ """Exceptions for the MarketData Python SDK""" +from datetime import datetime -class RateLimitError(Exception): +from httpx import Request, Response +from pytz import timezone + + +class BaseMarketdataException(Exception): + + def __init__(self, message: str, timestamp: datetime | None = None): + super().__init__(message) + self.message = message + self.timestamp = timestamp or datetime.now(timezone("US/Eastern")).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + @property + def support_context(self) -> dict: + return dict( + timestamp=self.timestamp, + message=self.message, + exception_type=self.__class__.__name__, + ) + + +class MarketdataHttpError(BaseMarketdataException): + def __init__( + self, + message: str, + request: Request, + response: Response | None = None, + timestamp: datetime | None = None, + ): + super().__init__(message, timestamp) + self.request = request + self.response = response + + @property + def request_id(self) -> str: + if not self.response: + return "N/A" + return self.response.headers.get("cf-ray", "N/A") + + @property + def request_url(self) -> str: + return str(self.request.url) or "N/A" + + @property + def status_code(self) -> int: + if not self.response: + return 0 + return self.response.status_code or 0 + + @property + def support_context(self) -> dict: + return dict( + request_id=self.request_id, + request_url=self.request_url, + status_code=self.status_code, + timestamp=self.timestamp, + message=self.message, + exception_type=self.__class__.__name__, + ) + + +class BadStatusCodeError(MarketdataHttpError): pass -class KeywordOnlyArgumentError(Exception): +class RequestError(MarketdataHttpError): pass -class BadStatusCodeError(Exception): +class RateLimitError(BaseMarketdataException): pass -class RequestError(Exception): +class KeywordOnlyArgumentError(BaseMarketdataException): pass -class InvalidStatusDataError(Exception): +class InvalidStatusDataError(BaseMarketdataException): pass -class MinMaxDateValidationError(Exception): +class MinMaxDateValidationError(BaseMarketdataException): pass diff --git a/src/marketdata/resources/options/quotes.py b/src/marketdata/resources/options/quotes.py index 096ef95..f12ceca 100644 --- a/src/marketdata/resources/options/quotes.py +++ b/src/marketdata/resources/options/quotes.py @@ -93,7 +93,11 @@ def _parse_data(response: Response) -> dict: ) if not has_results: return MarketDataClientErrorResult( - error=RequestError("No responses from API") + error=RequestError( + message="No responses from API", + request=responses[0].request, + response=responses[0], + ) ) data = [_parse_data(response) for response in responses] diff --git a/src/marketdata/sdk_error.py b/src/marketdata/sdk_error.py index e86f5c8..e7c3556 100644 --- a/src/marketdata/sdk_error.py +++ b/src/marketdata/sdk_error.py @@ -1,7 +1,11 @@ from functools import wraps from typing import Callable -from marketdata.exceptions import MinMaxDateValidationError, RateLimitError +from marketdata.exceptions import ( + BaseMarketdataException, + MinMaxDateValidationError, + RateLimitError, +) from marketdata.resources.base import BaseResource @@ -10,15 +14,25 @@ class MarketDataClientErrorResult: error: Exception - def __init__(self, error: Exception): + def __init__(self, error: BaseMarketdataException): self.error = error + @property + def support_context(self) -> dict: + return self.error.support_context + + @property + def support_info(self) -> str: + lines = ["--- MARKET DATA SUPPORT INFO ---"] + for field, value in self.support_context.items(): + lines.append(f"{field}:\t\t{value}") + lines.append("--------------------------------") + return "\n".join(lines) + def __repr__(self) -> str: - error_name = self.error.__class__.__name__ - error_message = str(self.error) - return ( - f"MarketDataClientErrorResult(error={error_name}, message={error_message})" - ) + data = self.support_context + data_str = "\n".join([f"\t{k}={v}" for k, v in data.items()]) + return f"MarketDataClientErrorResult(\n{data_str}\n)" def __str__(self) -> str: return self.__repr__() diff --git a/src/tests/test_api_error.py b/src/tests/test_api_error.py index 261c42b..25e529f 100644 --- a/src/tests/test_api_error.py +++ b/src/tests/test_api_error.py @@ -1,6 +1,7 @@ from unittest.mock import patch import pytest +from httpx import Request, Response from marketdata.api_error import api_error_handler from marketdata.exceptions import RequestError @@ -11,7 +12,9 @@ class DummyResource(BaseResource): @api_error_handler def test_function_fails(self): - raise RequestError("test exception") + request = Request(method="GET", url="https://example.com") + response = Response(status_code=500) + raise RequestError("test exception", request=request, response=response) @patch( diff --git a/src/tests/test_exceptions.py b/src/tests/test_exceptions.py index 3078535..0b354a4 100644 --- a/src/tests/test_exceptions.py +++ b/src/tests/test_exceptions.py @@ -1,3 +1,8 @@ +from datetime import datetime + +from httpx import Request, Response +from pytz import timezone + from marketdata.exceptions import ( BadStatusCodeError, InvalidStatusDataError, @@ -7,7 +12,11 @@ RequestError, ) from marketdata.resources.base import BaseResource -from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions +from marketdata.sdk_error import ( + BaseMarketdataException, + MarketDataClientErrorResult, + handle_exceptions, +) class DummyResource(BaseResource): @@ -19,13 +28,12 @@ def sample_function(self, exception_to_raise: Exception | None = None) -> None: def test_client_error_result_str(): - error = Exception("test exception") + timestamp = datetime.now(timezone("US/Eastern")).strftime("%Y-%m-%d %H:%M:%S") + error = BaseMarketdataException("test exception", timestamp=timestamp) result = MarketDataClientErrorResult(error=error) assert isinstance(result, MarketDataClientErrorResult) - assert ( - str(result) - == "MarketDataClientErrorResult(error=Exception, message=test exception)" - ) + expected_msg = f"MarketDataClientErrorResult(\n\ttimestamp={timestamp}\n\tmessage=test exception\n\texception_type=BaseMarketdataException\n)" + assert str(result) == expected_msg def test_handle_exceptions(client): @@ -52,7 +60,13 @@ def test_handle_exceptions_rate_limit_error(client): def test_handle_exceptions_request_error(client): resource = DummyResource(client=client) - result = resource.sample_function(exception_to_raise=RequestError("test exception")) + result = resource.sample_function( + exception_to_raise=RequestError( + "test exception", + request=Request(method="GET", url="https://example.com"), + response=Response(status_code=429), + ) + ) assert isinstance(result, MarketDataClientErrorResult) assert result.error.args[0] == "test exception" @@ -60,10 +74,14 @@ def test_handle_exceptions_request_error(client): def test_handle_exceptions_bad_status_code_error(client): resource = DummyResource(client=client) result = resource.sample_function( - exception_to_raise=BadStatusCodeError("test exception") + exception_to_raise=BadStatusCodeError( + "test exception", + request=Request(method="GET", url="https://example.com"), + response=Response(status_code=501), + ) ) assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "test exception" + assert result.error.message == "test exception" def test_handle_exceptions_invalid_status_data_error(client): @@ -72,7 +90,7 @@ def test_handle_exceptions_invalid_status_data_error(client): exception_to_raise=InvalidStatusDataError("test exception") ) assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "test exception" + assert result.error.message == "test exception" def test_handle_exceptions_keyword_only_argument_error(client): diff --git a/src/tests/test_markets_status.py b/src/tests/test_markets_status.py index bb48b13..4660465 100644 --- a/src/tests/test_markets_status.py +++ b/src/tests/test_markets_status.py @@ -166,7 +166,7 @@ def test_get_markets_status_response_bad_status_code(respx_mock, client): output_format=OutputFormat.INTERNAL, ) assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "Request failed with: Test error message" + assert result.error.message == "Request failed with: Test error message" def test_get_markets_status_status_offline(load_json, respx_mock, client): diff --git a/src/tests/test_options_quotes.py b/src/tests/test_options_quotes.py index 4e41b42..77959bf 100644 --- a/src/tests/test_options_quotes.py +++ b/src/tests/test_options_quotes.py @@ -218,7 +218,7 @@ def test_options_quotes_no_one_good_status_code(respx_mock, client): symbols="AAPL271217C00255000", output_format=OutputFormat.INTERNAL ) assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "No responses from API" + assert result.error.message == "No responses from API" def test_get_options_quotes_response_200_dataframe_pandas( diff --git a/src/tests/test_stocks_candles.py b/src/tests/test_stocks_candles.py index bc53258..f60c6e7 100644 --- a/src/tests/test_stocks_candles.py +++ b/src/tests/test_stocks_candles.py @@ -288,7 +288,7 @@ def test_get_stocks_candles_response_bad_status_code(respx_mock, client): result = client.stocks.candles(symbol="AAPL", resolution="D") assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "Request failed with: Test error message" + assert result.error.message == "Request failed with: Test error message" def test_get_stocks_candles_response_200_dataframe_multiple_years_hourly( diff --git a/src/tests/test_stocks_earnings.py b/src/tests/test_stocks_earnings.py index 3845691..13e3b20 100644 --- a/src/tests/test_stocks_earnings.py +++ b/src/tests/test_stocks_earnings.py @@ -220,7 +220,7 @@ def test_get_stocks_earnings_response_bad_status_code(respx_mock, client): ) result = client.stocks.earnings(symbol="AAPL") assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "Request failed with: Test error message" + assert result.error.message == "Request failed with: Test error message" def test_get_stocks_earnings_status_offline(respx_mock, client): diff --git a/src/tests/test_stocks_news.py b/src/tests/test_stocks_news.py index 0049060..09a82d6 100644 --- a/src/tests/test_stocks_news.py +++ b/src/tests/test_stocks_news.py @@ -170,7 +170,7 @@ def test_get_stocks_news_response_bad_status_code(respx_mock, client): ) news = client.stocks.news(symbol="AAPL", output_format=OutputFormat.INTERNAL) assert isinstance(news, MarketDataClientErrorResult) - assert news.error.args[0] == "Request failed with: Test error message" + assert news.error.message == "Request failed with: Test error message" def test_get_stocks_news_status_offline(respx_mock, client): diff --git a/src/tests/test_stocks_prices.py b/src/tests/test_stocks_prices.py index 4baa865..19f5972 100644 --- a/src/tests/test_stocks_prices.py +++ b/src/tests/test_stocks_prices.py @@ -180,7 +180,7 @@ def test_get_stocks_prices_response_bad_status_code(respx_mock, client): result = client.stocks.prices(symbols="TSLA", output_format=OutputFormat.INTERNAL) assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "Request failed with: Test error message" + assert result.error.message == "Request failed with: Test error message" def test_get_stocks_prices_status_offline(respx_mock, client): diff --git a/src/tests/test_stocks_quotes.py b/src/tests/test_stocks_quotes.py index e9592f4..634fed5 100644 --- a/src/tests/test_stocks_quotes.py +++ b/src/tests/test_stocks_quotes.py @@ -394,7 +394,7 @@ def test_get_stocks_quotes_response_bad_status_code(respx_mock, client): output_format=OutputFormat.INTERNAL, ) assert isinstance(result, MarketDataClientErrorResult) - assert result.error.args[0] == "Request failed with: Test error message" + assert result.error.message == "Request failed with: Test error message" def test_get_stocks_quotes_status_offline(load_json, respx_mock, client): From 8b3feb35232fa04351107da298eb57285460c673 Mon Sep 17 00:00:00 2001 From: Alberto Daniel Badia Date: Tue, 27 Jan 2026 17:22:25 -0300 Subject: [PATCH 2/3] ADD: Support info error tests --- src/tests/test_exceptions.py | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/tests/test_exceptions.py b/src/tests/test_exceptions.py index 0b354a4..f6a7ad2 100644 --- a/src/tests/test_exceptions.py +++ b/src/tests/test_exceptions.py @@ -7,6 +7,7 @@ BadStatusCodeError, InvalidStatusDataError, KeywordOnlyArgumentError, + MarketdataHttpError, MinMaxDateValidationError, RateLimitError, RequestError, @@ -109,3 +110,43 @@ def test_handle_exceptions_min_max_validation_error(client): ) assert isinstance(result, MarketDataClientErrorResult) assert result.error.args[0] == "test exception" + + +def test_support_info_base_exception(client): + resource = DummyResource(client=client) + result = resource.sample_function( + exception_to_raise=BaseMarketdataException( + message="test exception", timestamp="2022-01-01 00:00:00" + ) + ) + assert isinstance(result, MarketDataClientErrorResult) + assert result.error.args[0] == "test exception" + assert result.support_info + + +def test_support_info_http_base_exception(client): + resource = DummyResource(client=client) + result = resource.sample_function( + exception_to_raise=MarketdataHttpError( + message="test exception", + request=Request(method="GET", url="https://example.com"), + response=Response(status_code=501), + ) + ) + assert isinstance(result, MarketDataClientErrorResult) + assert result.error.message == "test exception" + assert result.support_info + + +def test_support_info_http_base_exception_no_response(client): + resource = DummyResource(client=client) + result = resource.sample_function( + exception_to_raise=MarketdataHttpError( + message="test exception", + request=Request(method="GET", url="https://example.com"), + response=None, + ) + ) + assert isinstance(result, MarketDataClientErrorResult) + assert result.error.message == "test exception" + assert result.support_info From 96e9cbb2f4e1ea9a8ffcc775003f5c320e1e8a35 Mon Sep 17 00:00:00 2001 From: Alberto Daniel Badia Date: Fri, 30 Jan 2026 18:13:57 -0300 Subject: [PATCH 3/3] IMPROVE: Catching generic exceptions - Ensure MarketDataClientErrorResult always wraps errors in BaseMarketdataException to guarantee suport_context availability. --- src/marketdata/exceptions.py | 14 ++++++++++++-- src/marketdata/sdk_error.py | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/marketdata/exceptions.py b/src/marketdata/exceptions.py index e210fe2..1436aba 100644 --- a/src/marketdata/exceptions.py +++ b/src/marketdata/exceptions.py @@ -11,10 +11,14 @@ class BaseMarketdataException(Exception): def __init__(self, message: str, timestamp: datetime | None = None): super().__init__(message) self.message = message - self.timestamp = timestamp or datetime.now(timezone("US/Eastern")).strftime( - "%Y-%m-%d %H:%M:%S" + self.timestamp = timestamp or self.format_timestamp( + datetime.now(timezone("US/Eastern")) ) + @classmethod + def format_timestamp(cls, timestamp: datetime): + return timestamp.strftime("%Y-%m-%d %H:%M:%S") + @property def support_context(self) -> dict: return dict( @@ -23,6 +27,12 @@ def support_context(self) -> dict: exception_type=self.__class__.__name__, ) + @classmethod + def from_exception(cls, exception: Exception): + timestamp = cls.format_timestamp(datetime.now(timezone("US/Eastern"))) + message = str(exception) + return cls(message, timestamp) + class MarketdataHttpError(BaseMarketdataException): def __init__( diff --git a/src/marketdata/sdk_error.py b/src/marketdata/sdk_error.py index e7c3556..6002a73 100644 --- a/src/marketdata/sdk_error.py +++ b/src/marketdata/sdk_error.py @@ -15,6 +15,8 @@ class MarketDataClientErrorResult: error: Exception def __init__(self, error: BaseMarketdataException): + if not isinstance(error, BaseMarketdataException): + error = BaseMarketdataException.from_exception(error) self.error = error @property