Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions architecture/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
`StatusError` and all its 4xx/5xx subclasses are constructed with a **single positional `response: httpx2.Response`**. Subclasses do not override `__init__`. All fields are available via `exc.response.*` (status code, headers, content, request, etc.).

```python
raise NotFoundError(response) # correct
exc.response.status_code # 404
exc.response.request.url # URL of the failed request
raise NotFoundError(response) # correct
exc.response.status_code # 404
exc.response.request.url # URL of the failed request
```

`__repr__` and the `str()` summary redact URL userinfo (`user:pass@`) and mask the values of known-sensitive query and fragment parameters (e.g. `token`, `api_key`, `secret`) to avoid leaking credentials in tracebacks.
Expand Down
5 changes: 1 addition & 4 deletions docs/decoders.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,7 @@ class CsvDecoder:
(row_type,) = typing.get_args(model)
field_types = {f.name: f.type for f in dataclasses.fields(row_type)}
reader = csv.DictReader(io.StringIO(content.decode("utf-8")))
return [
row_type(**{name: field_types[name](value) for name, value in row.items()})
for row in reader
]
return [row_type(**{name: field_types[name](value) for name, value in row.items()}) for row in reader]
```

`can_decode` is total and never raises: a non-`list` model, a bare `list`, or `list[int]` all fall through to `False`. `decode` coerces each CSV cell with its field's type (CSV values arrive as strings) — a real decoder would handle optionals, dates, and missing columns; this is where your domain logic goes. Wire it ahead of the built-ins so it gets first refusal on `list[...]` models while pydantic still handles everything else:
Expand Down
16 changes: 8 additions & 8 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,14 @@ async def fetch(client: AsyncClient, user_id: int) -> dict | None:
For any `StatusError` subclass, the raw `httpx2.Response` is on `exc.response`:

```python
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 method
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 method
```

**Security 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.
Expand Down
4 changes: 3 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ import asyncio

from httpware import AsyncClient


async def main() -> None:
async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
response = await client.get("/users/1")
print(response.json())


asyncio.run(main())
```

Expand Down Expand Up @@ -102,7 +104,7 @@ async def main() -> None:
base_url="https://api.example.com",
middleware=[
AsyncBulkhead(max_concurrent=10), # cap total in-flight
AsyncRetry(), # default: 3 attempts, full-jitter backoff
AsyncRetry(), # default: 3 attempts, full-jitter backoff
],
) as client:
user = await client.get("/users/1", response_model=User)
Expand Down
4 changes: 2 additions & 2 deletions docs/recipes/link-header-pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ async def main() -> None:
response, tags = await client.send_with_response(request, response_model=list[Tag])
for tag in tags:
process(tag)
url = next_link(response.headers.get("link")) # caller's parser
params = None # next link carries query
url = next_link(response.headers.get("link")) # caller's parser
params = None # next link carries query
```

`process` and `next_link` are caller-defined. Pick a Link-header parser that fits your project — there are several on PyPI, and the format is small enough to hand-roll.
Expand Down
1 change: 1 addition & 0 deletions docs/recipes/modern-di.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class ServiceClients(Group):
cache_settings=providers.CacheSettings(finalizer=AsyncClient.aclose),
)


# At Container(...) construction:
# modern_di.exceptions.DuplicateProviderTypeError: Provider is duplicated by type
# <class 'httpware.client.AsyncClient'>. To resolve this issue: ...
Expand Down
6 changes: 4 additions & 2 deletions docs/recipes/phase-decorator-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ from httpware import async_before_request


_CORRELATION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"correlation_id", default=None,
"correlation_id",
default=None,
)


Expand Down Expand Up @@ -132,7 +133,8 @@ from httpware import async_on_error

@async_on_error
async def fallback_on_network_error(
request: httpx2.Request, exc: Exception,
request: httpx2.Request,
exc: Exception,
) -> httpx2.Response | None:
if isinstance(exc, NetworkError):
return httpx2.Response(
Expand Down
6 changes: 3 additions & 3 deletions docs/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ When `acquire_timeout` elapses without a slot opening, `AsyncBulkhead` raises `B

```python
from httpware.middleware.resilience import AsyncCircuitBreaker # async
from httpware.middleware.resilience import CircuitBreaker # sync
from httpware.middleware.resilience import CircuitBreaker # sync
```

Classic consecutive-failure circuit breaker. Counts failures and prevents requests from reaching a downstream that is known to be broken.
Expand Down Expand Up @@ -208,8 +208,8 @@ from httpware.middleware.resilience import AsyncCircuitBreaker

