Skip to content
Merged
13 changes: 13 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ ClientError (catch-all for anything httpware raises)
└── ResponseTooLargeError (response body exceeds max_response_body_bytes; status-agnostic)
```

## Mapping `httpx2` exceptions

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-to-exception mapping

| Status | Exception class |
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ async def main() -> None:

`base_url` must not contain a query string; a client built with one raises `ValueError`. Put query parameters shared by every request in `params=` instead.

The other keywords of `httpx2.AsyncClient` and `httpx2.Client` (`verify`, `proxy`, `http2`, `transport`, `follow_redirects`, ...) are passed through to the `httpx2` client that httpware builds and closes. Two are refused. `cert` is deprecated by `httpx2` in favour of an `ssl.SSLContext` passed as `verify`. `event_hooks` run below the middleware chain, so use [middleware](middleware.md) instead.
The other keywords of `httpx2.AsyncClient` and `httpx2.Client` (`verify`, `proxy`, `http2`, `transport`, `follow_redirects`, ...) are passed through to the `httpx2` client that httpware builds and closes. An option set to `None` counts as not passed, so `httpx2` keeps its default, with one exception: `timeout=None` turns the timeout off. Leave `timeout` out to keep the `httpx2` default of 5 seconds. Two options are refused. `cert` is deprecated by `httpx2` in favour of an `ssl.SSLContext` passed as `verify`. `event_hooks` run below the middleware chain, so use [middleware](middleware.md) instead.

```python
import ssl
Expand Down
2 changes: 1 addition & 1 deletion docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ with Client(base_url="https://api.example.com", middleware=[add_request_id]) as
client.get("/users/1")
```

Sync and async middleware don't mix: pass `Middleware` to `Client` and `AsyncMiddleware` to `AsyncClient`.
Sync and async middleware don't mix: pass `Middleware` to `Client` and `AsyncMiddleware` to `AsyncClient`. `Client` raises `TypeError` when you build it with async middleware, such as `AsyncRetry` or a function wrapped by `@async_before_request`.

## See also

Expand Down
2 changes: 2 additions & 0 deletions docs/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ If the request body was an async iterable, `AsyncRetry` does not retry, because
httpware: not retrying because the request body is a stream that cannot replay across attempts
```

The same applies to a request you build yourself and pass to `send()` or `send_with_response()`, whether it comes from `client.build_request()` or `httpx2.Request()`. `send()` marks it as streaming when its body is not held in memory and is not a multipart upload, so `content=` with a generator is marked, while `content=b"..."`, `json=`, `data=` and `files=` are not. A body built from a list of chunks also counts as streaming here, because `send()` cannot tell it from a generator; `client.post()` and the other verb methods see the original argument and don't mark it.

The note, and the `retry.streaming_refused` event on `httpware.retry`, appear only when the failure would otherwise have been retried. A POST with a streaming body, for example, is re-raised without the note because POST is not retried in the first place. See [Observability](observability.md).

### Exhaustion behavior
Expand Down
28 changes: 20 additions & 8 deletions src/httpware/_internal/exception_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

map_httpx2_exception is a pure function used by both Client._terminal and
AsyncClient._terminal, and by both stream() methods. Clause ordering:
TimeoutException -> InvalidURL/CookieConflict -> NetworkError -> HTTPError
(subclass before parent so the right type wins). The two context managers
below wrap it for use as `with`/`async with` blocks around the httpx2 call.
TimeoutException -> InvalidURL/CookieConflict -> NetworkError/RemoteProtocolError
-> HTTPError (subclass before parent so the right type wins). The two context managers
below wrap it for use as `with`/`async with` blocks around the httpx2 call,
and also turn the RuntimeError httpx2 raises on a closed client into TransportError.
"""

import contextlib
Expand All @@ -25,30 +26,41 @@ def map_httpx2_exception(exc: BaseException) -> NetworkError | TimeoutError | Tr
return TimeoutError(str(exc))
if isinstance(exc, (httpx2.InvalidURL, httpx2.CookieConflict)):
return TransportError(str(exc))
if isinstance(exc, httpx2.NetworkError):
if isinstance(exc, (httpx2.NetworkError, httpx2.RemoteProtocolError)):
return NetworkError(str(exc))
if isinstance(exc, httpx2.HTTPError):
return TransportError(str(exc))
return TransportError(str(exc)) # pragma: no cover — defensive default; httpx2.HTTPError is the root


@contextlib.asynccontextmanager
async def _httpx2_exception_mapper() -> AsyncGenerator[None]:
"""Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream()."""
async def _httpx2_exception_mapper(client: httpx2.AsyncClient) -> AsyncGenerator[None]:
"""Map httpx2 exceptions, and use of a closed `client`, to httpware exceptions.

Shared by AsyncClient._terminal and stream().
"""
try:
yield
except httpx2.HTTPError as exc:
raise map_httpx2_exception(exc) from exc
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
raise map_httpx2_exception(exc) from exc
except RuntimeError as exc:
if client.is_closed:
raise TransportError(str(exc)) from exc
raise


@contextlib.contextmanager
def _httpx2_exception_mapper_sync() -> Generator[None]:
"""Map httpx2 exceptions to httpware exceptions. Sync sibling of _httpx2_exception_mapper."""
def _httpx2_exception_mapper_sync(client: httpx2.Client) -> Generator[None]:
"""Sync sibling of _httpx2_exception_mapper."""
try:
yield
except httpx2.HTTPError as exc:
raise map_httpx2_exception(exc) from exc
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
raise map_httpx2_exception(exc) from exc
except RuntimeError as exc:
if client.is_closed:
raise TransportError(str(exc)) from exc
raise
9 changes: 9 additions & 0 deletions src/httpware/_internal/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@
(the consumed iterator cannot replay across attempts)."""


def _mark_streaming_body(request: httpx2.Request) -> None:
"""Set STREAMING_BODY_MARKER unless the body is in memory, multipart, or the marker is already set."""
if isinstance(request.stream, httpx2.ByteStream) or STREAMING_BODY_MARKER in request.extensions:
return
if request.headers.get("content-type", "").startswith("multipart/form-data"):
return
request.extensions[STREAMING_BODY_MARKER] = True


def _raise_on_status_error(response: httpx2.Response) -> None:
"""Raise the appropriate StatusError subclass for a 4xx/5xx response. No-op for 2xx/3xx."""
status = response.status_code
Expand Down
Loading
Loading