diff --git a/jquantsapi/client_v2.py b/jquantsapi/client_v2.py index 5cf9348..9db484a 100644 --- a/jquantsapi/client_v2.py +++ b/jquantsapi/client_v2.py @@ -9,6 +9,7 @@ import pandas as pd # type: ignore import requests from requests.adapters import HTTPAdapter +from requests.exceptions import HTTPError from urllib3.util import Retry if sys.version_info >= (3, 11): @@ -216,6 +217,21 @@ def _base_headers(self) -> dict[str, str]: f"p/{platform.python_version()}", } + def _raise_for_status(self, resp: requests.Response) -> None: + """ + raise_for_status の拡張版。 + エラー時にレスポンスボディのメッセージを含めた HTTPError を送出する。 + """ + if resp.ok: + return + try: + body = resp.json() + detail = body.get("message", resp.text) + except Exception: + detail = resp.text + msg = f"{resp.status_code} for url: {resp.url} body: {detail}" + raise HTTPError(msg, response=resp) + def _get( self, url: str, params: Optional[dict[str, Any]] = None ) -> requests.Response: @@ -225,7 +241,7 @@ def _get( session = self._request_session() headers = self._base_headers() resp = session.get(url, params=params, headers=headers, timeout=30) - resp.raise_for_status() + self._raise_for_status(resp) return resp def _get_paginated( @@ -1334,7 +1350,7 @@ def download_bulk(self, key: str, output_path: str) -> None: # ファイルをダウンロード session = self._request_session() response = session.get(url, stream=True, timeout=300) - response.raise_for_status() + self._raise_for_status(response) # ファイルに書き込み with open(output_path, "wb") as f: diff --git a/tests/test_client_v2.py b/tests/test_client_v2.py index a35df53..cfb09ac 100644 --- a/tests/test_client_v2.py +++ b/tests/test_client_v2.py @@ -4,6 +4,7 @@ import pandas as pd import pytest +import requests from dateutil import tz import jquantsapi @@ -401,3 +402,93 @@ def test_get_bulk(): args, _ = mock_get.call_args assert args[1] == {"key": "2024/01/01/eq_master.csv"} assert ret == "https://example.com/data.csv" + + +def test_get_raises_with_api_error_message(): + """_get()がエラー時にAPIのメッセージを含むHTTPErrorを送出することを確認""" + mock_resp = MagicMock() + mock_resp.ok = False + mock_resp.status_code = 403 + mock_resp.url = "https://api.jquants.com/v2/equities/master" + mock_resp.json.return_value = {"message": "Forbidden - Invalid API key"} + mock_resp.text = '{"message": "Forbidden - Invalid API key"}' + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object( + jquantsapi.ClientV2, "_request_session" + ) as mock_session, patch.object( + jquantsapi.ClientV2, "_base_headers", return_value={} + ): + mock_session.return_value.get.return_value = mock_resp + cli = jquantsapi.ClientV2() + with pytest.raises( + requests.exceptions.HTTPError, match="Forbidden - Invalid API key" + ): + cli._get("https://api.jquants.com/v2/equities/master") + + +def test_get_raises_with_text_body_on_json_error(): + """レスポンスがJSONでない場合にテキストボディでHTTPErrorを送出することを確認""" + mock_resp = MagicMock() + mock_resp.ok = False + mock_resp.status_code = 500 + mock_resp.url = "https://api.jquants.com/v2/equities/master" + mock_resp.json.side_effect = ValueError("No JSON") + mock_resp.text = "Internal Server Error" + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object( + jquantsapi.ClientV2, "_request_session" + ) as mock_session, patch.object( + jquantsapi.ClientV2, "_base_headers", return_value={} + ): + mock_session.return_value.get.return_value = mock_resp + cli = jquantsapi.ClientV2() + with pytest.raises( + requests.exceptions.HTTPError, match="Internal Server Error" + ): + cli._get("https://api.jquants.com/v2/equities/master") + + +def test_get_success_does_not_raise(): + """正常レスポンスの場合はエラーが送出されないことを確認""" + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.status_code = 200 + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object( + jquantsapi.ClientV2, "_request_session" + ) as mock_session, patch.object( + jquantsapi.ClientV2, "_base_headers", return_value={} + ): + mock_session.return_value.get.return_value = mock_resp + cli = jquantsapi.ClientV2() + result = cli._get("https://api.jquants.com/v2/equities/master") + assert result == mock_resp + + +def test_get_error_has_response_attribute(): + """HTTPErrorにresponseオブジェクトが付与されることを確認(後方互換性)""" + mock_resp = MagicMock() + mock_resp.ok = False + mock_resp.status_code = 401 + mock_resp.url = "https://api.jquants.com/v2/equities/master" + mock_resp.json.return_value = {"message": "Unauthorized"} + mock_resp.text = '{"message": "Unauthorized"}' + + with patch.object( + jquantsapi.ClientV2, "_load_config", return_value={"api_key": "dummy_key"} + ), patch.object( + jquantsapi.ClientV2, "_request_session" + ) as mock_session, patch.object( + jquantsapi.ClientV2, "_base_headers", return_value={} + ): + mock_session.return_value.get.return_value = mock_resp + cli = jquantsapi.ClientV2() + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + cli._get("https://api.jquants.com/v2/equities/master") + assert exc_info.value.response == mock_resp