httpware 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder."""
2
+
3
+ import functools
4
+ from typing import TypeVar
5
+
6
+ from pydantic import TypeAdapter
7
+
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @functools.lru_cache(maxsize=1024)
13
+ def _get_adapter(model: type[T]) -> TypeAdapter[T]:
14
+ return TypeAdapter(model)
15
+
16
+
17
+ class PydanticDecoder:
18
+ """Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`."""
19
+
20
+ def decode(self, content: bytes, model: type[T]) -> T:
21
+ """Validate `content` as JSON against `model` in a single parse pass."""
22
+ try:
23
+ adapter = _get_adapter(model)
24
+ except TypeError:
25
+ adapter = TypeAdapter(model)
26
+ return adapter.validate_json(content)
27
+
28
+
29
+ __all__ = ["PydanticDecoder"]
httpware/errors.py ADDED
@@ -0,0 +1,194 @@
1
+ """Status-keyed exception hierarchy with plain typed fields.
2
+
3
+ Fallback rule: unknown 4xx statuses fall back to ``ClientStatusError``;
4
+ unknown 5xx fall back to ``ServerStatusError``. The fallback assumes
5
+ ``400 <= status < 600`` — callers must guard against non-error statuses
6
+ (1xx informational, 2xx success, 3xx redirect) before consulting
7
+ ``STATUS_TO_EXCEPTION``. The resolution logic lives at the transport
8
+ seam (Story 1.4); this module only ships the classes and the lookup dict.
9
+
10
+ ``__repr__`` and the summary message passed to ``Exception.__init__``
11
+ strip ``user:pass@`` userinfo from ``request_url`` to avoid leaking
12
+ credentials in tracebacks, log lines, and exception reporters.
13
+ Query-string secrets (e.g. ``?api_key=...``) are NOT stripped here —
14
+ full redaction is the responsibility of the ``Redactor`` middleware
15
+ (Story 5.3).
16
+ """
17
+
18
+ import builtins
19
+ from collections.abc import Mapping
20
+ from types import MappingProxyType
21
+ from typing import Any
22
+ from urllib.parse import urlsplit, urlunsplit
23
+
24
+
25
+ def _strip_userinfo(url: str) -> str:
26
+ """Drop the ``user:pass@`` portion of ``url`` if present."""
27
+ if "@" not in url or "://" not in url:
28
+ return url
29
+ parts = urlsplit(url)
30
+ if parts.username is None and parts.password is None:
31
+ return url
32
+ netloc = parts.hostname or ""
33
+ if parts.port is not None:
34
+ netloc = f"{netloc}:{parts.port}"
35
+ return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
36
+
37
+
38
+ def _reconstruct_status_error(
39
+ cls: "type[StatusError]",
40
+ status: int,
41
+ body: bytes,
42
+ headers: Mapping[str, str],
43
+ json: Any, # noqa: ANN401
44
+ request_method: str,
45
+ request_url: str,
46
+ ) -> "StatusError":
47
+ """Pickle / copy reconstructor for ``StatusError`` subclasses."""
48
+ return cls(
49
+ status=status,
50
+ body=body,
51
+ headers=headers,
52
+ json=json,
53
+ request_method=request_method,
54
+ request_url=request_url,
55
+ )
56
+
57
+
58
+ class ClientError(Exception):
59
+ """Root of the httpware exception tree."""
60
+
61
+
62
+ class TransportError(ClientError):
63
+ """Connection / network / protocol failure raised before a response was received."""
64
+
65
+
66
+ class TimeoutError(ClientError, builtins.TimeoutError): # noqa: A001
67
+ """Client-side timeout (connect / read / write / pool).
68
+
69
+ Inherits from both ``httpware.ClientError`` and ``builtins.TimeoutError``
70
+ so ``except httpware.TimeoutError`` catches httpware-raised timeouts AND
71
+ ``except builtins.TimeoutError`` / ``except OSError`` (the form
72
+ ``asyncio.wait_for`` uses) also catches them. Deliberately shadows
73
+ ``builtins.TimeoutError``; see Decision 3 in ``docs/architecture.md``.
74
+ Do not "fix" this name.
75
+ """
76
+
77
+
78
+ class StatusError(ClientError):
79
+ """Base for HTTP-status-keyed errors with plain typed fields."""
80
+
81
+ status: int
82
+ body: bytes
83
+ headers: Mapping[str, str]
84
+ json: Any
85
+ request_method: str
86
+ request_url: str
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ status: int,
92
+ body: bytes,
93
+ headers: Mapping[str, str],
94
+ json: Any | None, # noqa: ANN401
95
+ request_method: str,
96
+ request_url: str,
97
+ ) -> None:
98
+ """Store all six fields and emit a short summary message to ``Exception.__init__``.
99
+
100
+ Subclasses overriding ``__init__`` MUST call
101
+ ``super().__init__(status=..., body=..., headers=..., json=...,
102
+ request_method=..., request_url=...)`` to register ``args`` and the
103
+ summary message; otherwise ``str(exc)`` is silently empty.
104
+ ``headers`` is defensively copied into a read-only ``MappingProxyType``
105
+ so caller mutations after ``raise`` do not bleed into the exception.
106
+ """
107
+ self.status = status
108
+ self.body = body
109
+ self.headers = MappingProxyType(dict(headers))
110
+ self.json = json
111
+ self.request_method = request_method
112
+ self.request_url = request_url
113
+ super().__init__(f"{status} {request_method} {_strip_userinfo(request_url)}")
114
+
115
+ def __repr__(self) -> str:
116
+ cls_name = type(self).__name__
117
+ safe_url = _strip_userinfo(self.request_url)
118
+ return f"<{cls_name} status={self.status} method={self.request_method} url={safe_url}>"
119
+
120
+ def __reduce__(self) -> tuple[Any, ...]:
121
+ return (
122
+ _reconstruct_status_error,
123
+ (
124
+ type(self),
125
+ self.status,
126
+ self.body,
127
+ dict(self.headers),
128
+ self.json,
129
+ self.request_method,
130
+ self.request_url,
131
+ ),
132
+ )
133
+
134
+
135
+ class ClientStatusError(StatusError):
136
+ """Base for 4xx HTTP status errors."""
137
+
138
+
139
+ class ServerStatusError(StatusError):
140
+ """Base for 5xx HTTP status errors."""
141
+
142
+
143
+ class BadRequestError(ClientStatusError):
144
+ """HTTP 400 Bad Request."""
145
+
146
+
147
+ class UnauthorizedError(ClientStatusError):
148
+ """HTTP 401 Unauthorized."""
149
+
150
+
151
+ class ForbiddenError(ClientStatusError):
152
+ """HTTP 403 Forbidden."""
153
+
154
+
155
+ class NotFoundError(ClientStatusError):
156
+ """HTTP 404 Not Found."""
157
+
158
+
159
+ class ConflictError(ClientStatusError):
160
+ """HTTP 409 Conflict."""
161
+
162
+
163
+ class UnprocessableEntityError(ClientStatusError):
164
+ """HTTP 422 Unprocessable Entity."""
165
+
166
+
167
+ class RateLimitedError(ClientStatusError):
168
+ """HTTP 429 Too Many Requests."""
169
+
170
+
171
+ class InternalServerError(ServerStatusError):
172
+ """HTTP 500 Internal Server Error."""
173
+
174
+
175
+ class ServiceUnavailableError(ServerStatusError):
176
+ """HTTP 503 Service Unavailable."""
177
+
178
+
179
+ # Unknown 4xx → ``ClientStatusError``; unknown 5xx → ``ServerStatusError``.
180
+ # Fallback assumes ``400 <= status < 600`` — callers must guard against
181
+ # non-error codes (1xx/2xx/3xx) before consulting this dict. The fallback
182
+ # resolution lives at the call site (Story 1.4 inlines it at the transport
183
+ # seam).
184
+ STATUS_TO_EXCEPTION: Mapping[int, type[StatusError]] = {
185
+ 400: BadRequestError,
186
+ 401: UnauthorizedError,
187
+ 403: ForbiddenError,
188
+ 404: NotFoundError,
189
+ 409: ConflictError,
190
+ 422: UnprocessableEntityError,
191
+ 429: RateLimitedError,
192
+ 500: InternalServerError,
193
+ 503: ServiceUnavailableError,
194
+ }
@@ -0,0 +1,89 @@
1
+ """Middleware protocol — the AsyncClient ↔ Middleware seam (Seam 2)."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Protocol, TypeAlias, runtime_checkable
5
+
6
+ from httpware.request import Request
7
+ from httpware.response import Response
8
+
9
+
10
+ Next: TypeAlias = Callable[[Request], Awaitable[Response]]
11
+
12
+
13
+ @runtime_checkable
14
+ class Middleware(Protocol):
15
+ """Structural protocol every middleware satisfies.
16
+
17
+ A middleware receives the incoming `Request` and a `Next` callable. It may
18
+ inspect/transform the request, await `next(request)` to forward to the rest
19
+ of the chain (eventually the transport), inspect/transform the returned
20
+ `Response`, short-circuit by returning a `Response` without calling `next`,
21
+ or raise.
22
+ """
23
+
24
+ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002
25
+ """Process `request`; call `next(request)` to forward, or synthesize a Response."""
26
+ ...
27
+
28
+
29
+ def before_request(f: Callable[[Request], Awaitable[Request]]) -> Middleware:
30
+ """Wrap an async request transform into a Middleware.
31
+
32
+ The decorated function receives the incoming Request and returns a
33
+ (possibly modified) Request, which is then forwarded down the chain.
34
+ """
35
+
36
+ class _BeforeRequestMiddleware:
37
+ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002
38
+ return await next(await f(request))
39
+
40
+ def __repr__(self) -> str:
41
+ return f"<before_request({f.__qualname__})>" # ty: ignore[unresolved-attribute]
42
+
43
+ return _BeforeRequestMiddleware()
44
+
45
+
46
+ def after_response(f: Callable[[Request, Response], Awaitable[Response]]) -> Middleware:
47
+ """Wrap an async response transform into a Middleware.
48
+
49
+ The decorated function receives the original Request and the Response
50
+ returned by the chain, and returns a (possibly modified) Response.
51
+ """
52
+
53
+ class _AfterResponseMiddleware:
54
+ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002
55
+ response = await next(request)
56
+ return await f(request, response)
57
+
58
+ def __repr__(self) -> str:
59
+ return f"<after_response({f.__qualname__})>" # ty: ignore[unresolved-attribute]
60
+
61
+ return _AfterResponseMiddleware()
62
+
63
+
64
+ def on_error(f: Callable[[Request, Exception], Awaitable[Response | None]]) -> Middleware:
65
+ """Wrap an async error handler into a Middleware.
66
+
67
+ Catches Exception (not BaseException, so asyncio.CancelledError
68
+ propagates). If the handler returns a Response, that Response is
69
+ returned to the caller. If the handler returns None, the original
70
+ exception is re-raised.
71
+ """
72
+
73
+ class _OnErrorMiddleware:
74
+ async def __call__(self, request: Request, next: Next) -> Response: # noqa: A002
75
+ try:
76
+ return await next(request)
77
+ except Exception as exc:
78
+ result = await f(request, exc)
79
+ if result is None:
80
+ raise
81
+ return result
82
+
83
+ def __repr__(self) -> str:
84
+ return f"<on_error({f.__qualname__})>" # ty: ignore[unresolved-attribute]
85
+
86
+ return _OnErrorMiddleware()
87
+
88
+
89
+ __all__ = ["Middleware", "Next", "after_response", "before_request", "on_error"]
httpware/py.typed ADDED
File without changes
httpware/request.py ADDED
@@ -0,0 +1,55 @@
1
+ """Immutable request value type."""
2
+
3
+ import dataclasses
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Self
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class Request:
11
+ """Immutable HTTP request value type."""
12
+
13
+ method: str
14
+ url: str
15
+ headers: Mapping[str, str] = field(default_factory=dict)
16
+ params: Mapping[str, str] = field(default_factory=dict)
17
+ cookies: Mapping[str, str] = field(default_factory=dict)
18
+ body: bytes | None = None
19
+ extensions: Mapping[str, Any] = field(default_factory=dict)
20
+
21
+ def with_header(self, name: str, value: str) -> Self:
22
+ """Return a copy with the given header added or replaced."""
23
+ return dataclasses.replace(self, headers={**self.headers, name: value})
24
+
25
+ def with_url(self, url: str) -> Self:
26
+ """Return a copy with the given URL."""
27
+ return dataclasses.replace(self, url=url)
28
+
29
+ def with_body(self, body: bytes | None) -> Self:
30
+ """Return a copy with the given body."""
31
+ return dataclasses.replace(self, body=body)
32
+
33
+ def with_query(self, params: Mapping[str, str]) -> Self:
34
+ """Return a copy with the given query params replacing the existing ones."""
35
+ return dataclasses.replace(self, params=params)
36
+
37
+ def with_headers(self, headers: Mapping[str, str]) -> Self:
38
+ """Return a copy with the given headers merged in (incoming keys override existing)."""
39
+ return dataclasses.replace(self, headers={**self.headers, **headers})
40
+
41
+ def with_cookie(self, name: str, value: str) -> Self:
42
+ """Return a copy with the given cookie added or replaced."""
43
+ return dataclasses.replace(self, cookies={**self.cookies, name: value})
44
+
45
+ def with_cookies(self, cookies: Mapping[str, str]) -> Self:
46
+ """Return a copy with the given cookies merged in (incoming keys override existing)."""
47
+ return dataclasses.replace(self, cookies={**self.cookies, **cookies})
48
+
49
+ def with_extension(self, name: str, value: Any) -> Self: # noqa: ANN401
50
+ """Return a copy with the given extension entry added or replaced."""
51
+ return dataclasses.replace(self, extensions={**self.extensions, name: value})
52
+
53
+ def with_extensions(self, extensions: Mapping[str, Any]) -> Self:
54
+ """Return a copy with the given extensions merged in (incoming keys override existing)."""
55
+ return dataclasses.replace(self, extensions={**self.extensions, **extensions})
httpware/response.py ADDED
@@ -0,0 +1,69 @@
1
+ """Immutable response value type."""
2
+
3
+ import dataclasses
4
+ import json
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Self
8
+
9
+
10
+ _CHARSET_PREFIX = "charset="
11
+
12
+
13
+ def _get_content_type(headers: Mapping[str, str]) -> str:
14
+ for key, value in headers.items():
15
+ if key.lower() == "content-type":
16
+ return value
17
+ return ""
18
+
19
+
20
+ def _parse_charset(content_type: str) -> str | None:
21
+ for raw in content_type.split(";"):
22
+ part = raw.strip()
23
+ if part.lower().startswith(_CHARSET_PREFIX):
24
+ return part[len(_CHARSET_PREFIX) :].strip().strip('"').strip("'")
25
+ return None
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Response:
30
+ """Immutable HTTP response value type.
31
+
32
+ `elapsed` is wall-clock seconds from request send to response receipt.
33
+ """
34
+
35
+ status: int
36
+ headers: Mapping[str, str]
37
+ content: bytes
38
+ url: str
39
+ elapsed: float
40
+
41
+ @property
42
+ def text(self) -> str:
43
+ """Decode `content` using the response's declared charset (default UTF-8)."""
44
+ charset = _parse_charset(_get_content_type(self.headers)) or "utf-8"
45
+ try:
46
+ return self.content.decode(charset)
47
+ except LookupError:
48
+ return self.content.decode("utf-8")
49
+
50
+ def json(self) -> Any: # noqa: ANN401
51
+ """Parse `content` as JSON."""
52
+ return json.loads(self.content)
53
+
54
+ def with_headers(self, headers: Mapping[str, str]) -> Self:
55
+ """Return a copy with the given headers merged in (incoming keys override existing)."""
56
+ return dataclasses.replace(self, headers={**self.headers, **headers})
57
+
58
+ def with_status(self, status: int) -> Self:
59
+ """Return a copy with the given status code."""
60
+ return dataclasses.replace(self, status=status)
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class StreamResponse:
65
+ """Placeholder for the streaming response type — fleshed out in Story 4.1."""
66
+
67
+ status: int
68
+ headers: Mapping[str, str]
69
+ url: str
@@ -0,0 +1,27 @@
1
+ """Transport protocol — the middleware ↔ transport seam (Seam 1)."""
2
+
3
+ from contextlib import AbstractAsyncContextManager
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ from httpware.request import Request
7
+ from httpware.response import Response, StreamResponse
8
+
9
+
10
+ @runtime_checkable
11
+ class Transport(Protocol):
12
+ """Structural protocol every transport adapter satisfies."""
13
+
14
+ async def __call__(self, request: Request) -> Response:
15
+ """Send `request` and return the buffered response."""
16
+ ...
17
+
18
+ def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]:
19
+ """Open a streaming response for `request` as an async context manager."""
20
+ ...
21
+
22
+ async def aclose(self) -> None:
23
+ """Release any resources held by the transport."""
24
+ ...
25
+
26
+
27
+ __all__ = ["Transport"]
@@ -0,0 +1,180 @@
1
+ """Httpx2Transport — adapts the httpx2 AsyncClient to the Transport protocol.
2
+
3
+ This is the only file in `httpware` that imports `httpx2`. The v0
4
+ method / header / multi-valued-header contracts are documented on the
5
+ `Httpx2Transport` class.
6
+ """
7
+
8
+ import asyncio
9
+ import dataclasses
10
+ import json
11
+ import time
12
+ from contextlib import AbstractAsyncContextManager
13
+ from typing import Any
14
+
15
+ import httpx2
16
+
17
+ from httpware.config import Limits, Timeout
18
+ from httpware.errors import (
19
+ STATUS_TO_EXCEPTION,
20
+ ClientStatusError,
21
+ ServerStatusError,
22
+ TimeoutError, # noqa: A004
23
+ TransportError,
24
+ )
25
+ from httpware.request import Request
26
+ from httpware.response import Response, StreamResponse
27
+
28
+
29
+ def _try_decode_json(resp: httpx2.Response) -> Any | None: # noqa: ANN401
30
+ """Best-effort JSON decode of `resp.content`; never raises."""
31
+ content_type = ""
32
+ for key, value in resp.headers.items():
33
+ if key.lower() == "content-type":
34
+ content_type = value
35
+ break
36
+ # Strict match on the bare media type: ``application/json`` only.
37
+ # Splitting on ``;`` strips parameters (e.g. ``; charset=utf-8``) and
38
+ # avoids ``application/jsonpatch`` false-positives that ``startswith``
39
+ # would accept. ``+json`` variants (``application/problem+json``,
40
+ # ``application/vnd.api+json``) are deferred per Open Question (a).
41
+ media_type = content_type.split(";", 1)[0].strip().lower()
42
+ if media_type != "application/json":
43
+ return None
44
+ if not resp.content:
45
+ return None
46
+ try:
47
+ return json.loads(resp.content)
48
+ except json.JSONDecodeError:
49
+ return None
50
+
51
+
52
+ class Httpx2Transport:
53
+ """Default `Transport` implementation backed by `httpx2.AsyncClient`.
54
+
55
+ This is the only place in ``httpware`` that imports ``httpx2``. It owns
56
+ three v0 contracts the rest of the library relies on:
57
+
58
+ * The wire ``method`` is uppercased at this seam; the
59
+ ``httpware.Request.method`` itself is left untouched.
60
+ * ``headers`` returned to callers (and stored on ``StatusError``) use
61
+ the lowercase ASCII keys that ``httpx2.Response.headers`` already
62
+ emits. A case-insensitive header type is deferred until middleware
63
+ needs it.
64
+ * ``Mapping[str, str]`` is single-valued. ``dict(resp.headers)``
65
+ collapses duplicate-key headers (``Set-Cookie``, ``Via``, ``Link``)
66
+ to the last value only; the multi-valued contract widens together
67
+ with the case-insensitive type in a later story.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ client: httpx2.AsyncClient | None = None,
74
+ limits: Limits | None = None,
75
+ timeout: Timeout | None = None,
76
+ ) -> None:
77
+ """Store the (optionally user-supplied) client and lazy-init config."""
78
+ if client is not None and (limits is not None or timeout is not None):
79
+ msg = "Pass limits/timeout only when client is None."
80
+ raise ValueError(msg)
81
+ self._client: httpx2.AsyncClient | None = client
82
+ self._limits: Limits | None = limits
83
+ self._timeout: Timeout | None = timeout
84
+ self._closed: bool = False
85
+ self._init_lock: asyncio.Lock | None = None
86
+
87
+ async def _get_client(self) -> httpx2.AsyncClient:
88
+ if self._closed:
89
+ msg = "Httpx2Transport is closed."
90
+ raise TransportError(msg)
91
+ if self._client is not None:
92
+ return self._client
93
+ if self._init_lock is None:
94
+ self._init_lock = asyncio.Lock()
95
+ async with self._init_lock:
96
+ if self._client is None:
97
+ limits = self._limits or Limits()
98
+ timeout = self._timeout or Timeout()
99
+ httpx2_limits = httpx2.Limits(**dataclasses.asdict(limits))
100
+ httpx2_timeout = httpx2.Timeout(
101
+ connect=timeout.connect,
102
+ read=timeout.read,
103
+ write=timeout.write,
104
+ pool=timeout.pool,
105
+ )
106
+ self._client = httpx2.AsyncClient(limits=httpx2_limits, timeout=httpx2_timeout)
107
+ return self._client
108
+
109
+ async def __call__(self, request: Request) -> Response:
110
+ """Send `request` and return a `Response`, raising on 4xx/5xx."""
111
+ client = await self._get_client()
112
+ method = request.method.upper()
113
+ try:
114
+ httpx2_req = httpx2.Request(
115
+ method=method,
116
+ url=request.url,
117
+ headers=dict(request.headers),
118
+ params=dict(request.params),
119
+ cookies=dict(request.cookies),
120
+ content=request.body,
121
+ extensions=dict(request.extensions),
122
+ )
123
+ start = time.monotonic()
124
+ resp = await client.send(httpx2_req)
125
+ except httpx2.TimeoutException as exc:
126
+ raise TimeoutError(str(exc)) from exc
127
+ except httpx2.HTTPError as exc:
128
+ raise TransportError(str(exc)) from exc
129
+ except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
130
+ raise TransportError(str(exc)) from exc
131
+ except RuntimeError as exc:
132
+ # ``httpx2.AsyncClient.send`` raises a bare RuntimeError when
133
+ # the client has been closed externally; there is no public
134
+ # attribute we can interrogate ahead of time.
135
+ if "closed" in str(exc):
136
+ raise TransportError(str(exc)) from exc
137
+ raise
138
+ elapsed = time.monotonic() - start
139
+ status = resp.status_code
140
+ # ``dict(...)`` collapses duplicate-key headers (Set-Cookie etc.)
141
+ # to the last value — see class docstring; widens with the
142
+ # multi-valued header contract in a later story.
143
+ headers = dict(resp.headers)
144
+ if 400 <= status < 600: # noqa: PLR2004
145
+ exc_class = STATUS_TO_EXCEPTION.get(
146
+ status,
147
+ ClientStatusError if status < 500 else ServerStatusError, # noqa: PLR2004
148
+ )
149
+ raise exc_class(
150
+ status=status,
151
+ body=resp.content,
152
+ headers=headers,
153
+ json=_try_decode_json(resp),
154
+ request_method=method,
155
+ request_url=request.url,
156
+ )
157
+ return Response(
158
+ status=status,
159
+ headers=headers,
160
+ content=resp.content,
161
+ url=str(resp.url),
162
+ elapsed=elapsed,
163
+ )
164
+
165
+ def stream(self, request: Request) -> AbstractAsyncContextManager[StreamResponse]: # noqa: ARG002
166
+ """Open a streaming response — not yet implemented (Story 4.1)."""
167
+ if self._closed:
168
+ msg = "Httpx2Transport is closed."
169
+ raise TransportError(msg)
170
+ msg = "Streaming arrives in Epic 4 (Story 4.1)."
171
+ raise NotImplementedError(msg)
172
+
173
+ async def aclose(self) -> None:
174
+ """Close the underlying client; safe to call repeatedly."""
175
+ if self._closed:
176
+ return
177
+ if self._client is not None:
178
+ await self._client.aclose()
179
+ self._client = None
180
+ self._closed = True