From 0bc4e2df92700d5bc1a52d15a38cc780a14f42d4 Mon Sep 17 00:00:00 2001 From: pucedoteth Date: Sun, 30 Aug 2026 01:22:19 +0200 Subject: [PATCH] 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. --- hyperliquid/api.py | 13 +++++---- tests/api_test.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 tests/api_test.py 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"