Skip to content

Commit

Permalink
84 - HTTP Error code handling 400+ (#87)
Browse files Browse the repository at this point in the history
* feat: Enhance HTTP client error handling with custom exceptions

- Implement hierarchical exception system with base NHLApiException
- Add specific exceptions for common HTTP status codes (404, 429, 400, 401, 5xx)
- Create centralized error handling in _handle_response method
- Add comprehensive test suite with pytest for all error scenarios
- Improve error message formatting to include both request context and API response
- Handle edge cases for malformed JSON and non-JSON responses
- Add type checking for response parsing

Tests cover:
- All HTTP error status codes
- Success responses
- Non-JSON responses
- Malformed JSON handling
- Query parameter handling
- Verbose logging behavior

Breaking changes: None - extends existing error handling functionality
Closes #81

* README bugfix

* Fix: stats.team_summary() double encoding issue in notebooks
  • Loading branch information
coreyjs authored Nov 23, 2024
1 parent f492e60 commit 9ebb230
Show file tree
Hide file tree
Showing 7 changed files with 256 additions and 23 deletions.
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ client.stats.club_stats_season(team_abbr="BUF") # kinda weird endpoint.

client.stats.player_career_stats(player_id="8478402")

client.stats.player_game_log(player_id="", season_id="20242025", game_type="2")
client.stats.player_game_log(player_id="8478402", season_id="20242025", game_type="2")

# Team Summary Stats.
# These have lots of available parameters. You can also tap into the apache cayenne expressions to build custom
Expand Down Expand Up @@ -286,7 +286,7 @@ client.schedule.schedule_calendar(date="2023-11-23")
```python
client.standings.get_standings()
client.standings.get_standings(date="2021-01-13")
client.standings.get_standings(season="202222023")
client.standings.get_standings(season="20222023")

# standings manifest. This returns a ton of information for every season ever it seems like
# This calls the API for this info, I also cache this in /data/seasonal_information_manifest.json
Expand All @@ -299,8 +299,6 @@ client.standings.season_standing_manifest()

```python
client.teams.teams_info() # returns id + abbrevation + name of all teams

client.teams.team_stats_summary(lang="en") # I honestly dont know. This is missing teams and has teams long abandoned.
```

---
Expand Down Expand Up @@ -375,6 +373,13 @@ $ black .
```


### pypi test net
```
poetry build
poetry publish -r test-pypi
```


#### Poetry version management
```
# View current version
Expand Down
5 changes: 4 additions & 1 deletion nhlpy/api/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def get_schedule_by_team_by_week(self, team_abbr: str, date: Optional[str] = Non
:return:
"""
resource = f"club-schedule/{team_abbr}/week/{date if date else 'now'}"

return self.client.get(resource=resource).json()["games"]

def get_season_schedule(self, team_abbr: str, season: str) -> dict:
Expand All @@ -79,7 +80,9 @@ def get_season_schedule(self, team_abbr: str, season: str) -> dict:
:param season: Season in format YYYYYYYY. 20202021, 20212022, etc
:return:
"""
return self.client.get(resource=f"club-schedule-season/{team_abbr}/{season}").json()
request = self.client.get(resource=f"club-schedule-season/{team_abbr}/{season}")

return request.json()

def schedule_calendar(self, date: str) -> dict:
"""
Expand Down
3 changes: 1 addition & 2 deletions nhlpy/api/stats.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import urllib.parse
import json
from typing import List

Expand Down Expand Up @@ -94,7 +93,7 @@ def team_summary(
{"property": "wins", "direction": "DESC"},
{"property": "teamId", "direction": "ASC"},
]
q_params["sort"] = urllib.parse.quote(json.dumps(sort_expr))
q_params["sort"] = json.dumps(sort_expr)

if not default_cayenne_exp:
default_cayenne_exp = f"gameTypeId={game_type_id} and seasonId<={end_season} and seasonId>={start_season}"
Expand Down
125 changes: 113 additions & 12 deletions nhlpy/http_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,65 @@
from enum import Enum
from typing import Optional

import httpx
import logging


class NHLApiErrorCode(Enum):
"""Enum for NHL API specific error codes if any"""

RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
SERVER_ERROR = "SERVER_ERROR"
BAD_REQUEST = "BAD_REQUEST"
UNAUTHORIZED = "UNAUTHORIZED"


class NHLApiException(Exception):
"""Base exception for NHL API errors"""

def __init__(self, message: str, status_code: int, error_code: Optional[NHLApiErrorCode] = None):
self.message = message
self.status_code = status_code
self.error_code = error_code
super().__init__(self.message)


class ResourceNotFoundException(NHLApiException):
"""Raised when a resource is not found (404)"""

def __init__(self, message: str, status_code: int = 404):
super().__init__(message, status_code, NHLApiErrorCode.RESOURCE_NOT_FOUND)


class RateLimitExceededException(NHLApiException):
"""Raised when rate limit is exceeded (429)"""

def __init__(self, message: str, status_code: int = 429):
super().__init__(message, status_code, NHLApiErrorCode.RATE_LIMIT_EXCEEDED)


class ServerErrorException(NHLApiException):
"""Raised for server errors (5xx)"""

def __init__(self, message: str, status_code: int):
super().__init__(message, status_code, NHLApiErrorCode.SERVER_ERROR)


class BadRequestException(NHLApiException):
"""Raised for client errors (400)"""

def __init__(self, message: str, status_code: int = 400):
super().__init__(message, status_code, NHLApiErrorCode.BAD_REQUEST)


class UnauthorizedException(NHLApiException):
"""Raised for authentication errors (401)"""

def __init__(self, message: str, status_code: int = 401):
super().__init__(message, status_code, NHLApiErrorCode.UNAUTHORIZED)


class HttpClient:
def __init__(self, config) -> None:
self._config = config
Expand All @@ -10,35 +68,78 @@ def __init__(self, config) -> None:
else:
logging.basicConfig(level=logging.WARNING)

def get(self, resource: str) -> httpx.request:
def _handle_response(self, response: httpx.Response, url: str) -> None:
"""Handle different HTTP status codes and raise appropriate exceptions"""

if response.is_success:
return

# Build error message
error_message = f"Request to {url} failed"
try:
response_json = response.json()
if isinstance(response_json, dict):
error_detail = response_json.get("message")
if error_detail:
error_message = f"{error_message}: {error_detail}"
except Exception:
# If response isn't JSON or doesn't have a message field
pass

if response.status_code == 404:
raise ResourceNotFoundException(error_message)
elif response.status_code == 429:
raise RateLimitExceededException(error_message)
elif response.status_code == 400:
raise BadRequestException(error_message)
elif response.status_code == 401:
raise UnauthorizedException(error_message)
elif 500 <= response.status_code < 600:
raise ServerErrorException(error_message, response.status_code)
else:
raise NHLApiException(f"Unexpected error: {error_message}", response.status_code)

def get(self, resource: str) -> httpx.Response:
"""
Private method to make a get request to the NHL API. This wraps the lib httpx functionality.
:param resource:
:return:
:return: httpx.Response
:raises:
ResourceNotFoundException: When the resource is not found
RateLimitExceededException: When rate limit is exceeded
ServerErrorException: When server returns 5xx error
BadRequestException: When request is malformed
UnauthorizedException: When authentication fails
NHLApiException: For other unexpected errors
"""
with httpx.Client(
verify=self._config.ssl_verify, timeout=self._config.timeout, follow_redirects=self._config.follow_redirects
) as client:
r: httpx.request = client.get(url=f"{self._config.api_web_base_url}{self._config.api_web_api_ver}{resource}")

if self._config.verbose:
logging.info(f"API URL: {r.url}")
r: httpx.Response = client.get(
url=f"{self._config.api_web_base_url}{self._config.api_web_api_ver}{resource}"
)

self._handle_response(r, resource)
return r

def get_by_url(self, full_resource: str, query_params: dict = None) -> httpx.request:
def get_by_url(self, full_resource: str, query_params: dict = None) -> httpx.Response:
"""
Private method to make a get request to any HTTP resource. This wraps the lib httpx functionality.
:param query_params:
:param full_resource: The full resource to get.
:return:
:return: httpx.Response
:raises:
ResourceNotFoundException: When the resource is not found
RateLimitExceededException: When rate limit is exceeded
ServerErrorException: When server returns 5xx error
BadRequestException: When request is malformed
UnauthorizedException: When authentication fails
NHLApiException: For other unexpected errors
"""
with httpx.Client(
verify=self._config.ssl_verify, timeout=self._config.timeout, follow_redirects=self._config.follow_redirects
) as client:
r: httpx.request = client.get(url=full_resource, params=query_params)

if self._config.verbose:
logging.info(f"API URL: {r.url}")
r: httpx.Response = client.get(url=full_resource, params=query_params)

self._handle_response(r, full_resource)
return r
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "nhl-api-py"
version = "2.12.4"
version = "2.14.2"
description = "NHL API (Updated for 2024/2025) and EDGE Stats. For standings, team stats, outcomes, player information. Contains each individual API endpoint as well as convience methods as well as pythonic query builder for more indepth EDGE stats."
authors = ["Corey Schaf <cschaf@gmail.com>"]
readme = "README.md"
Expand Down
126 changes: 126 additions & 0 deletions tests/test_nhl_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,51 @@
import pytest
from unittest.mock import Mock, patch
from nhlpy.nhl_client import NHLClient
from nhlpy.api import teams, standings, schedule
from nhlpy.http_client import (
NHLApiException,
ResourceNotFoundException,
RateLimitExceededException,
ServerErrorException,
BadRequestException,
UnauthorizedException,
HttpClient,
)


class MockResponse:
"""Mock httpx.Response for testing"""

def __init__(self, status_code, json_data=None):
self.status_code = status_code
self._json_data = json_data or {}
self.url = "https://api.nhl.com/v1/test"

def json(self):
return self._json_data

@property
def is_success(self):
return 200 <= self.status_code < 300


@pytest.fixture
def mock_config():
"""Fixture for config object"""
config = Mock()
config.verbose = False
config.ssl_verify = True
config.timeout = 30
config.follow_redirects = True
config.api_web_base_url = "https://api.nhl.com"
config.api_web_api_ver = "/v1"
return config


@pytest.fixture
def http_client(mock_config):
"""Fixture for HttpClient instance"""
return HttpClient(mock_config)


def test_nhl_client_responds_to_teams():
Expand All @@ -18,3 +64,83 @@ def test_nhl_client_responds_to_schedule():
c = NHLClient()
assert c.schedule is not None
assert isinstance(c.schedule, schedule.Schedule)


@pytest.mark.parametrize(
"status_code,expected_exception",
[
(404, ResourceNotFoundException),
(429, RateLimitExceededException),
(400, BadRequestException),
(401, UnauthorizedException),
(500, ServerErrorException),
(502, ServerErrorException),
(599, NHLApiException),
],
)
def test_http_client_error_handling(http_client, status_code, expected_exception):
"""Test different HTTP error status codes raise appropriate exceptions"""
mock_response = MockResponse(status_code=status_code, json_data={"message": "Test error message"})

with patch("httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = mock_response

with pytest.raises(expected_exception) as exc_info:
http_client.get("/test")

assert exc_info.value.status_code == status_code
assert "Test error message" in str(exc_info.value)


def test_http_client_success_response(http_client):
"""Test successful HTTP response"""
mock_response = MockResponse(status_code=200, json_data={"data": "test"})

with patch("httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
response = http_client.get("/test")
assert response.status_code == 200


def test_http_client_non_json_error_response(http_client):
"""Test error response with non-JSON body still works"""
mock_response = MockResponse(status_code=500)
mock_response.json = Mock(side_effect=ValueError) # Simulate JSON decode error

with patch("httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = mock_response

with pytest.raises(ServerErrorException) as exc_info:
http_client.get("/test")

assert exc_info.value.status_code == 500
assert "Request to" in str(exc_info.value)


def test_http_client_get_by_url_with_params(http_client):
"""Test get_by_url method with query parameters"""
mock_response = MockResponse(status_code=200, json_data={"data": "test"})
query_params = {"season": "20232024"}

with patch("httpx.Client") as mock_client:
mock_instance = mock_client.return_value.__enter__.return_value
mock_instance.get.return_value = mock_response

response = http_client.get_by_url("https://api.nhl.com/v1/test", query_params)

mock_instance.get.assert_called_once_with(url="https://api.nhl.com/v1/test", params=query_params)
assert response.status_code == 200


def test_http_client_custom_error_message(http_client):
"""Test custom error message in JSON response"""
custom_message = "Custom API error explanation"
mock_response = MockResponse(status_code=400, json_data={"message": custom_message})

with patch("httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.get.return_value = mock_response

with pytest.raises(BadRequestException) as exc_info:
http_client.get("/test")

assert custom_message in str(exc_info.value)
5 changes: 2 additions & 3 deletions tests/test_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,8 @@ def test_team_summary_single_year(h_m, nhl_client):
"limit": 50,
"start": 0,
"factCayenneExp": "gamesPlayed>1",
"sort": "%5B%7B%22property%22%3A%20%22points%22%2C%20%22direction%22%3A%20%22DESC%22%7D%2C%20%7B%22property%22"
"%3A%20%22wins%22%2C%20%22direction%22%3A%20%22DESC%22%7D%2C%20%7B%22property%22%3A%20%22teamId%22%2C%"
"20%22direction%22%3A%20%22ASC%22%7D%5D",
"sort": '[{"property": "points", "direction": "DESC"}, {"property": "wins", "direction": "DESC"}, '
'{"property": "teamId", "direction": "ASC"}]',
"cayenneExp": "gameTypeId=2 and seasonId<=20232024 and seasonId>=20232024",
}

Expand Down

0 comments on commit 9ebb230

Please sign in to comment.