Skip to content

feat(client)!: typed HTTP errors, a configurable timeout, opt-in GET retries and faucet TryLater - #35

Merged
isaacdoidge merged 5 commits into
masterfrom
fix/typed-errors
Nov 4, 2026
Merged

isaacdoidge merged 5 commits into
masterfrom
fix/typed-errors

Conversation

@isaacdoidge

@isaacdoidge isaacdoidge commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

What

  • faucet() handles the node's "TryLater" answer instead of raising AttributeError, and the faucet request is
    never repeated.
  • HTTP errors are typed: ApiError stays the base class and gains the parsed node message, the raw body and the
    response headers; subclasses cover 404, 408, 410, 429 and 503.
  • The request timeout is configurable and now bounds the wait for a pooled connection.
  • GET requests can opt in to retries on 408, 429, 503 and timeouts.
  • view() and faucet() raise an error that says what is wrong when a 200 response lacks a usable field they read
    (result, Accepted).

Public API

Exported from supra_sdk.clients:

Name Notes
ApiError Base class. Constructor (message, status_code) unchanged. Adds headers, body; message is the JSON message field, falling back to the body text
ApiNotFoundError 404. A v11.7.x node answers a missing account with 400 and a missing resource with 404
ApiRequestTimeoutError 408
ApiGoneError 410; oldest_block: int | None from the x-supra-oldest-block header, the oldest block the node still holds
ApiRateLimitError 429; retry_after_seconds: float | None from the body's retry_after_seconds or the Retry-After header (seconds or HTTP date)
ApiServiceUnavailableError 503
UnexpectedResponseError Subclass of ApiError and KeyError; field names the field, and the message says whether it is missing, unusable, or the body is not JSON. Raised by view() and faucet()
ClientTimeoutError Subclass of httpx.TimeoutException; keeps the request; url, timeout_seconds, phase
RetryPolicy Frozen dataclass: attempts=3, base_delay_seconds=0.25, max_delay_seconds=10.0

ApiClientConfig (and SupraClientConfig) gain the keyword-only fields timeout: float = 60.0 and
retry: RetryPolicy | None = None; their positional order is unchanged.

Behaviour

  • Faucet:
    • "TryLater" (no faucet account was free; it does not count against the address) makes faucet(..., wait_for_faucet=False) return None and faucet(...) raise FaucetRequestNotAcceptedError, as documented.
    • A 200 body that is not JSON, or a JSON object whose Accepted is not a non-empty string, raises
      UnexpectedResponseError.
    • The faucet request is sent once whatever retry says, because each accepted request funds the account.
  • Retries:
    • Off by default, so one attempt as before.
    • With a RetryPolicy, a GET is repeated after 408, 429, 503 or a timeout, up to attempts in total. It waits for
      what the server asks (body or Retry-After), and raises at once if the server asks for longer than
      max_delay_seconds. Otherwise it backs off exponentially.
    • POST requests (submit, simulate, view) are never repeated.
  • Timeout: connect, read, write and pool timeouts are all timeout seconds. The pool timeout was unlimited.
  • Breaking:
    • ApiError.message is now the node's message, not the whole response body; the body is ApiError.body.
      str(error) keeps its format and still contains the node's message.
    • Timeouts raise ClientTimeoutError instead of the specific httpx timeout class; except httpx.TimeoutException still catches it.
    • A faucet 200 whose body is {} or whose Accepted is empty or not a string now raises
      UnexpectedResponseError. Before, faucet(..., wait_for_faucet=False) returned None, "" or the value as
      it was, and faucet(...) raised FaucetRequestNotAcceptedError; a caller catching that error for these bodies
      no longer catches them.
    • A faucet 200 whose body is not JSON used to raise json.JSONDecodeError, a ValueError. It now raises
      UnexpectedResponseError, which except ValueError does not catch.
    • view() without result raises UnexpectedResponseError, which is still a KeyError. A non-JSON 200 from
      view() used to raise json.JSONDecodeError and now raises UnexpectedResponseError.
  • Parsing an error body never raises anything other than the ApiError it builds (non-JSON, deeply nested, or
    out-of-range values are kept as text or ignored). This includes the 404 check made while wait_for_transaction
    and faucet() wait.

