httpware raises typed exceptions automatically — everything inherits ClientError, and HTTP responses with 4xx/5xx status raise status-keyed StatusError subclasses without you having to call response.raise_for_status().
For the resilience-specific errors (RetryBudgetExhaustedError, BulkheadFullError, CircuitOpenError) see the Resilience reference.
The status-keyed exception tree is shared between Client and AsyncClient. Catching NotFoundError in sync code uses the same import as catching it in async code (from httpware import NotFoundError).
ClientError (catch-all for anything httpware raises)
├── TransportError (connection/network/protocol failure pre-response)
│ └── NetworkError (transient — safe to retry; covered by AsyncRetry's defaults)
├── TimeoutError (also inherits builtins.TimeoutError — except OSError catches it)
├── StatusError (got a response but its status was 4xx/5xx)
│ ├── ClientStatusError (any 4xx — fallback for unknown 4xx codes)
│ │ ├── BadRequestError (400)
│ │ ├── UnauthorizedError (401)
│ │ ├── ForbiddenError (403)
│ │ ├── NotFoundError (404)
│ │ ├── ConflictError (409)
│ │ ├── UnprocessableEntityError (422)
│ │ └── RateLimitedError (429)
│ └── ServerStatusError (any 5xx — fallback for unknown 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)
| 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) |
The fallback assumes 400 ≤ status < 600. Statuses outside that range don't raise (they return the response as-is).
The explicit rows above are also exported as the public STATUS_TO_EXCEPTION mapping (Mapping[int, type[StatusError]]) — from httpware import STATUS_TO_EXCEPTION — so you can look up the class for a status code programmatically (e.g. STATUS_TO_EXCEPTION.get(404)). The two fallback rows are not in the mapping; they're applied by the raise logic for any unmapped in-range status.
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:
# Specific status — most precise. Convert to None as the "absent" sentinel.
return None
except StatusError as exc:
# Got a response, but its status was 4xx/5xx and not one we handle specifically.
# exc.response.* is available — headers, content, request, etc.
_LOGGER.warning("upstream returned %s for %s", exc.response.status_code, exc.response.request.url)
raise
except NetworkError:
# Transient transport failure. Already retried by the default AsyncRetry middleware
# (if installed) when the method was idempotent. Seeing this means retries
# exhausted or the method was non-idempotent.
raise
except (RetryBudgetExhaustedError, BulkheadFullError) as exc:
# Resilience refusal — backpressure signal. Back off the caller.
_LOGGER.error("resilience refused: %s", exc)
raise
except ClientError:
# Catch-all for anything else httpware raised.
raiseTimeoutError is doubly-inherited: except builtins.TimeoutError and except OSError both catch it (matches what asyncio.wait_for raises). This lets stdlib-style timeout handling Just Work.
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 methodSecurity note: __repr__ and the exception's summary message strip user:pass@ userinfo and mask the values of known-sensitive query and URL-fragment parameters (api_key, apikey, access_token, refresh_token, token, secret, client_secret, password, passwd, pwd, auth, authorization, sig, signature, key, private_key, session, sessionid, x-api-key) as REDACTED, preserving the keys. Query values under other names are not masked, so still avoid putting non-standard secrets in query strings. Note that request headers (Authorization, Cookie, etc.) are never redacted — see exc.response.request.headers above.
RetryBudgetExhaustedError carries:
last_response: httpx2.Response | None— the last response observed before the budget refused (None if all failures were transport-level)last_exception: BaseException | None— the last exception observed before the budget refusedattempts: int— number of attempts already completed
BulkheadFullError carries:
max_concurrent: int— the configured capacquire_timeout: float | None— the configured timeout
CircuitOpenError carries:
retry_after: float | None— seconds until the circuit will next admit a probe;Nonewhen a concurrent probe is already in flight (HALF_OPEN slot taken).
Use these for caller-side logging / alerting:
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 is raised when response_model= is set on a request and the active ResponseDecoder failed to parse the response body. The HTTP call itself succeeded — status was 2xx/3xx and the transport delivered the body intact — but the body could not be coerced into the requested model. The exception is raised independently of which decoder is in use (PydanticDecoder, MsgspecDecoder, or a third-party adapter), so except httpware.ClientError is sufficient to cover the response-model decode path.
Fields:
response: httpx2.Response— the response whose body failed to decode. Status, headers, and the originatingrequestare all available viaexc.response.*.model: type— the type that was passed asresponse_model=.original: BaseException— the underlying library exception (e.g.,pydantic.ValidationError,msgspec.ValidationError,msgspec.DecodeError). Also available viaexc.__cause__.
from httpware import AsyncClient, DecodeError
try:
user = await client.get("/users/1", response_model=User)
except DecodeError as exc:
_LOGGER.error(
"decode failed for %s into %s: %s",
exc.response.request.url,
exc.model.__name__,
exc.original,
)
raiseRaised by send() / send_with_response() / verb methods when response_model= is set but no registered decoder claims the model. Carries:
model: type— theresponse_model=value that wasn't claimed.registered_names: tuple[str, ...]— class names of the registered decoders that all rejected the model. Empty tuple means no decoders were registered.
The message reads no decoder for response_model=<Model>: <hint>, and the corrective action depends on the hint. The two hints, verbatim:
-
No decoders were registered — install an extra or pass an explicit decoder list:
no decoders registered. Install `pip install httpware[pydantic]` or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly. -
Registered decoders all rejected the model — your
response_modeltype is exotic enough that neither built-in claims it; pass a customResponseDecoderviadecoders=[...]:registered decoders (PydanticDecoder + MsgspecDecoder) all rejected it. Pass a custom decoder via decoders=[...].
Unlike DecodeError, this error fires before the HTTP request — no traffic is sent.
Both Client and AsyncClient accept a max_response_body_bytes: int | None = None constructor argument. It's an opt-in cap — the default None means unbounded, matching current behavior. When set, a response body that exceeds the cap raises ResponseTooLargeError instead of being returned. The check is status-agnostic (a 200 can trip it just as easily as a 4xx/5xx), and it counts decoded bytes. It fires from the non-streaming terminal (send() / verb methods) and from stream()'s internal error pre-read; bytes you pull yourself via stream() iteration are never capped.
ResponseTooLargeError carries:
status_code: int— the response's HTTP status code.limit: int— the configuredmax_response_body_bytesvalue that was exceeded.content_length: int | None— the server-declaredContent-Length, when known.reason: Literal["declared", "streamed"]— which trip mode fired:"declared"— the declaredContent-Lengthalready exceededlimit; the body was rejected before any byte was read, andcontent_lengthholds the offending value."streamed"— the decoded body crossedlimitmid-read (the chunked-transfer or compression-bomb case); the true oversized length is unknown by design, socontent_lengthis whatever (possibly absent or understated) value the server declared.
It is a non-status ClientError — it does not carry a StatusError-style positional response and is not in STATUS_TO_EXCEPTION. Because it's neither a StatusError, NetworkError, nor TimeoutError, it is not retried by AsyncRetry and does not count toward the circuit breaker.
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 reference —
AsyncRetry,RetryBudget,AsyncBulkheadparameter tables. - Middleware guide — the
@async_on_errordecorator can translate exceptions into responses. architecture/errors.md— the formal exception contract.