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
Fix ClientError arguments and crashes on unusable 4xx bodies
`API._handle_exception` has two problems, both in the 4xx branch.

The two fallback `raise`s pass their arguments in the wrong positions.
`ClientError.__init__` is `(status_code, error_code, error_message, header,
error_data)`, but they pass `(status_code, None, response.text, None,
response.headers)`, so `header` ends up `None` and the HTTP headers land in
`error_data`. The success path gets this right, so the same exception carries
headers in different attributes depending on the response body.

This is the ordinary path, not an edge case: the API answers a bad request with
a plain-text body ("Failed to deserialize the JSON body into the target type"),
which is not valid JSON, so every real 4xx takes the `JSONDecodeError` fallback
and loses its headers. Anything reading `err.header` to back off on a rate limit
sees `None`.

Second, the body is only checked for `None` before being used as a mapping.
`json.loads` returns `None` only for the literal `null`; a JSON string, array or
number is equally not a dict, and `err.get("data")` raises `AttributeError` on
all three. An object that lacks `code` or `msg` -- as an intermediary with its
own error schema would return -- raises `KeyError`. Either way the caller gets
an exception the SDK does not define, in place of the `ClientError` it catches.

Accept a body only when it is a dict carrying both keys, and otherwise raise a
`ClientError` holding the raw text as the message, with the headers in `header`.

Adds tests/api_test.py: the well-formed body is unpacked as before, 5xx still
raises `ServerError`, and eight unusable bodies -- including the plain-text one
the live API returns -- all produce a well-formed `ClientError`. Eight of the
eleven fail before this change.
  • Loading branch information
pucedoteth committed Aug 29, 2026
commit 0bc4e2df92700d5bc1a52d15a38cc780a14f42d4
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"