diff --git a/hyperliquid/api.py b/hyperliquid/api.py index d808f2ce..465d952f 100644 --- a/hyperliquid/api.py +++ b/hyperliquid/api.py @@ -35,9 +35,12 @@ def _handle_exception(self, response): try: err = json.loads(response.text) except JSONDecodeError: - raise ClientError(status_code, None, response.text, None, response.headers) - if err is None: - raise ClientError(status_code, None, response.text, None, response.headers) - error_data = err.get("data") - raise ClientError(status_code, err["code"], err["msg"], response.headers, error_data) + err = None + # A 4xx body is only useful when it is an object carrying both keys. + # Anything else (plain text, a bare JSON scalar or array, or an object + # from an intermediary with a different schema) still has to surface as + # a ClientError, with the raw body as the message. + if not isinstance(err, dict) or "code" not in err or "msg" not in err: + raise ClientError(status_code, None, response.text, response.headers, None) + raise ClientError(status_code, err["code"], err["msg"], response.headers, err.get("data")) raise ServerError(status_code, response.text) diff --git a/tests/api_test.py b/tests/api_test.py new file mode 100644 index 00000000..b63da92e --- /dev/null +++ b/tests/api_test.py @@ -0,0 +1,69 @@ +import pytest + +from hyperliquid.api import API +from hyperliquid.utils.error import ClientError, ServerError + + +class FakeResponse: + def __init__(self, status_code, text, headers=None): + self.status_code = status_code + self.text = text + self.headers = {} if headers is None else headers + + +@pytest.fixture +def api(): + return API() + + +def test_no_exception_below_400(api): + assert api._handle_exception(FakeResponse(200, "{}")) is None + + +def test_well_formed_error_body_is_unpacked(api): + headers = {"x-ratelimit-remaining": "0"} + body = '{"code": "SOME_CODE", "msg": "some message", "data": {"detail": "x"}}' + with pytest.raises(ClientError) as excinfo: + api._handle_exception(FakeResponse(422, body, headers)) + err = excinfo.value + assert err.status_code == 422 + assert err.error_code == "SOME_CODE" + assert err.error_message == "some message" + assert err.header == headers + assert err.error_data == {"detail": "x"} + + +# The live API answers a bad request with a plain-text body, so this is the +# ordinary error path rather than an exotic one. +@pytest.mark.parametrize( + "body", + [ + "Failed to deserialize the JSON body into the target type", + "null", + '"Unauthorized"', + '["a", "b"]', + "429", + '{"error": "rate limited"}', + '{"code": "only-code"}', + '{"msg": "only-msg"}', + ], +) +def test_unusable_error_body_still_raises_client_error(api, body): + headers = {"x-ratelimit-remaining": "0"} + with pytest.raises(ClientError) as excinfo: + api._handle_exception(FakeResponse(422, body, headers)) + err = excinfo.value + assert err.status_code == 422 + assert err.error_code is None + # The raw body is the only description available, so it becomes the message. + assert err.error_message == body + # Headers belong in `header`; `error_data` is for the body's "data" field. + assert err.header == headers + assert err.error_data is None + + +def test_5xx_raises_server_error(api): + with pytest.raises(ServerError) as excinfo: + api._handle_exception(FakeResponse(503, "upstream unavailable")) + assert excinfo.value.status_code == 503 + assert excinfo.value.message == "upstream unavailable"