breaker = AsyncCircuitBreaker(
failure_rate_threshold=0.5, # open at ≥50% failures
window_seconds=30.0, # over a rolling 30s window
minimum_calls=20, # but only once 20+ calls are observed
window_seconds=30.0, # over a rolling 30s window
minimum_calls=20, # but only once 20+ calls are observed
)
```

Expand Down
5 changes: 5 additions & 0 deletions planning/audits/2026-06-07-deep-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,10 @@ async def test_on_error_lets_cancelled_propagate() -> None:
@async_on_error
async def swallow_all(request, exc) -> httpx2.Response | None:
raise AssertionError("should not catch CancelledError")

async def terminal(request):
raise asyncio.CancelledError

dispatch = compose_async((swallow_all,), terminal)
with pytest.raises(asyncio.CancelledError):
await dispatch(_make_request())
Expand All @@ -556,7 +558,10 @@ Suggested direction: add `test_on_error_lets_keyboardinterrupt_propagate` (and o

```python
"""Tests for the per-method API surface of AsyncClient."""

from httpware import AsyncClient, NotFoundError


def _client_with_handler(handler, **kwargs) -> AsyncClient: ...
async def test_get_returns_httpx2_response() -> None: ...
```
Expand Down
20 changes: 14 additions & 6 deletions planning/audits/2026-06-14-deep-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ The discover map labels the file "Hypothesis property-based tests for retry inte

```python
async def test_total_attempts_never_exceeds_max_attempts(
max_attempts: int, status: int, method: str,
max_attempts: int,
status: int,
method: str,
) -> None:
...
await client.request(method, "https://example.test/x")
Expand Down Expand Up @@ -270,8 +272,15 @@ CLAUDE.md and `architecture/errors.md` mandate that all `StatusError` subclasses
```python
def test_inheritance_tree() -> None:
...
for exc in (BadRequestError, UnauthorizedError, ForbiddenError, ForbiddenError,
ConflictError, UnprocessableEntityError, RateLimitedError):
for exc in (
BadRequestError,
UnauthorizedError,
ForbiddenError,
ForbiddenError,
ConflictError,
UnprocessableEntityError,
RateLimitedError,
):
assert issubclass(exc, ClientStatusError), exc
```

Expand Down Expand Up @@ -334,6 +343,7 @@ def _is_streaming_body_async(value: object) -> bool:
...
return hasattr(value, "__aiter__")


def _is_streaming_body_sync(value: object) -> bool:
...
return hasattr(value, "__iter__")
Expand Down Expand Up @@ -410,9 +420,7 @@ The test asserts `len(budget._deposits) == expected_deposits`, relying on a comm

```python
expected_deposits = (_N_SYNC_THREADS * _N_OPS_PER_THREAD) + _N_ASYNC_TASKS
assert len(budget._deposits) == expected_deposits, (
f"expected {expected_deposits} deposits, got {len(budget._deposits)}"
)
assert len(budget._deposits) == expected_deposits, f"expected {expected_deposits} deposits, got {len(budget._deposits)}"
```

Panel 2/3: code_reality, reproducer. Suggested direction: pin the injected clock so no real time elapses, making the no-purge assumption an enforced invariant rather than a fragile comment.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,22 @@ def with_headers(self, headers: Mapping[str, str]) -> Self:
"""Return a copy with the given headers merged in (incoming keys override existing)."""
return dataclasses.replace(self, headers={**self.headers, **headers})


def with_cookie(self, name: str, value: str) -> Self:
"""Return a copy with the given cookie added or replaced."""
return dataclasses.replace(self, cookies={**self.cookies, name: value})


def with_cookies(self, cookies: Mapping[str, str]) -> Self:
"""Return a copy with the given cookies merged in (incoming keys override existing)."""
return dataclasses.replace(self, cookies={**self.cookies, **cookies})


def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401
"""Return a copy with the given extension entry added or replaced."""
return dataclasses.replace(self, extensions={**self.extensions, name: value})


def with_extensions(self, extensions: Mapping[str, Any]) -> Self:
"""Return a copy with the given extensions merged in (incoming keys override existing)."""
return dataclasses.replace(self, extensions={**self.extensions, **extensions})
Expand All @@ -92,6 +96,7 @@ def with_headers(self, headers: Mapping[str, str]) -> Self:
"""Return a copy with the given headers merged in (incoming keys override existing)."""
return dataclasses.replace(self, headers={**self.headers, **headers})


def with_status(self, status: int) -> Self:
"""Return a copy with the given status code."""
return dataclasses.replace(self, status=status)
Expand Down
5 changes: 1 addition & 4 deletions planning/changes/2026-05-31.06-msgspec-decoder-via-extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,7 @@ if import_checker.is_msgspec_installed:
import msgspec


