Skip to content
Open
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: 8 additions & 5 deletions hyperliquid/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
69 changes: 69 additions & 0 deletions tests/api_test.py
Original file line number Diff line number Diff line change
@@ -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"