Every exception httpware raises subclasses ClientError. A response with a 4xx or 5xx status raises a StatusError subclass for that status, so you never call response.raise_for_status().
Client and AsyncClient raise the same classes, so from httpware import NotFoundError works for both. The resilience middleware that raise RetryBudgetExhaustedError, BulkheadFullError and CircuitOpenError are described in Resilience.
ClientError (catch-all for anything httpware raises)
├── TransportError (connection, network or protocol failure before a response)
│ └── NetworkError (transient and safe to retry; AsyncRetry retries it by default)
├── TimeoutError (also a builtins.TimeoutError, so except OSError catches it)
├── StatusError (got a response but its status was 4xx/5xx)
│ ├── ClientStatusError (any 4xx; raised as is for unmapped 4xx codes)
│ │ ├── BadRequestError (400)
│ │ ├── UnauthorizedError (401)
│ │ ├── ForbiddenError (403)
│ │ ├── NotFoundError (404)
│ │ ├── ConflictError (409)
│ │ ├── UnprocessableEntityError (422)
│ │ └── RateLimitedError (429)
│ └── ServerStatusError (any 5xx; raised as is for unmapped 5xx codes)
│ ├── InternalServerError (500)
│ └── ServiceUnavailableError (503)
├── RetryBudgetExhaustedError (a retry was needed but the budget refused)
├── BulkheadFullError (acquire_timeout elapsed before a slot opened)
├── CircuitOpenError (circuit is OPEN or HALF_OPEN probe slot taken; request not forwarded)
├── DecodeError (response_model= decoder failed; HTTP call itself succeeded)
├── MissingDecoderError (no registered decoder claims response_model=; fires before the HTTP call)
└── ResponseTooLargeError (response body exceeds max_response_body_bytes; status-agnostic)
An exception from httpx2 is re-raised as an httpware one, with the original on __cause__:
httpx2 exception |
httpware exception |
|---|---|
TimeoutException and its subclasses |
TimeoutError |
NetworkError (connect, read, write and close errors) |
NetworkError |
RemoteProtocolError, such as a server dropping a keep-alive connection mid-response |
NetworkError |
any other HTTPError, InvalidURL, CookieConflict |
TransportError |
NetworkError and TimeoutError are the transient ones: AsyncRetry and Retry retry them, and the circuit breaker counts them. An invalid URL raises TransportError from every method, whether httpx2 rejects it while the request is built or while it is sent. So does a call on a client that has been closed, stream() included.
| Status | Exception class |
|---|---|
| 400 | BadRequestError |
| 401 | UnauthorizedError |
| 403 | ForbiddenError |
| 404 | NotFoundError |
| 409 | ConflictError |
| 422 | UnprocessableEntityError |
| 429 | RateLimitedError |
| 500 | InternalServerError |
| 503 | ServiceUnavailableError |
| other 4xx | ClientStatusError (fallback) |
| other 5xx | ServerStatusError (fallback) |
Only statuses from 400 to 599 raise. Any other status returns the response unchanged.
The numbered rows are exported as STATUS_TO_EXCEPTION, a Mapping[int, type[StatusError]], so you can look up a class in code with STATUS_TO_EXCEPTION.get(404). The two fallback rows are not in the mapping. The mapping is read-only, and assigning to it raises TypeError. To raise your own class for a status, catch the httpware error in a middleware and raise yours from it.
The examples below assume a module logger in your own namespace (not under httpware.*): _LOGGER = logging.getLogger("myapp").
import logging
from httpware import (
AsyncClient,
ClientError,
StatusError,
NetworkError,
TimeoutError,
NotFoundError,
RetryBudgetExhaustedError,
BulkheadFullError,
)
_LOGGER = logging.getLogger("myapp")
async def fetch(client: AsyncClient, user_id: int) -> dict | None:
try:
return await client.get(f"/users/{user_id}", response_model=dict)
except NotFoundError:
# The most specific catch: treat a missing user as None.
return None
except StatusError as exc:
# Any other 4xx or 5xx. exc.response has the headers, body and request.
_LOGGER.warning("upstream returned %s for %s", exc.response.status_code, exc.response.request.url)
raise
except NetworkError:
# Transient transport failure. If the client has AsyncRetry, an idempotent
# request was already retried and this is the last attempt's error.
raise
except (RetryBudgetExhaustedError, BulkheadFullError) as exc:
# A resilience middleware refused the call; ease off upstream.
_LOGGER.error("resilience refused: %s", exc)
raise
except ClientError:
# Anything else httpware raised.
raisehttpware.TimeoutError also subclasses builtins.TimeoutError, the class asyncio.wait_for raises, so except builtins.TimeoutError and except OSError both catch it.
For any StatusError subclass, the raw httpx2.Response is on exc.response:
exc.response.status_code # 404
exc.response.headers # httpx2.Headers, case-insensitive
exc.response.content # raw bytes
exc.response.text # decoded body
exc.response.json() # parsed JSON (raises if not JSON)
exc.response.request # the failing httpx2.Request
exc.response.request.url # the failing URL (httpx2.URL)
exc.response.request.method # the HTTP methodThe exception's repr and message strip user:pass@ userinfo and replace every query-parameter value with REDACTED, keeping the keys, so ?page=2&X-Amz-Signature=abc shows as ?page=REDACTED&X-Amz-Signature=REDACTED. Fragment values get the same treatment, and a fragment without =, like #section-3, is kept. The unredacted URL is still on exc.response.request.url. Request headers such as Authorization and Cookie are never redacted, and exc.response.request.headers returns them in full.
RetryBudgetExhaustedError carries:
last_response: httpx2.Response | None: the last response before the budget refused, orNoneif every failure was at the transport level.last_exception: BaseException | None: the last exception before the budget refused.attempts: int: how many attempts were completed.
BulkheadFullError carries:
max_concurrent: int: the configured cap.acquire_timeout: float | None: the configured timeout.
CircuitOpenError carries:
retry_after: float | None: seconds until the circuit admits its next probe, orNonewhen a probe is already in flight.
You can use these fields in your own logging and alerts:
except RetryBudgetExhaustedError as exc:
_LOGGER.error(
"budget exhausted after %d attempts; last_status=%s",
exc.attempts,
exc.last_response.status_code if exc.last_response is not None else None,
)DecodeError means the HTTP call succeeded but the decoder could not turn the body into the response_model= type. Every decoder's failures are wrapped this way, whether it is PydanticDecoder, MsgspecDecoder or your own, so except httpware.ClientError covers decoding too.
Fields:
response: httpx2.Response: the response whose body failed to decode, with its status, headers andrequest.model: type: the type passed asresponse_model=.original: BaseException: the decoder's own exception, such aspydantic.ValidationError,msgspec.ValidationErrorormsgspec.DecodeError. It is alsoexc.__cause__.
The message names the model and the type of original, and adds the error count when original has an error_count() method, as pydantic.ValidationError does:
failed to decode response into User: ValidationError (2 errors)
A model that is not a class, such as User | None or list[User], appears as its repr. The message never quotes the body. str(exc.original) can: a pydantic ValidationError echoes the input values it rejected, so check what it holds before you log it.
from httpware import AsyncClient, DecodeError
try:
user = await client.get("/users/1", response_model=User)
except DecodeError as exc:
_LOGGER.error("%s", exc)
raisesend(), send_with_response() and the verb methods raise MissingDecoderError when response_model= is set and no registered decoder accepts the type. It carries:
model: type: theresponse_model=value nobody accepted.registered_names: tuple[str, ...]: class names of the decoders that rejected it. An empty tuple means no decoders were registered.
The message reads no decoder for response_model=<Model>: <hint>. There are two hints.
-
No decoders were registered. Install an extra or pass a decoder list:
no decoders registered. Install `pip install httpware[pydantic]` or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly. -
Every registered decoder rejected the type. Neither built-in handles it, so pass your own
ResponseDecoderindecoders=[...]:registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it. Pass a custom decoder via decoders=[...].
Unlike DecodeError, this error is raised before the request is sent.
Both clients accept max_response_body_bytes: int | None = None. By default there is no limit. When it is set, a response body larger than the cap raises ResponseTooLargeError instead of being returned, whatever the status: a 200 trips it as easily as a 500. The cap counts decoded bytes, after decompression. It applies to send() and the verb methods, and to the error body that stream() reads before raising a StatusError. Bytes you read yourself while iterating a stream() are never capped, except with an auth that sets requires_response_body: that auth needs every body, so stream() reads the final one under the cap before yielding it. With a cap set, httpware runs the client's auth flow and, with follow_redirects=True, follows the redirects itself, and caps only the final response. It reads the body of each intermediate response, such as a redirect or a DigestAuth challenge, and throws it away, so the connection can be reused and the responses in response.history have no content. An intermediate body declared or read past the cap is not an error: httpware stops reading it and closes that connection. An auth that sets requires_response_body, like a token refresh that parses the token endpoint's JSON, gets each response read under the cap instead. Client auth is sent to the first URL only. httpx2 keeps its Authorization header on a redirect within the same origin or from http to https on the same host, and drops it otherwise.
A response read under the cap keeps elapsed, next_request and the client's default_encoding. A response without Content-Encoding is the one httpx2 built, with the same headers and num_bytes_downloaded it has without a cap. A response with Content-Encoding is rebuilt from its decoded body: it loses the Content-Encoding and Transfer-Encoding headers, its Content-Length is the decoded size, and num_bytes_downloaded is 0.
ResponseTooLargeError carries:
status_code: int: the response's HTTP status code.limit: int: themax_response_body_bytesvalue that was exceeded.content_length: int | None: theContent-Lengththe server declared, if any.reason: Literal["declared", "streamed"]: why the cap tripped."declared": the declaredContent-Lengthwas already overlimit, so no body was read.content_lengthholds that value."streamed": the decoded body passedlimitwhile being read, as with chunked transfer or a compression bomb. Reading stops there, so the real size is unknown andcontent_lengthis only what the server declared, which may be missing or too small.
ResponseTooLargeError is a ClientError but not a StatusError: it has no response and is not in STATUS_TO_EXCEPTION. AsyncRetry does not retry it and the circuit breaker does not count it.
from httpware import AsyncClient, ResponseTooLargeError
client = AsyncClient(base_url="https://api.example.com", max_response_body_bytes=1_000_000)
try:
await client.get("/reports/huge")
except ResponseTooLargeError as exc:
_LOGGER.error("response too large: limit=%d reason=%s content_length=%s", exc.limit, exc.reason, exc.content_length)
raise- Resilience: the middleware that raise
RetryBudgetExhaustedError,BulkheadFullErrorandCircuitOpenError. - Middleware: the
@async_on_errordecorator can turn exceptions into responses. src/httpware/errors.py: the exception classes.