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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Core data types: `Request`, `Response`, `Limits`, `Timeout`, `ClientConfig` — frozen+slotted dataclasses with `with_*` immutability helpers on `Request` and computed `text`/`json()` accessors on `Response` (Story 1.2).
- Status-keyed exception hierarchy with plain typed fields: `ClientError`, `TransportError`, `TimeoutError`, `StatusError`, `ClientStatusError`/`ServerStatusError` bases, 9 leaf classes (`BadRequestError` … `ServiceUnavailableError`), `STATUS_TO_EXCEPTION` lookup dict (Story 1.3). `StatusError` is picklable and deep-copyable via custom `__reduce__`; `__repr__` and the summary message strip `user:pass@` userinfo from the request URL; `headers` is stored as a read-only `MappingProxyType` so caller mutations after `raise` do not bleed into the exception. `TimeoutError` multi-inherits from `builtins.TimeoutError` (revisits architecture Decision 3) so `except builtins.TimeoutError` (the form `asyncio.wait_for` raises) also catches httpware-raised timeouts.
- `Transport` protocol (`@runtime_checkable`) and default `Httpx2Transport` adapter; `StreamResponse` placeholder for Story 4.1 protocol typing; the wire `method` is uppercased at the seam and `httpx2` exceptions (`TimeoutException`, `HTTPError`, `InvalidURL`, `CookieConflict`, and the closed-client `RuntimeError`) are mapped to `httpware.TimeoutError` / `httpware.TransportError` (with the original exception's message preserved on the mapped instance) so no `httpx2` exception escapes the library; lazy `httpx2.AsyncClient` construction is guarded by an `asyncio.Lock` so concurrent first-calls share one client; `httpx2` is confined to `src/httpware/transports/httpx2.py` (Story 1.4).
- `ResponseDecoder` protocol (`@runtime_checkable`) and default `PydanticDecoder` adapter — single-parse-pass JSON decoding via `pydantic.TypeAdapter.validate_json(bytes)`; a module-level `@functools.lru_cache(maxsize=None)` factory (`_get_adapter`) memoizes one `TypeAdapter` per `response_model` across the process so warm-path requests pay zero adapter-construction cost; `pydantic.ValidationError` surfaces unchanged to the caller (Story 1.5).

[Unreleased]: https://github.com/modern-python/httpware/commits/main
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ async def main() -> None:

Full docs (in progress): https://httpware.readthedocs.io

## Part of `modern-python`

Browse the full list of templates and libraries in
[`modern-python`](https://github.com/modern-python) — see the org profile for the
categorized index.

## License

MIT — see [LICENSE](./LICENSE).
7 changes: 7 additions & 0 deletions docs/deferred-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Items raised in reviews that are real but not actionable now.

## Deferred from: code review of story-1-5 (2026-05-14)

- **Empty/malformed payload tests** — `b""`, `b"null"`, `b"{}"`, invalid UTF-8: current pydantic-core behavior is correct but unpinned; a future pydantic upgrade could change error types undetected. (`tests/test_decoders_pydantic.py`)
- **`PLR2004` per-file-ignores** — `# noqa: PLR2004` repeated 4× in this test file; idiomatic fix is `tool.ruff.lint.per-file-ignores` for `tests/*`. Project-wide lint-config tidy. (`tests/test_decoders_pydantic.py:48,57,68,82`)
- **CHANGELOG bullet tone** — leaks `_get_adapter` / "zero adapter-construction cost" implementation detail into a user-facing log; AC14 has no wording constraint. Pre-v1 tone pass. (`CHANGELOG.md:19`)
- **`@runtime_checkable` isinstance cost in Story 1.7** — `_ProtocolMeta.__instancecheck__` is ~µs-scale; matters only if `AsyncClient(decoder=...)` validation runs per-request rather than per-construction. Defer to Story 1.7 design.

## Deferred from: code review of story-1-4 (2026-05-14)

- **Unbounded error body size** — `StatusError.body` holds the full `resp.content` with no cap; large 5xx pages stay pinned in memory through exception lifetimes (Sentry payloads, logs, retained tracebacks). Revisit with retry/observability middleware. (`src/httpware/transports/httpx2.py:117-124`)
Expand Down
407 changes: 407 additions & 0 deletions docs/stories/1-5-responsedecoder-protocol-and-pydantic-adapter.md

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions docs/stories/sprint-status.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# generated: 2026-05-14
# last_updated: 2026-05-14 # story 1-5 → done (code review applied)
# project: httpware
# project_key: NOKEY
# tracking_system: file-system
# story_location: docs/stories

# STATUS DEFINITIONS:
# ==================
# Epic Status:
# - backlog: Epic not yet started
# - in-progress: Epic actively being worked on
# - done: All stories in epic completed
#
# Epic Status Transitions:
# - backlog → in-progress: Automatically when first story is created (via create-story)
# - in-progress → done: Manually when all stories reach 'done' status
#
# Story Status:
# - backlog: Story only exists in epic file
# - ready-for-dev: Story file created in stories folder
# - in-progress: Developer actively working on implementation
# - review: Ready for code review (via Dev's code-review workflow)
# - done: Story completed
#
# Retrospective Status:
# - optional: Can be completed but not required
# - done: Retrospective has been completed
#
# WORKFLOW NOTES:
# ===============
# - Epic transitions to 'in-progress' automatically when first story is created
# - Stories can be worked in parallel if team capacity allows
# - Developer typically creates next story after previous one is 'done' to incorporate learnings
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)

generated: 2026-05-14
last_updated: 2026-05-14 # story 1-5 → done (code review applied)
project: httpware
project_key: NOKEY
tracking_system: file-system
story_location: docs/stories

development_status:
epic-1: in-progress
1-1-project-scaffold-and-tooling: done
1-2-core-data-types: done
1-3-exception-hierarchy-with-plain-fields: done
1-4-transport-protocol-and-httpx2transport-adapter: done
1-5-responsedecoder-protocol-and-pydantic-adapter: done
1-6-msgspec-decoder-via-extras: backlog
1-7-asyncclient-with-http-methods-response-model-with-options-lifecycle: backlog
1-8-recordedtransport-for-testing: backlog
epic-1-retrospective: optional

epic-2: backlog
2-1-middleware-protocol-next-type-and-chain-composition: backlog
2-2-phase-shortcut-decorators: backlog
2-3-request-immutability-helpers: backlog
2-4-auth-coercion-as-middleware: backlog
2-5-wire-middleware-into-asyncclient: backlog
epic-2-retrospective: optional

epic-3: backlog
3-1-timeout-middleware-per-attempt: backlog
3-2-retry-middleware: backlog
3-3-retrybudget-data-structure: backlog
3-4-retrybudget-middleware-integration: backlog
3-5-bulkhead-middleware: backlog
3-6-document-the-extension-slot: backlog
epic-3-retrospective: optional

epic-4: backlog
4-1-streamresponse-type: backlog
4-2-transport-stream-implementation-in-httpx2transport: backlog
4-3-asyncclient-stream-context-manager: backlog
epic-4-retrospective: optional

epic-5: backlog
5-1-layer-1-observability-middleware-lifecycle-hooks: backlog
5-2-wire-emission-into-resilience-middlewares: backlog
5-3-redactor-class-and-integration: backlog
5-4-opentelemetry-middleware: backlog
5-5-logging-policy-enforcement: backlog
epic-5-retrospective: optional

epic-6: backlog
6-1-migration-guide-from-base-client: backlog
6-2-documentation-site-mkdocs: backlog
6-3-public-benchmark-suite: backlog
6-4-ci-enforcement-gates: backlog
6-5-release-flow-with-trusted-publishers-and-sigstore: backlog
epic-6-retrospective: optional
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,13 @@ isort.lines-after-imports = 2
isort.no-lines-before = ["standard-library", "local-folder"]

[tool.pytest.ini_options]
addopts = "--cov=src/httpware --cov-report term-missing"
addopts = "--cov=src/httpware --cov-report term-missing -m 'not perf'"
asyncio_mode = "auto"
pythonpath = ["src"]
asyncio_default_fixture_loop_scope = "function"
markers = [
"perf: assertive performance tests (skipped by default; run with `pytest -m perf`)",
]

[tool.coverage]
run.concurrency = ["thread"]
Expand Down
4 changes: 4 additions & 0 deletions src/httpware/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""httpware — resilience-first async HTTP client framework for Python."""

from httpware.config import ClientConfig, Limits, Timeout
from httpware.decoders import ResponseDecoder
from httpware.decoders.pydantic import PydanticDecoder
from httpware.errors import (
STATUS_TO_EXCEPTION,
BadRequestError,
Expand Down Expand Up @@ -37,9 +39,11 @@
"InternalServerError",
"Limits",
"NotFoundError",
"PydanticDecoder",
"RateLimitedError",
"Request",
"Response",
"ResponseDecoder",
"ServerStatusError",
"ServiceUnavailableError",
"StatusError",
Expand Down
18 changes: 18 additions & 0 deletions src/httpware/decoders/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""ResponseDecoder protocol — the AsyncClient ↔ ResponseDecoder seam (Seam 3)."""

from typing import Protocol, TypeVar, runtime_checkable


T = TypeVar("T")


@runtime_checkable
class ResponseDecoder(Protocol):
"""Structural protocol every response-body decoder satisfies."""

def decode(self, content: bytes, model: type[T]) -> T:
"""Decode `content` (raw response bytes) into an instance of `model`."""
...


__all__ = ["ResponseDecoder"]
29 changes: 29 additions & 0 deletions src/httpware/decoders/pydantic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder."""

import functools
from typing import TypeVar

from pydantic import TypeAdapter


T = TypeVar("T")


@functools.lru_cache(maxsize=1024)
def _get_adapter(model: type[T]) -> TypeAdapter[T]:
return TypeAdapter(model)


class PydanticDecoder:
"""Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`."""

def decode(self, content: bytes, model: type[T]) -> T:
"""Validate `content` as JSON against `model` in a single parse pass."""
try:
adapter = _get_adapter(model)
except TypeError:
adapter = TypeAdapter(model)
return adapter.validate_json(content)


__all__ = ["PydanticDecoder"]
156 changes: 156 additions & 0 deletions tests/test_decoders_pydantic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Tests for `httpware.decoders.pydantic.PydanticDecoder` (Story 1.5)."""

import asyncio
import concurrent.futures
import dataclasses
from unittest.mock import patch

import pydantic
import pytest

from httpware import PydanticDecoder, ResponseDecoder
from httpware.decoders.pydantic import _get_adapter


class User(pydantic.BaseModel):
"""Test pydantic model."""

id: int
name: str


@dataclasses.dataclass
class UserDC:
"""Test stdlib dataclass model."""

id: int
name: str


@pytest.fixture(autouse=True)
def _clear_adapter_cache() -> None:
_get_adapter.cache_clear()


def test_pydantic_decoder_satisfies_response_decoder_protocol() -> None:
assert isinstance(PydanticDecoder(), ResponseDecoder)


def test_pydantic_decoder_does_not_inherit_response_decoder() -> None:
assert ResponseDecoder not in PydanticDecoder.__mro__


def test_decodes_basemodel_subclass() -> None:
result = PydanticDecoder().decode(b'{"id": 1, "name": "Ada"}', User)
assert type(result) is User
assert result.id == 1
assert result.name == "Ada"


def test_decodes_stdlib_dataclass() -> None:
result = PydanticDecoder().decode(b'{"id": 1, "name": "Ada"}', UserDC)
assert type(result) is UserDC
assert result.id == 1
assert result.name == "Ada"


def test_decodes_list_of_models() -> None:
result = PydanticDecoder().decode(
b'[{"id": 1, "name": "Ada"}, {"id": 2, "name": "Bo"}]',
list[User],
)
assert type(result) is list
assert len(result) == 2 # noqa: PLR2004
assert all(type(item) is User for item in result)
assert result[0].id == 1
assert result[0].name == "Ada"
assert result[1].id == 2 # noqa: PLR2004
assert result[1].name == "Bo"


def test_decodes_dict_of_models() -> None:
result = PydanticDecoder().decode(b'{"u1": {"id": 1, "name": "Ada"}}', dict[str, User])
assert type(result) is dict
assert list(result.keys()) == ["u1"]
assert type(result["u1"]) is User
assert result["u1"].id == 1
assert result["u1"].name == "Ada"


def test_decodes_primitive_int() -> None:
result = PydanticDecoder().decode(b"42", int)
assert type(result) is int
assert result == 42 # noqa: PLR2004


def test_validation_error_surfaces_unchanged() -> None:
with pytest.raises(pydantic.ValidationError):
PydanticDecoder().decode(b'{"id": "not-a-number", "name": "Ada"}', User)


def test_cache_invariance_single_model() -> None:
_get_adapter.cache_clear()
with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy:
decoder = PydanticDecoder()
for _ in range(1000):
decoder.decode(b'{"id": 1, "name": "Ada"}', User)
assert spy.call_count == 1


def test_cache_invariance_two_distinct_models() -> None:
_get_adapter.cache_clear()
with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy:
decoder = PydanticDecoder()
for _ in range(500):
decoder.decode(b'{"id": 1, "name": "Ada"}', User)
decoder.decode(b'{"id": 1, "name": "Ada"}', UserDC)
assert spy.call_count == 2 # noqa: PLR2004 — two distinct model types


async def test_cache_invariance_concurrent_first_calls() -> None:
_get_adapter.cache_clear()
with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy:
decoder = PydanticDecoder()

async def one_decode() -> User:
return decoder.decode(b'{"id": 1, "name": "Ada"}', User)

await asyncio.gather(*(one_decode() for _ in range(50)))
assert spy.call_count == 1


def test_cache_invariance_concurrent_first_calls_threadpool() -> None:
_get_adapter.cache_clear()
n_workers = 20
with patch("httpware.decoders.pydantic.TypeAdapter", wraps=pydantic.TypeAdapter) as spy:
decoder = PydanticDecoder()

def one_decode(_: int) -> User:
return decoder.decode(b'{"id": 1, "name": "Ada"}', User)

with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as pool:
results = list(pool.map(one_decode, range(50)))

assert all(type(r) is User and r.id == 1 for r in results)
# functools.lru_cache serializes the cache slot but the user function runs
# outside the lock — concurrent first-callers may both build a TypeAdapter
# before one wins (idempotent; loser is GC'd). Bounded by worker count.
assert 1 <= spy.call_count <= n_workers


def test_unhashable_model_falls_back_to_uncached_adapter() -> None:
"""Unhashable `model` falls back to a direct uncached `TypeAdapter`.

When `_get_adapter` raises `TypeError` (e.g., `Annotated[int, unhashable_metadata]`),
`decode` bypasses the cache so `pydantic.ValidationError` surfaces cleanly instead
of leaking a `functools`-internal `TypeError` to the caller.
"""
with patch(
"httpware.decoders.pydantic._get_adapter",
side_effect=TypeError("unhashable type"),
):
result = PydanticDecoder().decode(b"42", int)
assert result == 42 # noqa: PLR2004

with pytest.raises(pydantic.ValidationError):
PydanticDecoder().decode(b'"not-an-int"', int)
Loading
Loading