Skip to content

Commit d013407

Browse files
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 <spahmatthew@gmail.com>
1 parent 6c4db03 commit d013407

8 files changed

Lines changed: 603 additions & 26 deletions

File tree

‎mlbstatsapi/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
from .mlb_api import Mlb
22
from .mlb_dataadapter import MlbDataAdapter, MlbResult
3-
from .exceptions import TheMlbStatsApiException
3+
from .exceptions import (
4+
MlbDecodeError,
5+
MlbHttpError,
6+
MlbTimeoutError,
7+
MlbTransportError,
8+
TheMlbStatsApiException,
9+
)
410

511
from .mlb_module import (
612
return_splits,

‎mlbstatsapi/exceptions.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,32 @@
11
class TheMlbStatsApiException(Exception):
2-
pass
2+
pass
3+
4+
5+
class MlbTransportError(TheMlbStatsApiException):
6+
"""A network or request transport failure."""
7+
8+
9+
class MlbTimeoutError(MlbTransportError):
10+
"""A connection or read timeout."""
11+
12+
13+
class MlbHttpError(TheMlbStatsApiException):
14+
"""An unexpected HTTP response."""
15+
16+
def __init__(
17+
self,
18+
status_code: int,
19+
reason: str,
20+
url: str | None = None,
21+
):
22+
self.status_code = int(status_code)
23+
self.reason = str(reason)
24+
self.url = url
25+
26+
super().__init__(
27+
f"{self.status_code}: {self.reason}"
28+
)
29+
30+
31+
class MlbDecodeError(TheMlbStatsApiException):
32+
"""A successful response contained invalid JSON."""

‎mlbstatsapi/mlb_api.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
from mlbstatsapi.models.homerunderby import HomeRunDerby
2323
from mlbstatsapi.models.standings import Standings
2424

25-
from .mlb_dataadapter import DEFAULT_TIMEOUT, MlbDataAdapter, TimeoutType
25+
from .mlb_dataadapter import (
26+
DEFAULT_TIMEOUT,
27+
MlbDataAdapter,
28+
TimeoutType,
29+
_configure_retry_adapters,
30+
)
2631
# from .exceptions import TheMlbStatsApiException
2732
from . import mlb_module
2833

@@ -49,8 +54,13 @@ def __init__(
4954
):
5055
# One session is shared by the v1 and v1.1 adapters. The library closes
5156
# only sessions it creates; caller-injected sessions remain caller-owned.
57+
# Retry adapters are installed only on library-created Sessions.
5258
self._owns_session = session is None
53-
self._session = session if session is not None else requests.Session()
59+
if session is None:
60+
self._session = requests.Session()
61+
_configure_retry_adapters(self._session)
62+
else:
63+
self._session = session
5464
self._closed = False
5565
self._timeout = timeout
5666
self._mlb_adapter_v1 = MlbDataAdapter(

‎mlbstatsapi/mlb_dataadapter.py‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,65 @@
11
from typing import Dict
22

3-
from .exceptions import TheMlbStatsApiException
4-
import requests
3+
from .exceptions import (
4+
MlbDecodeError,
5+
MlbHttpError,
6+
MlbTimeoutError,
7+
MlbTransportError,
8+
)
59
import logging
610

11+
import requests
12+
from requests.adapters import HTTPAdapter
13+
from urllib3.util.retry import Retry
14+
715

816
# Connect timeout, then read timeout. Callers may override with a scalar or tuple.
917
DEFAULT_TIMEOUT = (3.05, 30.0)
1018
TimeoutType = int | float | tuple[float, float]
1119

1220

21+
def _build_retry_policy() -> Retry:
22+
return Retry(
23+
total=3,
24+
connect=3,
25+
read=2,
26+
status=3,
27+
backoff_factor=0.5,
28+
status_forcelist=(
29+
429,
30+
500,
31+
502,
32+
503,
33+
504,
34+
),
35+
allowed_methods=frozenset({"GET"}),
36+
respect_retry_after_header=True,
37+
raise_on_status=False,
38+
)
39+
40+
41+
def _configure_retry_adapters(
42+
session: requests.Session,
43+
) -> None:
44+
"""Mount default retry adapters on a library-created Session.
45+
46+
Caller-injected Sessions must not be passed here; their adapters stay
47+
under the caller's control.
48+
"""
49+
session.mount(
50+
"https://",
51+
HTTPAdapter(
52+
max_retries=_build_retry_policy()
53+
),
54+
)
55+
session.mount(
56+
"http://",
57+
HTTPAdapter(
58+
max_retries=_build_retry_policy()
59+
),
60+
)
61+
62+
1363
class MlbResult:
1464
"""
1565
A class that holds data, status_code, and message returned from statsapi.mlb.com
@@ -64,7 +114,11 @@ def __init__(
64114
self._logger = logger or logging.getLogger(__name__)
65115
self._timeout = timeout
66116
self._owns_session = session is None
67-
self._session = session if session is not None else requests.Session()
117+
if session is None:
118+
self._session = requests.Session()
119+
_configure_retry_adapters(self._session)
120+
else:
121+
self._session = session
68122
self._closed = False
69123

70124
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
97151
timeout=self._timeout,
98152
)
99153

100-
except requests.exceptions.RequestException as e:
101-
self._logger.error(msg=(str(e)))
102-
raise TheMlbStatsApiException('Request failed') from e
154+
except requests.exceptions.Timeout as exc:
155+
self._logger.error(msg=(str(exc)))
156+
raise MlbTimeoutError("Request failed") from exc
157+
except requests.exceptions.RequestException as exc:
158+
self._logger.error(msg=(str(exc)))
159+
raise MlbTransportError("Request failed") from exc
103160

104161
status_code = response.status_code
105162

@@ -123,13 +180,17 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe
123180
response.reason,
124181
response.url,
125182
))
126-
raise TheMlbStatsApiException(
127-
f"{status_code}: {response.reason}"
183+
raise MlbHttpError(
184+
status_code=status_code,
185+
reason=response.reason,
186+
url=response.url,
128187
)
129188

130189
if not 200 <= status_code <= 299:
131-
raise TheMlbStatsApiException(
132-
f"{status_code}: {response.reason}"
190+
raise MlbHttpError(
191+
status_code=status_code,
192+
reason=response.reason,
193+
url=response.url,
133194
)
134195

135196
self._logger.debug(msg=logline_post.format(
@@ -146,7 +207,7 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe
146207
response_data = response.json()
147208
except (ValueError, requests.JSONDecodeError) as exc:
148209
self._logger.error(msg=(str(exc)))
149-
raise TheMlbStatsApiException(
210+
raise MlbDecodeError(
150211
"Bad JSON in response"
151212
) from exc
152213

‎tests/test_mlb_dataadapter.py‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
import requests
1111
import pytest
1212

13-
from mlbstatsapi import MlbDataAdapter, MlbResult, TheMlbStatsApiException
13+
from mlbstatsapi import (
14+
MlbDataAdapter,
15+
MlbDecodeError,
16+
MlbHttpError,
17+
MlbResult,
18+
MlbTransportError,
19+
TheMlbStatsApiException,
20+
)
1421

1522

1623
BASE_URL = "https://statsapi.mlb.com/api/v1/"
@@ -172,7 +179,7 @@ def test_non_json_404_returns_empty_data(adapter, requests_mock):
172179
assert result.data == {}
173180

174181

175-
def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock):
182+
def test_json_500_raises_mlb_http_error(adapter, requests_mock):
176183
requests_mock.get(
177184
f"{BASE_URL}teams/133/stats",
178185
json={
@@ -185,16 +192,19 @@ def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock):
185192
reason="Internal Server Error",
186193
)
187194

188-
with pytest.raises(TheMlbStatsApiException, match=r"^500: Internal Server Error$") as exc_info:
195+
with pytest.raises(MlbHttpError, match=r"^500: Internal Server Error$") as exc_info:
189196
adapter.get(
190197
endpoint="teams/133/stats",
191198
ep_params={"stats": "standard", "group": "hitting"},
192199
)
193200

201+
assert isinstance(exc_info.value, TheMlbStatsApiException)
194202
assert str(exc_info.value) == "500: Internal Server Error"
203+
assert exc_info.value.status_code == 500
204+
assert exc_info.value.reason == "Internal Server Error"
195205

196206

197-
def test_html_502_raises_the_mlb_stats_api_exception(adapter, requests_mock):
207+
def test_html_502_raises_mlb_http_error(adapter, requests_mock):
198208
requests_mock.get(
199209
f"{BASE_URL}sports",
200210
text="<html><body>Bad Gateway</body></html>",
@@ -203,26 +213,30 @@ def test_html_502_raises_the_mlb_stats_api_exception(adapter, requests_mock):
203213
headers={"Content-Type": "text/html"},
204214
)
205215

206-
with pytest.raises(TheMlbStatsApiException, match=r"^502: Bad Gateway$") as exc_info:
216+
with pytest.raises(MlbHttpError, match=r"^502: Bad Gateway$") as exc_info:
207217
adapter.get(endpoint="sports")
208218

219+
assert isinstance(exc_info.value, TheMlbStatsApiException)
209220
assert str(exc_info.value) == "502: Bad Gateway"
221+
assert exc_info.value.status_code == 502
222+
assert exc_info.value.reason == "Bad Gateway"
210223

211224

212-
def test_connection_failure_raises_request_failed(adapter, requests_mock):
225+
def test_connection_failure_raises_mlb_transport_error(adapter, requests_mock):
213226
requests_mock.get(
214227
f"{BASE_URL}sports",
215228
exc=requests.exceptions.ConnectionError("connection refused"),
216229
)
217230

218-
with pytest.raises(TheMlbStatsApiException, match=r"^Request failed$") as exc_info:
231+
with pytest.raises(MlbTransportError, match=r"^Request failed$") as exc_info:
219232
adapter.get(endpoint="sports")
220233

234+
assert isinstance(exc_info.value, TheMlbStatsApiException)
221235
assert str(exc_info.value) == "Request failed"
222236
assert isinstance(exc_info.value.__cause__, requests.exceptions.ConnectionError)
223237

224238

225-
def test_invalid_json_on_successful_response_raises_bad_json(adapter, requests_mock):
239+
def test_invalid_json_on_successful_response_raises_mlb_decode_error(adapter, requests_mock):
226240
requests_mock.get(
227241
f"{BASE_URL}teams/133/stats",
228242
text='{"some bad json": sdfsd',
@@ -231,13 +245,15 @@ def test_invalid_json_on_successful_response_raises_bad_json(adapter, requests_m
231245
headers={"Content-Type": "application/json"},
232246
)
233247

234-
with pytest.raises(TheMlbStatsApiException, match=r"^Bad JSON in response$") as exc_info:
248+
with pytest.raises(MlbDecodeError, match=r"^Bad JSON in response$") as exc_info:
235249
adapter.get(
236250
endpoint="teams/133/stats",
237251
ep_params={"stats": "season", "group": "hitting"},
238252
)
239253

254+
assert isinstance(exc_info.value, TheMlbStatsApiException)
240255
assert str(exc_info.value) == "Bad JSON in response"
256+
assert isinstance(exc_info.value.__cause__, ValueError)
241257

242258

243259
def test_constructor_does_not_change_logger_level():

0 commit comments

Comments
 (0)