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.
- httpware/__init__.py +67 -0
- httpware/_internal/__init__.py +1 -0
- httpware/_internal/chain.py +39 -0
- httpware/_internal/import_checker.py +6 -0
- httpware/client.py +620 -0
- httpware/config.py +40 -0
- httpware/decoders/__init__.py +18 -0
- httpware/decoders/msgspec.py +32 -0
- httpware/decoders/pydantic.py +29 -0
- httpware/errors.py +194 -0
- httpware/middleware/__init__.py +89 -0
- httpware/py.typed +0 -0
- httpware/request.py +55 -0
- httpware/response.py +69 -0
- httpware/transports/__init__.py +27 -0
- httpware/transports/httpx2.py +180 -0
- httpware/transports/recorded.py +84 -0
- httpware-0.1.0.dist-info/METADATA +94 -0
- httpware-0.1.0.dist-info/RECORD +20 -0
- httpware-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|