From 89df4590f72feb8da8a800423dd4a49b99eebe73 Mon Sep 17 00:00:00 2001 From: Chip Kent <5250374+chipkent@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:55:47 -0600 Subject: [PATCH] fix: include the HTTP status in BadResponse BaseClient._get raised BadResponse(resp.data.decode("utf-8")) for any non-200 response, so the status code was lost. Callers had to guess from the body whether the key was not entitled (403) or the resource was missing (404). BadResponse now takes a keyword-only status argument and stores it as an attribute, and _get passes resp.status. Positional arguments still go to Exception unchanged, so str(exc) and exc.args are the same as before, and BadResponse() still works (status is None). exceptions.py gets an __all__, so the package's star import does not re-export typing.Optional as massive.Optional. --- massive/exceptions.py | 13 +++++++++++- massive/rest/base.py | 2 +- test_rest/test_bad_response.py | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 test_rest/test_bad_response.py diff --git a/massive/exceptions.py b/massive/exceptions.py index b8960763..d5b1c40b 100644 --- a/massive/exceptions.py +++ b/massive/exceptions.py @@ -1,3 +1,9 @@ +from typing import Optional + +# the package star-imports this module; keep Optional out of its namespace +__all__ = ["AuthError", "BadResponse"] + + class AuthError(Exception): """ Empty or invalid API key @@ -9,6 +15,11 @@ class AuthError(Exception): class BadResponse(Exception): """ Non-200 response from API + + The message is the response body. ``status`` is the HTTP status code, or + None if not given. """ - pass + def __init__(self, *args: object, status: Optional[int] = None): + super().__init__(*args) + self.status = status diff --git a/massive/rest/base.py b/massive/rest/base.py index 294dd482..7b728685 100644 --- a/massive/rest/base.py +++ b/massive/rest/base.py @@ -152,7 +152,7 @@ def _get( ) if resp.status != 200: - raise BadResponse(resp.data.decode("utf-8")) + raise BadResponse(resp.data.decode("utf-8"), status=resp.status) if raw: return resp diff --git a/test_rest/test_bad_response.py b/test_rest/test_bad_response.py new file mode 100644 index 00000000..23c86e7b --- /dev/null +++ b/test_rest/test_bad_response.py @@ -0,0 +1,38 @@ +from massive.exceptions import BadResponse +from base import BaseTest + +import pook # type: ignore + + +class BadResponseTest(BaseTest): + def test_status_is_set(self): + cases = [ + (403, '{"status":"NOT_AUTHORIZED","message":"not entitled"}'), + (404, '{"status":"NOT_FOUND","message":"not found"}'), + ] + for status, body in cases: + with self.subTest(status=status): + ticker = f"ERR{status}" # not covered by the fixture mocks + pook.get( + self.c.BASE + "/v3/reference/tickers/" + ticker, + reply=status, + response_body=body, + ) + with self.assertRaises(BadResponse) as ctx: + self.c.get_ticker_details(ticker) + self.assertEqual(ctx.exception.status, status) + self.assertEqual(ctx.exception.args, (body,)) + self.assertEqual(str(ctx.exception), body) + + def test_status_defaults_to_none(self): + for args in [(), ("boom",), ("boom", 1)]: + with self.subTest(args=args): + exc = BadResponse(*args) + self.assertEqual(exc.args, args) + self.assertIsNone(exc.status) + + def test_status_does_not_change_message(self): + exc = BadResponse("boom", status=500) + self.assertEqual(exc.args, ("boom",)) + self.assertEqual(str(exc), "boom") + self.assertEqual(exc.status, 500)