MISSING_DEPENDENCY_MESSAGE = (
"MsgspecDecoder requires the 'msgspec' extra. "
"Install with: pip install httpware[msgspec]"
)
MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"

T = TypeVar("T")

Expand Down
40 changes: 27 additions & 13 deletions planning/changes/2026-05-31.07-asyncclient.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,7 @@ class AsyncClient:
) -> None:
normalized_timeout = _normalize_timeout(timeout)
resolved_limits = limits or Limits()
resolved_transport = transport or Httpx2Transport(
limits=resolved_limits, timeout=normalized_timeout
)
resolved_transport = transport or Httpx2Transport(limits=resolved_limits, timeout=normalized_timeout)
resolved_decoder = decoder or PydanticDecoder()
resolved_middleware = tuple(middleware) if middleware is not None else ()

Expand Down Expand Up @@ -179,9 +177,7 @@ def _build_request(
Body builder:

```python
def _build_body(
json_value: Any | None, content: bytes | None
) -> tuple[bytes | None, str | None]:
def _build_body(json_value: Any | None, content: bytes | None) -> tuple[bytes | None, str | None]:
if json_value is not None and content is not None:
raise TypeError("pass either `json` or `content`, not both")
if json_value is not None:
Expand All @@ -206,6 +202,7 @@ async def get(
response_model: None = None,
) -> Response: ...


@overload
async def get(
self,
Expand All @@ -218,6 +215,7 @@ async def get(
response_model: type[T],
) -> T: ...


async def get(
self,
path: str,
Expand All @@ -229,9 +227,14 @@ async def get(
response_model: type[T] | None = None,
) -> Response | T:
return await self._send(
"GET", path,
headers=headers, params=params, cookies=cookies, timeout=timeout,
body=None, content_type=None,
"GET",
path,
headers=headers,
params=params,
cookies=cookies,
timeout=timeout,
body=None,
content_type=None,
response_model=response_model,
)
```
Expand All @@ -250,13 +253,23 @@ async def _send(
method: str,
path: str,
*,
headers, params, cookies, timeout, body, content_type,
headers,
params,
cookies,
timeout,
body,
content_type,
response_model,
):
request = self._build_request(
method, path,
headers=headers, params=params, cookies=cookies, timeout=timeout,
body=body, content_type=content_type,
method,
path,
headers=headers,
params=params,
cookies=cookies,
timeout=timeout,
body=body,
content_type=content_type,
)
response = await self._dispatch(request)
if response_model is None:
Expand All @@ -272,6 +285,7 @@ async def _send(
async def __aenter__(self) -> "AsyncClient":
return self


async def __aexit__(self, exc_type, exc, tb) -> None:
if self._owns_transport:
await self._transport.aclose()
Expand Down
24 changes: 13 additions & 11 deletions planning/changes/2026-05-31.08-recordedtransport.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ class RecordedTransport:
default: Response | BaseException | None = None,
) -> None: ...

requests: list[Request] # appended on every __call__
aclose_calls: int # incremented on every aclose
requests: list[Request] # appended on every __call__
aclose_calls: int # incremented on every aclose

@property
def last_request(self) -> Request | None: ...
Expand All @@ -95,9 +95,11 @@ Usage examples documented in the class docstring:

```python
# Simple canned response for a single endpoint.
transport = RecordedTransport(routes={
("GET", "/users"): Response(status=200, headers={}, content=b"[]", url="/users", elapsed=0.0),
})
transport = RecordedTransport(
routes={
("GET", "/users"): Response(status=200, headers={}, content=b"[]", url="/users", elapsed=0.0),
}
)

# Same canned response for every request — useful for AsyncClient construction tests.
transport = RecordedTransport(default=Response(status=200, headers={}, content=b"", url="", elapsed=0.0))
Expand All @@ -106,9 +108,11 @@ transport = RecordedTransport(default=Response(status=200, headers={}, content=b
transport = RecordedTransport() # RuntimeError("No route for ...")

# Raise a specific exception on a route.
transport = RecordedTransport(routes={
("GET", "/error"): RuntimeError("upstream down"),
})
transport = RecordedTransport(
routes={
("GET", "/error"): RuntimeError("upstream down"),
}
)

# Inspect observed requests after the test.
client = AsyncClient(transport=transport)
Expand Down Expand Up @@ -157,9 +161,7 @@ class RecordedTransport:
default: Response | BaseException | None = None,
) -> None:
self._routes: dict[tuple[str, str], Response | BaseException] = (
{(m.upper(), u): v for (m, u), v in routes.items()}
if routes is not None
else {}
{(m.upper(), u): v for (m, u), v in routes.items()} if routes is not None else {}
)
self._default = default
self.requests: list[Request] = []
Expand Down
Loading
Loading