Checks

  • make fmt-check, make lint, make typecheck: clean.
  • After merging master and the review fixes: make test-unit: 372 tests, OK (26 node-backed tests skipped). tests/unit/test_api_errors.py and
    tests/unit/test_faucet.py run over httpx.MockTransport. They cover:
    • "TryLater" with both wait_for_faucet values, and the malformed faucet bodies;
    • a typed 429 with retry_after_seconds from the body, from the header and from an HTTP date;
    • a 408 then a 200 on a GET with retry enabled, which returns the 200 body;
    • that POSTs and the faucet are never repeated, and that no wait follows the last attempt;
    • ApiClientConfig(timeout=...) setting the request and pool timeouts;
    • a 410 with and without x-supra-oldest-block;
    • the unexpected-shape error and its message for each malformed faucet body;
    • a deeply nested 404 body while waiting for a transaction;
    • an out-of-range retry delay;
    • pickling and .request of ClientTimeoutError.
  • make test-spec: 248 scenarios passed. make build: sdist and wheel built.
  • Node-backed tests against a supra_node_v12.0.0.rc7 and a supra_node_v11.7.2 localnet, before the merge with
    master: every test passed except the ones then failing on master for reasons outside this change, which test: run the node-backed tests and the automation example on v11.7 and v12 nodes #37
    fixed.

🤖 Generated with Claude Code

isaacdoidge and others added 5 commits November 4, 2026 01:58
…ET retries

A response with a status other than 200 raises an ApiError whose message is
the `message` field of the node's JSON error body, falling back to the body
and then to the HTTP reason phrase. The error also keeps the response
headers and body. 404, 408, 410, 429 and 503 raise subclasses of ApiError:
ApiNotFoundError, ApiRequestTimeoutError, ApiGoneError (with oldest_block,
from the x-supra-oldest-block header), ApiRateLimitError (with
retry_after_seconds, from the body's retry_after_seconds or the Retry-After
header) and ApiServiceUnavailableError.

ApiClientConfig gains two keyword-only fields. `timeout` (seconds, default
60) limits connecting, reading, writing and waiting for a pooled
connection; the pool wait had no limit before. `retry` takes a RetryPolicy
that repeats GET requests after a 408, 429 or 503 response or a timeout, up
to a bounded number of attempts, waiting for the time the response asks
for (up to a cap) or an exponential backoff. It defaults to None, so no
request is repeated unless the caller sets it. POST requests are never
repeated. A request that exceeds the timeout raises ClientTimeoutError, a
subclass of httpx.TimeoutException.

A view response without `result` raises UnexpectedResponseError, which
names the field and subclasses both ApiError and KeyError.

The new classes and RetryPolicy are exported from supra_sdk.clients.

BREAKING CHANGE: ApiError.message is the node's error message instead of
the whole response body; the body is ApiError.body. A request timeout
raises ClientTimeoutError instead of the specific httpx timeout class
(ReadTimeout, ConnectTimeout, ...); `except httpx.TimeoutException` still
matches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… request

The node answers a faucet request that no faucet account is free to fund
with 200 and the JSON string "TryLater". faucet() read it as an object and
raised AttributeError. It now returns None for it, so
faucet(..., wait_for_faucet=False) returns None and faucet(...) raises
FaucetRequestNotAcceptedError, as documented. A 200 body that is neither a
JSON object nor "TryLater" raises UnexpectedResponseError.

The faucet request is sent once even when ApiClientConfig.retry is set,
because each accepted request funds the account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly and reject malformed faucet answers

ClientTimeoutError carries the request of the httpx timeout it replaces,
so `error.request` works as it did for httpx.ReadTimeout, and it survives
pickling.

Parsing an error response raises nothing but the ApiError: a body nested
beyond the recursion limit is kept as text, and a retry_after_seconds,
Retry-After or x-supra-oldest-block value out of the float or int range is
ignored. A timeout outside the float range is a ValueError. The backoff
exponent is capped, so a large RetryPolicy.attempts cannot overflow.

faucet() raises UnexpectedResponseError for a 200 body that is not JSON,
and for an object whose Accepted is missing or not a non-empty string,
where it returned None before. None now means only "TryLater".

The ApiNotFoundError, ApiError and README texts now say that v11.7 nodes
answer a missing resource with 404, and that only view() and faucet()
raise UnexpectedResponseError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…faucet errors

- `wait_for_transaction` and `faucet()` treat a 404 whose body is nested
  deeper than the interpreter's recursion limit as an ordinary 404 and raise
  it, instead of failing with a bare RecursionError.
- `UnexpectedResponseError` takes an optional `problem` that completes its
  message. A faucet 200 whose `Accepted` is empty or not a string says so, a
  body that is not JSON says so, and a missing field keeps the existing text.
- README describes the faucet outcomes: `"TryLater"`, non-JSON bodies and an
  `Accepted` that is missing, empty or not a string.
- `retry_after_seconds` documents the string forms it accepts.
- Tests cover an out-of-range integer retry delay.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@isaacdoidge
isaacdoidge merged commit 4970e00 into master Nov 4, 2026
5 checks passed
@isaacdoidge
isaacdoidge deleted the fix/typed-errors branch November 4, 2026 01:46
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