Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

84 - HTTP Error code handling 400+ #87

Merged
merged 5 commits into from
Nov 23, 2024
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
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 <[email protected]>"]
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
Loading