Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion mlbstatsapi/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
32 changes: 31 additions & 1 deletion mlbstatsapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,32 @@
class TheMlbStatsApiException(Exception):
pass
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."""
14 changes: 12 additions & 2 deletions mlbstatsapi/mlb_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down
83 changes: 72 additions & 11 deletions mlbstatsapi/mlb_dataadapter.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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

Expand Down
34 changes: 25 additions & 9 deletions tests/test_mlb_dataadapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -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={
Expand All @@ -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="<html><body>Bad Gateway</body></html>",
Expand All @@ -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',
Expand All @@ -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():
Expand Down
Loading
Loading