Skip to content

Commit 0bc4e2d

Browse files
committed
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.
1 parent 2fdb18f commit 0bc4e2d

2 files changed

Lines changed: 77 additions & 5 deletions

File tree

‎hyperliquid/api.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ def _handle_exception(self, response):
3535
try:
3636
err = json.loads(response.text)
3737
except JSONDecodeError:
38-
raise ClientError(status_code, None, response.text, None, response.headers)
39-
if err is None:
40-
raise ClientError(status_code, None, response.text, None, response.headers)
41-
error_data = err.get("data")
42-
raise ClientError(status_code, err["code"], err["msg"], response.headers, error_data)
38+
err = None
39+
# A 4xx body is only useful when it is an object carrying both keys.
40+
# Anything else (plain text, a bare JSON scalar or array, or an object
41+
# from an intermediary with a different schema) still has to surface as
42+
# a ClientError, with the raw body as the message.
43+
if not isinstance(err, dict) or "code" not in err or "msg" not in err:
44+
raise ClientError(status_code, None, response.text, response.headers, None)
45+
raise ClientError(status_code, err["code"], err["msg"], response.headers, err.get("data"))
4346
raise ServerError(status_code, response.text)

‎tests/api_test.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pytest
2+
3+
from hyperliquid.api import API
4+
from hyperliquid.utils.error import ClientError, ServerError
5+
6+
7+
class FakeResponse:
8+
def __init__(self, status_code, text, headers=None):
9+
self.status_code = status_code
10+
self.text = text
11+
self.headers = {} if headers is None else headers
12+
13+
14+
@pytest.fixture
15+
def api():
16+
return API()
17+
18+
19+
def test_no_exception_below_400(api):
20+
assert api._handle_exception(FakeResponse(200, "{}")) is None
21+
22+
23+
def test_well_formed_error_body_is_unpacked(api):
24+
headers = {"x-ratelimit-remaining": "0"}
25+
body = '{"code": "SOME_CODE", "msg": "some message", "data": {"detail": "x"}}'
26+
with pytest.raises(ClientError) as excinfo:
27+
api._handle_exception(FakeResponse(422, body, headers))
28+
err = excinfo.value
29+
assert err.status_code == 422
30+
assert err.error_code == "SOME_CODE"
31+
assert err.error_message == "some message"
32+
assert err.header == headers
33+
assert err.error_data == {"detail": "x"}
34+
35+
36+
# The live API answers a bad request with a plain-text body, so this is the
37+
# ordinary error path rather than an exotic one.
38+
@pytest.mark.parametrize(
39+
"body",
40+
[
41+
"Failed to deserialize the JSON body into the target type",
42+
"null",
43+
'"Unauthorized"',
44+
'["a", "b"]',
45+
"429",
46+
'{"error": "rate limited"}',
47+
'{"code": "only-code"}',
48+
'{"msg": "only-msg"}',
49+
],
50+
)
51+
def test_unusable_error_body_still_raises_client_error(api, body):
52+
headers = {"x-ratelimit-remaining": "0"}
53+
with pytest.raises(ClientError) as excinfo:
54+
api._handle_exception(FakeResponse(422, body, headers))
55+
err = excinfo.value
56+
assert err.status_code == 422
57+
assert err.error_code is None
58+
# The raw body is the only description available, so it becomes the message.
59+
assert err.error_message == body
60+
# Headers belong in `header`; `error_data` is for the body's "data" field.
61+
assert err.header == headers
62+
assert err.error_data is None
63+
64+
65+
def test_5xx_raises_server_error(api):
66+
with pytest.raises(ServerError) as excinfo:
67+
api._handle_exception(FakeResponse(503, "upstream unavailable"))
68+
assert excinfo.value.status_code == 503
69+
assert excinfo.value.message == "upstream unavailable"

0 commit comments

Comments
 (0)