Skip to content

feat: add BadResponse.status and an opt-in raise_on_status for RESTClient - #6

Closed
chipkent wants to merge 1 commit into
masterfrom
fix/rest-bad-response-status
Closed

chipkent wants to merge 1 commit into
masterfrom
fix/rest-bad-response-status

Conversation

@chipkent

@chipkent chipkent commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Preview only. This is a draft in the fork showing the exact text of the PR planned for massive-com/client-python. It will be closed, not merged, once the upstream PR is opened.

Fixes #<issue: F5>

Summary

When a request fails, BadResponse carries the response body but not the HTTP status. BaseClient._get raises BadResponse(resp.data.decode("utf-8")) for any non-200 response, so callers have to guess from the body whether the key is not entitled (403), the request was rate limited (429), or the server failed (5xx). And with the default retries=3, a 429 or 5xx that is still failing after the last retry surfaces as urllib3's MaxRetryError, not BadResponse.

This PR adds the status to BadResponse, and adds an opt-in so that those retried statuses can also end in BadResponse.

Existing behavior is unchanged. With the default settings every request raises the same exception type as before, str(exc) and exc.args are the same, and the retry policy is the same. The only caller-visible additions are the BadResponse.status attribute and the new raise_on_status argument.

Changes

  1. BadResponse.status. BadResponse takes a keyword-only status argument and stores it as an attribute; _get passes resp.status. Positional arguments go to Exception unchanged, so str(exc) and exc.args are still the decoded body, and BadResponse(), BadResponse("msg") and BadResponse("a", "b") still work, with status set to None.
  2. raise_on_status on RESTClient (and BaseClient), default True. It is passed to the client's urllib3 Retry and reaches both the main client and client.vx.
  3. __all__ in massive/exceptions.py. The package does from .exceptions import *, so without it the new typing.Optional import would appear as massive.Optional. The exported names are unchanged: AuthError and BadResponse.

The raise_on_status opt-in

The client retries 413, 429, 499, 500, 502, 503 and 504 up to retries times (3 by default). If the last attempt still gets one of those statuses:

raise_on_status Result
True (default, same as today) urllib3 raises urllib3.exceptions.MaxRetryError; BadResponse is not raised
False the client raises BadResponse, with status set (for example 429) and the response body as the message

Statuses that are not retried, such as 403 or 404, raise BadResponse either way. Connection errors that use up the retries raise MaxRetryError either way. The number of attempts and the backoff are the same either way.

client = RESTClient(raise_on_status=False)
try:
    client.get_market_status()
except BadResponse as e:
    if e.status == 429:
        ...  # rate limited, even after retries

Why a boolean and not a Retry object. The constructor already exposes connection settings as plain values (retries, maxsize, proxy), each passed to urllib3. The name and meaning match urllib3's Retry(raise_on_status=...). Letting retries also accept a Retry would widen an existing argument's type, and callers would have to restate the client's status list and backoff to change this one setting. A custom Retry is already possible through connection_pool_kw={"retries": ...}.

Test plan

  • test_rest/test_bad_response.py:
    • drives 403, 429 and 500 through pook and checks status, str() and args;
    • checks that BadResponse(), BadResponse("boom") and BadResponse("boom", 1) keep their args and str(), with status set to None;
    • runs 429 and 503 against a local HTTP server with the backoff sleep patched out (pook replaces urllib3's urlopen, which is where retries happen, so a real server is needed). With the default settings, MaxRetryError is still raised after 4 requests (the first attempt plus 3 retries), which pins today's behavior; with raise_on_status=False and retries set to 0 or 3, BadResponse is raised with the right status and body after exactly retries + 1 requests;
    • checks that raise_on_status reaches both the main client and client.vx.
  • make test, make style and make static pass on Python 3.10 and 3.14.

Independent of the other PRs.

@coderabbitai

coderabbitai Bot commented Oct 24, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@chipkent
chipkent force-pushed the fix/rest-bad-response-status branch from e4c82fb to 8a83934 Compare October 24, 2026 03:26
@chipkent chipkent changed the title fix: BadResponse carries the HTTP status and body fix: BadResponse carries the HTTP status Oct 24, 2026
@chipkent
chipkent force-pushed the fix/rest-bad-response-status branch from 8a83934 to 108e01e Compare October 24, 2026 04:15
BaseClient._get raised BadResponse(resp.data.decode("utf-8")) for any
non-200 response, so the HTTP status code was lost. Callers had to
guess from the body whether a request failed because the key is not
entitled (403), because it was rate limited (429), or because of a
server error (5xx).

BadResponse now accepts a keyword-only status argument and exposes it
as an attribute; _get passes resp.status. Positional arguments still go
to Exception unchanged, so str(exc) and exc.args are the same as before,
and BadResponse() and BadResponse("a", "b") still work (status is None).

Statuses in the retry list (413, 429, 499, 500, 502, 503, 504) never
reach _get by default: once the retries run out, urllib3 raises
MaxRetryError instead. That stays the default. A new raise_on_status
argument on RESTClient and BaseClient, default True, is passed to the
Retry; with raise_on_status=False the last response is returned and
_get raises BadResponse with its status and body.

exceptions.py gets an __all__, so "from .exceptions import *" in the
package does not re-export typing.Optional as massive.Optional.

Tests drive 403, 429 and 500 responses through pook, check that
construction without status is unchanged, and run 429 and 503 against
a local HTTP server: with the defaults they still raise MaxRetryError,
and with raise_on_status=False they raise BadResponse with the status
after exactly retries + 1 requests.
@chipkent
chipkent force-pushed the fix/rest-bad-response-status branch from 108e01e to b74a664 Compare October 24, 2026 04:26
@chipkent chipkent changed the title fix: BadResponse carries the HTTP status feat: BadResponse carries the HTTP status Oct 24, 2026
@chipkent chipkent changed the title feat: BadResponse carries the HTTP status feat: add BadResponse.status and an opt-in raise_on_status for RESTClient Oct 24, 2026
@chipkent

Copy link
Copy Markdown
Owner Author

Superseded by smaller PRs split by content: #12, #13. Closing this preview; the replacements carry the same changes, opt-in where behaviour changes.

@chipkent chipkent closed this Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant