From d01340798128baaf9f6043f2ef46ff4627c4ca63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 07:29:47 +0000 Subject: [PATCH 1/2] feat: add HTTP retries and structured errors Add bounded urllib3 Retry adapters for library-created Sessions and introduce structured transport, timeout, HTTP, and decode exceptions while preserving 4xx empty-result compatibility. Co-authored-by: Matthew Spah --- mlbstatsapi/__init__.py | 8 +- mlbstatsapi/exceptions.py | 32 +++- mlbstatsapi/mlb_api.py | 14 +- mlbstatsapi/mlb_dataadapter.py | 83 ++++++++-- tests/test_mlb_dataadapter.py | 34 ++-- tests/test_mlb_exceptions.py | 171 ++++++++++++++++++++ tests/test_mlb_retries.py | 278 +++++++++++++++++++++++++++++++++ tests/test_mlb_session.py | 9 +- 8 files changed, 603 insertions(+), 26 deletions(-) create mode 100644 tests/test_mlb_exceptions.py create mode 100644 tests/test_mlb_retries.py diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index d61935df..a2d66697 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -1,6 +1,12 @@ from .mlb_api import Mlb from .mlb_dataadapter import MlbDataAdapter, MlbResult -from .exceptions import TheMlbStatsApiException +from .exceptions import ( + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, + TheMlbStatsApiException, +) from .mlb_module import ( return_splits, diff --git a/mlbstatsapi/exceptions.py b/mlbstatsapi/exceptions.py index 03a329c3..7270de35 100644 --- a/mlbstatsapi/exceptions.py +++ b/mlbstatsapi/exceptions.py @@ -1,2 +1,32 @@ class TheMlbStatsApiException(Exception): - pass \ No newline at end of file + pass + + +class MlbTransportError(TheMlbStatsApiException): + """A network or request transport failure.""" + + +class MlbTimeoutError(MlbTransportError): + """A connection or read timeout.""" + + +class MlbHttpError(TheMlbStatsApiException): + """An unexpected HTTP response.""" + + def __init__( + self, + status_code: int, + reason: str, + url: str | None = None, + ): + self.status_code = int(status_code) + self.reason = str(reason) + self.url = url + + super().__init__( + f"{self.status_code}: {self.reason}" + ) + + +class MlbDecodeError(TheMlbStatsApiException): + """A successful response contained invalid JSON.""" diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 0f6de371..26921e66 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -22,7 +22,12 @@ from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.standings import Standings -from .mlb_dataadapter import DEFAULT_TIMEOUT, MlbDataAdapter, TimeoutType +from .mlb_dataadapter import ( + DEFAULT_TIMEOUT, + MlbDataAdapter, + TimeoutType, + _configure_retry_adapters, +) # from .exceptions import TheMlbStatsApiException from . import mlb_module @@ -49,8 +54,13 @@ def __init__( ): # One session is shared by the v1 and v1.1 adapters. The library closes # only sessions it creates; caller-injected sessions remain caller-owned. + # Retry adapters are installed only on library-created Sessions. self._owns_session = session is None - self._session = session if session is not None else requests.Session() + if session is None: + self._session = requests.Session() + _configure_retry_adapters(self._session) + else: + self._session = session self._closed = False self._timeout = timeout self._mlb_adapter_v1 = MlbDataAdapter( diff --git a/mlbstatsapi/mlb_dataadapter.py b/mlbstatsapi/mlb_dataadapter.py index 5a7cafd7..f9a416ca 100644 --- a/mlbstatsapi/mlb_dataadapter.py +++ b/mlbstatsapi/mlb_dataadapter.py @@ -1,15 +1,65 @@ from typing import Dict -from .exceptions import TheMlbStatsApiException -import requests +from .exceptions import ( + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, +) import logging +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + # Connect timeout, then read timeout. Callers may override with a scalar or tuple. DEFAULT_TIMEOUT = (3.05, 30.0) TimeoutType = int | float | tuple[float, float] +def _build_retry_policy() -> Retry: + return Retry( + total=3, + connect=3, + read=2, + status=3, + backoff_factor=0.5, + status_forcelist=( + 429, + 500, + 502, + 503, + 504, + ), + allowed_methods=frozenset({"GET"}), + respect_retry_after_header=True, + raise_on_status=False, + ) + + +def _configure_retry_adapters( + session: requests.Session, +) -> None: + """Mount default retry adapters on a library-created Session. + + Caller-injected Sessions must not be passed here; their adapters stay + under the caller's control. + """ + session.mount( + "https://", + HTTPAdapter( + max_retries=_build_retry_policy() + ), + ) + session.mount( + "http://", + HTTPAdapter( + max_retries=_build_retry_policy() + ), + ) + + class MlbResult: """ A class that holds data, status_code, and message returned from statsapi.mlb.com @@ -64,7 +114,11 @@ def __init__( self._logger = logger or logging.getLogger(__name__) self._timeout = timeout self._owns_session = session is None - self._session = session if session is not None else requests.Session() + if session is None: + self._session = requests.Session() + _configure_retry_adapters(self._session) + else: + self._session = session self._closed = False def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbResult: @@ -97,9 +151,12 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe timeout=self._timeout, ) - except requests.exceptions.RequestException as e: - self._logger.error(msg=(str(e))) - raise TheMlbStatsApiException('Request failed') from e + except requests.exceptions.Timeout as exc: + self._logger.error(msg=(str(exc))) + raise MlbTimeoutError("Request failed") from exc + except requests.exceptions.RequestException as exc: + self._logger.error(msg=(str(exc))) + raise MlbTransportError("Request failed") from exc status_code = response.status_code @@ -123,13 +180,17 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe response.reason, response.url, )) - raise TheMlbStatsApiException( - f"{status_code}: {response.reason}" + raise MlbHttpError( + status_code=status_code, + reason=response.reason, + url=response.url, ) if not 200 <= status_code <= 299: - raise TheMlbStatsApiException( - f"{status_code}: {response.reason}" + raise MlbHttpError( + status_code=status_code, + reason=response.reason, + url=response.url, ) self._logger.debug(msg=logline_post.format( @@ -146,7 +207,7 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe response_data = response.json() except (ValueError, requests.JSONDecodeError) as exc: self._logger.error(msg=(str(exc))) - raise TheMlbStatsApiException( + raise MlbDecodeError( "Bad JSON in response" ) from exc diff --git a/tests/test_mlb_dataadapter.py b/tests/test_mlb_dataadapter.py index d4af1964..521eb6f3 100644 --- a/tests/test_mlb_dataadapter.py +++ b/tests/test_mlb_dataadapter.py @@ -10,7 +10,14 @@ import requests import pytest -from mlbstatsapi import MlbDataAdapter, MlbResult, TheMlbStatsApiException +from mlbstatsapi import ( + MlbDataAdapter, + MlbDecodeError, + MlbHttpError, + MlbResult, + MlbTransportError, + TheMlbStatsApiException, +) BASE_URL = "https://statsapi.mlb.com/api/v1/" @@ -172,7 +179,7 @@ def test_non_json_404_returns_empty_data(adapter, requests_mock): assert result.data == {} -def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock): +def test_json_500_raises_mlb_http_error(adapter, requests_mock): requests_mock.get( f"{BASE_URL}teams/133/stats", json={ @@ -185,16 +192,19 @@ def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock): reason="Internal Server Error", ) - with pytest.raises(TheMlbStatsApiException, match=r"^500: Internal Server Error$") as exc_info: + with pytest.raises(MlbHttpError, match=r"^500: Internal Server Error$") as exc_info: adapter.get( endpoint="teams/133/stats", ep_params={"stats": "standard", "group": "hitting"}, ) + assert isinstance(exc_info.value, TheMlbStatsApiException) assert str(exc_info.value) == "500: Internal Server Error" + assert exc_info.value.status_code == 500 + assert exc_info.value.reason == "Internal Server Error" -def test_html_502_raises_the_mlb_stats_api_exception(adapter, requests_mock): +def test_html_502_raises_mlb_http_error(adapter, requests_mock): requests_mock.get( f"{BASE_URL}sports", text="Bad Gateway", @@ -203,26 +213,30 @@ def test_html_502_raises_the_mlb_stats_api_exception(adapter, requests_mock): headers={"Content-Type": "text/html"}, ) - with pytest.raises(TheMlbStatsApiException, match=r"^502: Bad Gateway$") as exc_info: + with pytest.raises(MlbHttpError, match=r"^502: Bad Gateway$") as exc_info: adapter.get(endpoint="sports") + assert isinstance(exc_info.value, TheMlbStatsApiException) assert str(exc_info.value) == "502: Bad Gateway" + assert exc_info.value.status_code == 502 + assert exc_info.value.reason == "Bad Gateway" -def test_connection_failure_raises_request_failed(adapter, requests_mock): +def test_connection_failure_raises_mlb_transport_error(adapter, requests_mock): requests_mock.get( f"{BASE_URL}sports", exc=requests.exceptions.ConnectionError("connection refused"), ) - with pytest.raises(TheMlbStatsApiException, match=r"^Request failed$") as exc_info: + with pytest.raises(MlbTransportError, match=r"^Request failed$") as exc_info: adapter.get(endpoint="sports") + assert isinstance(exc_info.value, TheMlbStatsApiException) assert str(exc_info.value) == "Request failed" assert isinstance(exc_info.value.__cause__, requests.exceptions.ConnectionError) -def test_invalid_json_on_successful_response_raises_bad_json(adapter, requests_mock): +def test_invalid_json_on_successful_response_raises_mlb_decode_error(adapter, requests_mock): requests_mock.get( f"{BASE_URL}teams/133/stats", text='{"some bad json": sdfsd', @@ -231,13 +245,15 @@ def test_invalid_json_on_successful_response_raises_bad_json(adapter, requests_m headers={"Content-Type": "application/json"}, ) - with pytest.raises(TheMlbStatsApiException, match=r"^Bad JSON in response$") as exc_info: + with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$") as exc_info: adapter.get( endpoint="teams/133/stats", ep_params={"stats": "season", "group": "hitting"}, ) + assert isinstance(exc_info.value, TheMlbStatsApiException) assert str(exc_info.value) == "Bad JSON in response" + assert isinstance(exc_info.value.__cause__, ValueError) def test_constructor_does_not_change_logger_level(): diff --git a/tests/test_mlb_exceptions.py b/tests/test_mlb_exceptions.py new file mode 100644 index 00000000..66f59b05 --- /dev/null +++ b/tests/test_mlb_exceptions.py @@ -0,0 +1,171 @@ +"""Offline tests for the structured MLB Stats API exception hierarchy.""" + +import pytest +import requests + +from mlbstatsapi import ( + Mlb, + MlbDataAdapter, + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, + TheMlbStatsApiException, +) + + +BASE_URL = "https://statsapi.mlb.com/api/v1/" + + +def test_exception_hierarchy(): + assert issubclass(MlbTransportError, TheMlbStatsApiException) + assert issubclass(MlbTimeoutError, MlbTransportError) + assert issubclass(MlbHttpError, TheMlbStatsApiException) + assert issubclass(MlbDecodeError, TheMlbStatsApiException) + + +@pytest.mark.parametrize( + "exc_type", + [ + MlbTransportError, + MlbTimeoutError, + MlbHttpError, + MlbDecodeError, + ], +) +def test_new_exceptions_are_catchable_as_base(exc_type): + if exc_type is MlbHttpError: + raised = MlbHttpError(500, "Internal Server Error", "https://example.test") + else: + raised = exc_type("failure") + + with pytest.raises(TheMlbStatsApiException): + raise raised + + +def test_mlb_http_error_attributes_and_message(): + exc = MlbHttpError( + status_code=500, + reason="Internal Server Error", + url="https://statsapi.mlb.com/api/v1/sports", + ) + + assert exc.status_code == 500 + assert exc.reason == "Internal Server Error" + assert exc.url == "https://statsapi.mlb.com/api/v1/sports" + assert str(exc) == "500: Internal Server Error" + + +def test_timeout_raises_mlb_timeout_error(): + original = requests.exceptions.Timeout("timed out") + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(original) + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbTimeoutError, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, MlbTransportError) + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Request failed" + assert exc_info.value.__cause__ is original + session.close() + + +@pytest.mark.parametrize( + "timeout_exc", + [ + requests.exceptions.ConnectTimeout("connect timed out"), + requests.exceptions.ReadTimeout("read timed out"), + ], +) +def test_concrete_timeout_subclasses_raise_mlb_timeout_error(timeout_exc): + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(timeout_exc) + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbTimeoutError, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, MlbTransportError) + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.__cause__ is timeout_exc + session.close() + + +def test_connection_error_raises_mlb_transport_error(): + original = requests.exceptions.ConnectionError("connection refused") + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(original) + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbTransportError, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert not isinstance(exc_info.value, MlbTimeoutError) + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Request failed" + assert exc_info.value.__cause__ is original + session.close() + + +def test_invalid_json_raises_mlb_decode_error(requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + text='{"some bad json": sdfsd', + status_code=200, + reason="OK", + headers={"Content-Type": "application/json"}, + ) + adapter = MlbDataAdapter() + + with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert str(exc_info.value) == "Bad JSON in response" + assert isinstance(exc_info.value.__cause__, ValueError) + adapter.close() + + +@pytest.mark.parametrize( + ("status_code", "reason"), + [ + (500, "Internal Server Error"), + (502, "Bad Gateway"), + ], +) +def test_server_error_raises_mlb_http_error(status_code, reason, requests_mock): + url = f"{BASE_URL}sports" + requests_mock.get( + url, + text="error", + status_code=status_code, + reason=reason, + headers={"Content-Type": "text/html"}, + ) + adapter = MlbDataAdapter() + + with pytest.raises(MlbHttpError, match=rf"^{status_code}: {reason}$") as exc_info: + adapter.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.status_code == status_code + assert exc_info.value.reason == reason + assert exc_info.value.url == url + assert str(exc_info.value) == f"{status_code}: {reason}" + adapter.close() + + +def test_injected_session_exception_wrapping_still_works(): + original = requests.exceptions.Timeout("timed out") + session = requests.Session() + session.get = lambda *args, **kwargs: (_ for _ in ()).throw(original) + mlb = Mlb(session=session) + + with pytest.raises(MlbTimeoutError, match=r"^Request failed$") as exc_info: + mlb._mlb_adapter_v1.get(endpoint="sports") + + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.__cause__ is original + session.close() diff --git a/tests/test_mlb_retries.py b/tests/test_mlb_retries.py new file mode 100644 index 00000000..a9a8b999 --- /dev/null +++ b/tests/test_mlb_retries.py @@ -0,0 +1,278 @@ +"""Offline tests for bounded HTTP retries and retry adapter configuration.""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Iterable + +import pytest +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from mlbstatsapi import Mlb, MlbDataAdapter, MlbHttpError, MlbResult + + +def _assert_retry_policy(retry: Retry) -> None: + assert retry.total == 3 + assert retry.connect == 3 + assert retry.read == 2 + assert retry.status == 3 + assert retry.backoff_factor == 0.5 + assert set(retry.status_forcelist) == {429, 500, 502, 503, 504} + assert retry.allowed_methods == frozenset({"GET"}) + assert retry.respect_retry_after_header is True + assert retry.raise_on_status is False + assert "POST" not in retry.allowed_methods + assert "PATCH" not in retry.allowed_methods + assert "DELETE" not in retry.allowed_methods + + +def test_library_created_mlb_session_has_retry_policy(): + mlb = Mlb() + try: + for scheme in ("https://", "http://"): + adapter = mlb._session.get_adapter(scheme) + _assert_retry_policy(adapter.max_retries) + finally: + mlb.close() + + +def test_library_created_adapter_session_has_retry_policy(): + adapter = MlbDataAdapter() + try: + for scheme in ("https://", "http://"): + http_adapter = adapter._session.get_adapter(scheme) + _assert_retry_policy(http_adapter.max_retries) + finally: + adapter.close() + + +def test_injected_session_adapters_are_not_replaced(): + session = requests.Session() + custom_adapter = HTTPAdapter(max_retries=0) + session.mount("https://", custom_adapter) + + mlb = Mlb(session=session) + try: + assert session.get_adapter("https://") is custom_adapter + finally: + mlb.close() + session.close() + + +def test_injected_adapter_session_adapters_are_not_replaced(): + session = requests.Session() + custom_adapter = HTTPAdapter(max_retries=0) + session.mount("http://", custom_adapter) + + adapter = MlbDataAdapter(session=session) + try: + assert session.get_adapter("http://") is custom_adapter + finally: + adapter.close() + session.close() + + +class _ScriptedHandler(BaseHTTPRequestHandler): + """Serve a scripted sequence of HTTP status codes for retry tests.""" + + statuses: list[int] = [] + request_count = 0 + lock = threading.Lock() + + def do_GET(self) -> None: # noqa: N802 - required by BaseHTTPRequestHandler + with self.lock: + index = self.request_count + self.__class__.request_count += 1 + + if index < len(self.statuses): + status = self.statuses[index] + else: + status = self.statuses[-1] if self.statuses else 500 + + body = b"" + if status == 200: + body = json.dumps({"sports": [{"id": 1}]}).encode("utf-8") + + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + +@pytest.fixture +def scripted_http_server(): + """Start a local HTTP server that returns a caller-provided status sequence.""" + + server = ThreadingHTTPServer(("127.0.0.1", 0), _ScriptedHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + port = server.server_address[1] + + def configure(statuses: Iterable[int]) -> None: + _ScriptedHandler.statuses = list(statuses) + _ScriptedHandler.request_count = 0 + + try: + yield configure, port + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.fixture +def no_retry_sleep(monkeypatch): + monkeypatch.setattr(Retry, "sleep", lambda self, response=None: None) + + +def _adapter_against_local_server(port: int) -> MlbDataAdapter: + adapter = MlbDataAdapter() + adapter.url = f"http://127.0.0.1:{port}/api/v1/" + return adapter + + +def test_retries_recover_from_two_500_responses( + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([500, 500, 200]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert isinstance(result, MlbResult) + assert result.status_code == 200 + assert result.data == {"sports": [{"id": 1}]} + assert _ScriptedHandler.request_count == 3 + + +@pytest.mark.parametrize( + "retryable_status", + [429, 500, 502, 503, 504], +) +def test_retries_retryable_statuses_then_succeed( + retryable_status, + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([retryable_status, 200]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert result.status_code == 200 + assert result.data == {"sports": [{"id": 1}]} + assert _ScriptedHandler.request_count == 2 + + +@pytest.mark.parametrize( + "status_code", + [400, 401, 403, 404], +) +def test_non_retryable_client_errors_are_not_retried( + status_code, + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([status_code, 200]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert result.status_code == status_code + assert result.data == {} + assert _ScriptedHandler.request_count == 1 + + +def test_bounded_persistent_500_raises_after_four_attempts( + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([500, 500, 500, 500, 500, 500]) + adapter = _adapter_against_local_server(port) + + try: + with pytest.raises(MlbHttpError) as exc_info: + adapter.get(endpoint="sports") + finally: + adapter.close() + + assert exc_info.value.status_code == 500 + assert exc_info.value.reason == "Internal Server Error" + assert _ScriptedHandler.request_count == 4 + + +def test_final_429_returns_empty_mlb_result( + scripted_http_server, + no_retry_sleep, +): + configure, port = scripted_http_server + configure([429, 429, 429, 429, 429, 429]) + adapter = _adapter_against_local_server(port) + + try: + result = adapter.get(endpoint="sports") + finally: + adapter.close() + + assert isinstance(result, MlbResult) + assert result.status_code == 429 + assert result.data == {} + assert _ScriptedHandler.request_count == 4 + + +def test_invalid_json_is_not_retried(scripted_http_server, no_retry_sleep): + configure, port = scripted_http_server + + class BadJsonHandler(_ScriptedHandler): + def do_GET(self) -> None: # noqa: N802 + with self.lock: + self.__class__.request_count += 1 + body = b'{"bad": json' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + # Replace handler class on a dedicated server for this case. + server = ThreadingHTTPServer(("127.0.0.1", 0), BadJsonHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + BadJsonHandler.request_count = 0 + adapter = MlbDataAdapter() + adapter.url = f"http://127.0.0.1:{server.server_address[1]}/api/v1/" + + try: + from mlbstatsapi import MlbDecodeError + + with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$"): + adapter.get(endpoint="sports") + assert BadJsonHandler.request_count == 1 + finally: + adapter.close() + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/tests/test_mlb_session.py b/tests/test_mlb_session.py index c3ebb713..6f0c6fcb 100644 --- a/tests/test_mlb_session.py +++ b/tests/test_mlb_session.py @@ -9,7 +9,7 @@ import pytest import requests -from mlbstatsapi import Mlb, MlbDataAdapter, TheMlbStatsApiException +from mlbstatsapi import Mlb, MlbDataAdapter, MlbHttpError, TheMlbStatsApiException from mlbstatsapi.mlb_dataadapter import DEFAULT_TIMEOUT @@ -144,9 +144,14 @@ def test_adapter_500_behavior_with_session(): ) adapter = MlbDataAdapter(session=session) - with pytest.raises(TheMlbStatsApiException, match=r"^500: Internal Server Error$"): + with pytest.raises(MlbHttpError, match=r"^500: Internal Server Error$") as exc_info: adapter.get(endpoint="sports") + assert isinstance(exc_info.value, TheMlbStatsApiException) + assert exc_info.value.status_code == 500 + assert exc_info.value.reason == "Internal Server Error" + assert exc_info.value.url == "https://statsapi.mlb.com/api/v1/sports" + # --- Timeouts --- From 50e6d9d803277ffb7756c343932ba99d1fb21243 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 07:29:56 +0000 Subject: [PATCH 2/2] test: simplify invalid JSON no-retry coverage Remove an unused local-server fixture dependency from the decode no-retry test. Co-authored-by: Matthew Spah --- tests/test_mlb_retries.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_mlb_retries.py b/tests/test_mlb_retries.py index a9a8b999..0bae978e 100644 --- a/tests/test_mlb_retries.py +++ b/tests/test_mlb_retries.py @@ -243,9 +243,7 @@ def test_final_429_returns_empty_mlb_result( assert _ScriptedHandler.request_count == 4 -def test_invalid_json_is_not_retried(scripted_http_server, no_retry_sleep): - configure, port = scripted_http_server - +def test_invalid_json_is_not_retried(no_retry_sleep): class BadJsonHandler(_ScriptedHandler): def do_GET(self) -> None: # noqa: N802 with self.lock: @@ -257,7 +255,6 @@ def do_GET(self) -> None: # noqa: N802 self.end_headers() self.wfile.write(body) - # Replace handler class on a dedicated server for this case. server = ThreadingHTTPServer(("127.0.0.1", 0), BadJsonHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start()