Error handling¶
Two separate failure classes, never unified:
HTTPResponseError— the server answered with a 4xx/5xx. Carries.status,.headers(the error response's own, e.g. aRetry-Afteronce retries are exhausted, or a request id to quote to support),.body(the whole body) and.body_start(its first 100 bytes, as used in the message),.parsed_body, and.request(the request that got this response — method/url/path/host; handy for telling which call failed when several are in flight at once). It pickles, so it can cross a process boundary (multiprocessing, a task queue).HTTPTransportError— no usable response: either none arrived at all, or the connection failed partway through one (a body cut short raisesHTTPConnectionError). Its subclasses areHTTPTimeoutErrorandHTTPConnectionError. pyreqwest's own exception types never leak through; they're translated to these at every call site.
Using a client after its with/async with block has exited raises a plain RuntimeError: that's
a bug in the calling code, not a network failure, so it deliberately isn't an HTTPTransportError
that a retry handler would swallow. And a response_data_type=dict body that isn't valid JSON
raises the stdlib's own json.JSONDecodeError, like any other decode failure.
from lothc import HTTPClient, HTTPConnectionError, HTTPResponseError, HTTPTimeoutError
async with HTTPClient(base_url="https://pokeapi.co/api/v2/") as client:
try:
await client.get("pokemon/does-not-exist")
except HTTPResponseError as e:
print(e.status, e.body_start)
print(e.request.method, e.request.path) # "GET", "/api/v2/pokemon/does-not-exist"
except HTTPTimeoutError:
print("took too long")
except HTTPConnectionError:
print("never reached the server")
Typed error bodies¶
.parsed_body is None unless you pass error_type — every verb takes it, mirroring
response_data_type, and it decodes the 4xx/5xx body onto HTTPResponseError.parsed_body
instead of leaving it None:
from pydantic import BaseModel
class APIError(BaseModel):
type: str
title: str
status: int
async with HTTPClient(base_url="https://api.example.com/") as client:
try:
await client.get("items/7", error_type=APIError)
except HTTPResponseError as e:
print(e.parsed_body) # APIError(type=..., title=..., status=...)
Decoding an error body is best-effort. If the body doesn't match error_type (the classic case:
a proxy in front of a JSON API answers 502 with an HTML page), you still get the
HTTPResponseError, with its real status, headers and body; .parsed_body is None and
.parse_error holds why it didn't parse (the decode library's own exception):
try:
await client.get("items/7", error_type=APIError)
except HTTPResponseError as e:
if e.parsed_body is None:
print(e.status, e.parse_error) # 502, ValidationError(...)
On the success path it's the opposite: a response_data_type body that doesn't match raises the
decode library's own error (pydantic.ValidationError, msgspec.ValidationError), unwrapped,
since choosing that library is opting into its exception too. An error body is where a server is
least likely to honour its own contract, so failing to decode one must never hide the error
itself.
OAuthTokenError¶
The one exception to the rule above. OAuthProvider/SyncOAuthProvider (see
Authentication) run inside whatever request needed the
token, so a failure at the token endpoint would otherwise surface as a failure of that unrelated
call — an HTTPResponseError whose .status is really the auth server's, a
pydantic.ValidationError from a token response you never asked that call to decode. Every
failure to obtain a token is therefore raised as OAuthTokenError, with the original exception as
__cause__ and the endpoint in .token_url:
from lothc import HTTPClient, HTTPResponseError, OAuthProvider, OAuthTokenError
async with HTTPClient(base_url=..., bearer_auth=OAuthProvider(...)) as client:
try:
await client.get("items/7")
except OAuthTokenError as e:
print(e.token_url) # the token endpoint, not "items/7"
if isinstance(e.__cause__, HTTPResponseError):
print(e.__cause__.status) # what the token endpoint answered
except HTTPResponseError as e:
print(e.status) # an error from "items/7" itself
A response_data_type decode error is never wrapped because it already belongs to the call that
raised it; a token decode error is wrapped because it doesn't.