headgate 0.2.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.
- headgate/__init__.py +131 -0
- headgate/_attempts.py +50 -0
- headgate/_auth.py +504 -0
- headgate/_body.py +115 -0
- headgate/_client.py +927 -0
- headgate/_codec.py +24 -0
- headgate/_core/__init__.py +88 -0
- headgate/_core/_context.py +228 -0
- headgate/_core/_events.py +248 -0
- headgate/_core/_origin.py +39 -0
- headgate/_core/_retry_after.py +76 -0
- headgate/_core/_seams.py +50 -0
- headgate/_core/_switch.py +40 -0
- headgate/_core/_time.py +102 -0
- headgate/_curl.py +287 -0
- headgate/_deprecation.py +101 -0
- headgate/_failures.py +439 -0
- headgate/_middleware.py +90 -0
- headgate/_options.py +358 -0
- headgate/_redirects.py +86 -0
- headgate/_request_body.py +81 -0
- headgate/_responses.py +290 -0
- headgate/_stack.py +185 -0
- headgate/_url.py +258 -0
- headgate/_user_agent.py +30 -0
- headgate/_view.py +105 -0
- headgate/breaker/__init__.py +24 -0
- headgate/breaker/_breaker.py +302 -0
- headgate/breaker/_policy.py +37 -0
- headgate/breaker/_registry.py +168 -0
- headgate/breaker/_scope.py +43 -0
- headgate/cache/__init__.py +35 -0
- headgate/cache/_conditional.py +89 -0
- headgate/cache/_credentials.py +143 -0
- headgate/cache/_policy.py +131 -0
- headgate/cache/_transport.py +568 -0
- headgate/curl.py +8 -0
- headgate/errors.py +329 -0
- headgate/events.py +45 -0
- headgate/health.py +73 -0
- headgate/msgspec.py +125 -0
- headgate/otel.py +236 -0
- headgate/pagination/__init__.py +25 -0
- headgate/pagination/_link.py +141 -0
- headgate/pagination/_pager.py +197 -0
- headgate/pagination/_paginated.py +82 -0
- headgate/policy.py +43 -0
- headgate/py.typed +0 -0
- headgate/pydantic.py +131 -0
- headgate/ratelimit/__init__.py +23 -0
- headgate/ratelimit/_budget.py +275 -0
- headgate/ratelimit/_budgets.py +62 -0
- headgate/ratelimit/_feedback.py +133 -0
- headgate/ratelimit/_fields.py +187 -0
- headgate/ratelimit/_inflight.py +92 -0
- headgate/ratelimit/_rate.py +103 -0
- headgate/ratelimit/_transport.py +106 -0
- headgate/retry/__init__.py +40 -0
- headgate/retry/_budget.py +73 -0
- headgate/retry/_classify.py +135 -0
- headgate/retry/_policy.py +254 -0
- headgate/retry/_tracking.py +121 -0
- headgate/retry/_transport.py +448 -0
- headgate/security/__init__.py +36 -0
- headgate/security/_addresses.py +120 -0
- headgate/security/_credentials.py +124 -0
- headgate/security/_downgrade.py +42 -0
- headgate/security/_transport.py +119 -0
- headgate/streaming/__init__.py +12 -0
- headgate/streaming/_stream.py +164 -0
- headgate/testing/__init__.py +58 -0
- headgate/testing/_breakers.py +33 -0
- headgate/testing/_recording.py +120 -0
- headgate/testing/_time.py +84 -0
- headgate/testing/_transport.py +115 -0
- headgate/testing/faults.py +275 -0
- headgate-0.2.0.dist-info/METADATA +373 -0
- headgate-0.2.0.dist-info/RECORD +80 -0
- headgate-0.2.0.dist-info/WHEEL +4 -0
- headgate-0.2.0.dist-info/licenses/LICENSE +21 -0
headgate/_responses.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""What a client call declares about the responses it can get back.
|
|
5
|
+
|
|
6
|
+
A client method is one provider endpoint, and it returns that endpoint's documented outcome as a typed value or raises
|
|
7
|
+
a ``ClientError``. So each call declares, where it is made, what it can get back:
|
|
8
|
+
|
|
9
|
+
* a :data:`Reader` for any 2xx, the success reader;
|
|
10
|
+
* optionally an :class:`Expect`, which adds the exact non-2xx statuses the provider documents as answers.
|
|
11
|
+
|
|
12
|
+
Anything a call did not declare raises through the base client's status ladder. There is no raw mode, so no call can
|
|
13
|
+
receive an undeclared status. A refusal the provider writes inside a body is raised by the reader as
|
|
14
|
+
:class:`RefusedError`, and the base client turns it into a ``ClientError`` carrying the status the wire sent.
|
|
15
|
+
|
|
16
|
+
This module is codec-free: it imports neither msgspec nor pydantic. The JSON ``decoder`` a client builds its success
|
|
17
|
+
reader with comes from the flavor module it chose, :mod:`headgate.msgspec` or :mod:`headgate.pydantic`, and both build
|
|
18
|
+
on :func:`json_reader`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import functools
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Callable, Mapping
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from types import MappingProxyType
|
|
28
|
+
from typing import Final, final
|
|
29
|
+
|
|
30
|
+
import httpx2
|
|
31
|
+
|
|
32
|
+
from headgate.errors import UpstreamStatusError
|
|
33
|
+
|
|
34
|
+
__all__ = ["Expect", "Reader", "RefusalCheck", "RefusedError", "as_bytes", "event_stream", "json_reader", "nothing"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
type Reader[T] = Callable[[httpx2.Response], T]
|
|
38
|
+
"""Turns a response into a value.
|
|
39
|
+
|
|
40
|
+
A reader receives the whole response (status, headers, body), because it may need what a header declares, such as a
|
|
41
|
+
``content-type`` choosing between JSON and an event stream. It may raise :class:`~headgate.errors.DecodeError` (the
|
|
42
|
+
body is not what was declared; a ``ValueError``, ``LookupError`` or ``TypeError`` from code over ``response.json()``
|
|
43
|
+
counts the same) or :class:`RefusedError` (the provider said no inside the body). It never builds a ``ClientError``
|
|
44
|
+
itself: the base client does, so the error carries the real status and the client's name.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
#: Statuses that mean "we could not ask", never an answer about the request: our own credentials (401, 407), and the
|
|
48
|
+
#: rate limit the retry transport and the breaker own (429). Every 5xx is refused by range beside these.
|
|
49
|
+
_COULD_NOT_ASK: Final = frozenset(
|
|
50
|
+
{httpx2.codes.UNAUTHORIZED, httpx2.codes.PROXY_AUTHENTICATION_REQUIRED, httpx2.codes.TOO_MANY_REQUESTS}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: The status range RFC 9110 defines.
|
|
54
|
+
_MIN_STATUS, _MAX_STATUS = 100, 599
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _refuse_unclaimable(status: int) -> None:
|
|
58
|
+
"""Raise ``ValueError`` for a status no call may declare as an answer.
|
|
59
|
+
|
|
60
|
+
Both ways of declaring an answer run this one check (the :class:`Expect` constructor over every key of
|
|
61
|
+
``answers``, and :meth:`Expect.on`), so they refuse the same statuses with the same message.
|
|
62
|
+
"""
|
|
63
|
+
if not _MIN_STATUS <= status <= _MAX_STATUS:
|
|
64
|
+
msg = f"{status} is not an HTTP status"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
if status in _COULD_NOT_ASK or status >= httpx2.codes.INTERNAL_SERVER_ERROR:
|
|
67
|
+
msg = f"{status} means we could not ask; it is never a documented answer"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RefusedError(Exception):
|
|
72
|
+
"""A provider's refusal, as a reader or a host check saw it.
|
|
73
|
+
|
|
74
|
+
A reader raises it for a refusal written inside a body (an error envelope, a ``REQUEST_NOT_PROCESSED`` status, a
|
|
75
|
+
vendor error code); a ``decoder`` ``check`` and a client's ``refusal_check`` return one instead. The base client is
|
|
76
|
+
its only catcher, and turns it into ``kind(message, status_code=<the status the wire sent>, code=code, ...)``, so
|
|
77
|
+
a refusal in a 200 body carries 200.
|
|
78
|
+
|
|
79
|
+
``code`` is the provider's own code. ``kind`` is the ``ClientError`` class that code means: ``AuthError`` for a
|
|
80
|
+
credential, ``RateLimitError`` for a quota or rate limit, and ``UpstreamStatusError`` otherwise.
|
|
81
|
+
|
|
82
|
+
It is an exception rather than a return value because ``T | RefusedError`` would widen every reader's type and
|
|
83
|
+
blur a refusal with the values a reader legitimately returns. It is not a ``ClientError``: the base client
|
|
84
|
+
converts it before a caller of the client sees it.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self, message: str, *, code: str | None = None, kind: type[UpstreamStatusError] = UpstreamStatusError
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Record the refusal's *message*, the provider's *code*, and the ``ClientError`` *kind* it becomes."""
|
|
91
|
+
super().__init__(message)
|
|
92
|
+
self.message = message
|
|
93
|
+
self.code = code
|
|
94
|
+
self.kind = kind
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
type RefusalCheck = Callable[[httpx2.Response], RefusedError | None]
|
|
98
|
+
"""A per-host check, run on the final response before anything the call declared.
|
|
99
|
+
|
|
100
|
+
It names a refusal the status line alone cannot show, such as a key-error redirect, and returns ``None`` for
|
|
101
|
+
everything else; raising the :class:`RefusedError` instead of returning it is treated the same. It reads the status
|
|
102
|
+
line and headers, never the body: a refusal written in a body is its reader's to raise. A client carrying one does not
|
|
103
|
+
follow redirects (the base client enforces it), because the check sees only the final response.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@final
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class Expect[T]:
|
|
110
|
+
"""One endpoint's documented responses: a success reader for any 2xx, plus exact-status answers.
|
|
111
|
+
|
|
112
|
+
``answers`` holds the statuses the provider documents as answers, each declared with :meth:`on` in the client
|
|
113
|
+
method whose endpoint returns it. The base client reads an exact declared status first, applies its status ladder
|
|
114
|
+
to any other non-2xx, and hands every other 2xx to :attr:`ok`. A declared non-2xx whose body its reader cannot
|
|
115
|
+
decode falls through to the ladder with its real status, so a claim covers only the body shape it names: a declared
|
|
116
|
+
business 403 never swallows a real permission 403.
|
|
117
|
+
|
|
118
|
+
``Expect`` is deliberately not callable. A verb takes ``Reader[T] | Expect[T]``, and that union is unambiguous only
|
|
119
|
+
because of it.
|
|
120
|
+
|
|
121
|
+
The constructor refuses exactly what :meth:`on` refuses, since an ``Expect`` built with ``answers`` makes the same
|
|
122
|
+
claim, and then freezes ``answers`` into a read-only copy that neither the caller's dict nor the default can change.
|
|
123
|
+
|
|
124
|
+
``headers`` are request headers the declaration needs sent (a conditional read's preconditions, see
|
|
125
|
+
:func:`headgate.cache.conditional`); a header the call sets itself wins.
|
|
126
|
+
``accept`` names the media types the endpoint answers with (``"application/json"``, or a comma-separated list).
|
|
127
|
+
The client sends it as the request's ``Accept`` header, unless the call sets one in ``headers=``, and checks every
|
|
128
|
+
non-empty response it hands a reader against it before the reader runs (see :meth:`accepts`). A body of another
|
|
129
|
+
type raises ``ResponseParseError`` naming the status and the ``Content-Type`` on a 2xx; on a declared non-2xx it
|
|
130
|
+
falls through to the status ladder, as an unreadable body does.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
ok: Reader[T]
|
|
134
|
+
answers: Mapping[int, Reader[T]] = field(default_factory=dict)
|
|
135
|
+
headers: Mapping[str, str] = field(default_factory=dict, kw_only=True)
|
|
136
|
+
accept: str | None = field(default=None, kw_only=True)
|
|
137
|
+
|
|
138
|
+
def __post_init__(self) -> None:
|
|
139
|
+
"""Refuse an unclaimable status in ``answers`` or a malformed ``accept``; freeze ``answers`` and ``headers``."""
|
|
140
|
+
for status in self.answers:
|
|
141
|
+
_refuse_unclaimable(status)
|
|
142
|
+
# A frozen, slotted dataclass refuses plain assignment even here; object.__setattr__ is the documented route.
|
|
143
|
+
object.__setattr__(self, "answers", MappingProxyType(dict(self.answers)))
|
|
144
|
+
object.__setattr__(self, "headers", MappingProxyType(dict(self.headers)))
|
|
145
|
+
if self.accept is not None:
|
|
146
|
+
_media_ranges(self.accept)
|
|
147
|
+
|
|
148
|
+
def accepts(self, resp: httpx2.Response) -> bool:
|
|
149
|
+
"""Return whether *resp*'s ``Content-Type`` is one ``accept`` names; always ``True`` without ``accept``.
|
|
150
|
+
|
|
151
|
+
Media types compare case-insensitively with their parameters ignored (``application/json; charset=utf-8``
|
|
152
|
+
is ``application/json``); ``*/*`` and ``type/*`` match as in ``Accept``; and a structured-syntax suffix
|
|
153
|
+
(RFC 6839) matches its base type, so ``application/problem+json`` is an ``application/json``. An empty body
|
|
154
|
+
(a 204, a HEAD) has nothing to check, and passes.
|
|
155
|
+
"""
|
|
156
|
+
if self.accept is None or not resp.content:
|
|
157
|
+
return True
|
|
158
|
+
found = _media_type(resp.headers.get("content-type", ""))
|
|
159
|
+
if found is None:
|
|
160
|
+
return False
|
|
161
|
+
kind, subtype = found
|
|
162
|
+
suffix = subtype.rpartition("+")[2]
|
|
163
|
+
return any(
|
|
164
|
+
want_kind in {"*", kind} and want_subtype in {"*", subtype, suffix}
|
|
165
|
+
for want_kind, want_subtype in _media_ranges(self.accept)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def on[E](self, status: int, read: Reader[E]) -> Expect[T | E]:
|
|
169
|
+
"""Return a new ``Expect`` that also reads *status* with *read*, widening the result type to ``T | E``.
|
|
170
|
+
|
|
171
|
+
No ranges and no ``default``: OpenAPI allows ``4XX``, but a range is a claim about statuses nobody documented.
|
|
172
|
+
This ``Expect`` is left as it was.
|
|
173
|
+
|
|
174
|
+
:raises ValueError: for a status outside 100-599; for 401, 407, 429 or any 5xx, which mean we could not ask
|
|
175
|
+
(the credentials are ours, and the transport owns 429 and 5xx through retry and the breaker); or for a
|
|
176
|
+
status already declared. It is raised where the ``Expect`` is built, usually at import.
|
|
177
|
+
"""
|
|
178
|
+
_refuse_unclaimable(status)
|
|
179
|
+
if status in self.answers:
|
|
180
|
+
msg = f"{status} is already declared on this Expect"
|
|
181
|
+
raise ValueError(msg)
|
|
182
|
+
widened: dict[int, Reader[T | E]] = {**self.answers, status: read}
|
|
183
|
+
return Expect(self.ok, widened, headers=self.headers, accept=self.accept)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
#: How many distinct ``accept`` values keep their parsed form: far more than any client declares.
|
|
187
|
+
_PARSED_ACCEPTS: Final = 256
|
|
188
|
+
|
|
189
|
+
#: A media type's ``type/subtype``, each an RFC 9110 token, lower-cased.
|
|
190
|
+
_MEDIA_TYPE: Final = re.compile(r"([!#$%&'*+\-.^_`|~0-9a-z]+)/([!#$%&'*+\-.^_`|~0-9a-z]+)")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _media_type(text: str) -> tuple[str, str] | None:
|
|
194
|
+
"""Return the ``(type, subtype)`` of a media type or range *text*, lower-cased and without parameters."""
|
|
195
|
+
match = _MEDIA_TYPE.fullmatch(text.partition(";")[0].strip().lower())
|
|
196
|
+
return None if match is None else (str(match[1]), str(match[2]))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@functools.lru_cache(maxsize=_PARSED_ACCEPTS)
|
|
200
|
+
def _media_ranges(accept: str) -> tuple[tuple[str, str], ...]:
|
|
201
|
+
"""Return the media ranges a comma-separated *accept* names; ``ValueError`` for an empty or malformed one.
|
|
202
|
+
|
|
203
|
+
Cached, since an ``accept`` is a constant of the endpoint method, checked on every response it reads.
|
|
204
|
+
"""
|
|
205
|
+
ranges: list[tuple[str, str]] = []
|
|
206
|
+
for item in accept.split(","):
|
|
207
|
+
found = _media_type(item)
|
|
208
|
+
if found is None:
|
|
209
|
+
msg = f"accept must name media types such as 'application/json' (got {accept!r})"
|
|
210
|
+
raise ValueError(msg)
|
|
211
|
+
ranges.append(found)
|
|
212
|
+
return tuple(ranges)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def json_reader[T](
|
|
216
|
+
decode: Callable[[bytes], T],
|
|
217
|
+
*,
|
|
218
|
+
allow_empty_body: bool = False,
|
|
219
|
+
check: Callable[[T], RefusedError | None] | None = None,
|
|
220
|
+
) -> Reader[T]:
|
|
221
|
+
"""Build a reader around a codec's bytes-to-value function.
|
|
222
|
+
|
|
223
|
+
Each codec flavor's ``decoder`` builds on this, so the empty-body rule and the ``check`` hook behave identically
|
|
224
|
+
across flavors.
|
|
225
|
+
|
|
226
|
+
``allow_empty_body`` reads an empty body (zero bytes or only whitespace) as the empty array ``[]``. It suits only a
|
|
227
|
+
list-shaped type, for a provider whose no-data answer is a zero-byte 200 or a 204. For every other provider an empty
|
|
228
|
+
200 is corruption, and decoding it fails.
|
|
229
|
+
|
|
230
|
+
``check`` runs on the decoded value and returns a :class:`RefusedError` for a refusal written inside a 2xx body, or
|
|
231
|
+
``None``; the reader raises what it returns. Type a check over a small structural ``Protocol`` naming only the
|
|
232
|
+
envelope fields it reads. Have it refuse only codes every consumer of the endpoint would read as failure (auth,
|
|
233
|
+
quota, rate limit, server, a malformed request, any undocumented code) and leave a code about the subject (an
|
|
234
|
+
unknown instrument, a rejected order) in the value for the caller.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def _read(resp: httpx2.Response, /) -> T:
|
|
238
|
+
content = resp.content
|
|
239
|
+
if allow_empty_body and not content.strip():
|
|
240
|
+
content = b"[]"
|
|
241
|
+
value = decode(content)
|
|
242
|
+
if check is not None and (refused := check(value)) is not None:
|
|
243
|
+
raise refused
|
|
244
|
+
return value
|
|
245
|
+
|
|
246
|
+
return _read
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def as_bytes(resp: httpx2.Response, /) -> bytes:
|
|
250
|
+
"""Return the body undecoded, for a wire that is not a format from the client's point of view.
|
|
251
|
+
|
|
252
|
+
A CSV, TXT, ZIP, PDF or XML dump a parser reads later, or an opaque blob. It never raises. It is named ``as_bytes``
|
|
253
|
+
rather than ``body`` because ``body`` is the commonest parameter name in a client and would shadow the import.
|
|
254
|
+
"""
|
|
255
|
+
return resp.content
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def nothing(resp: httpx2.Response, /) -> None:
|
|
259
|
+
"""Return ``None``: the call's answer is that it completed. For a void write (a cancel, a delete, a reset) or HEAD.
|
|
260
|
+
|
|
261
|
+
Throwing a body away is a claim about the wire, so declare ``nothing`` only for an endpoint documented to send no
|
|
262
|
+
body you need. Await such a call as a statement: type checkers reject binding a ``None``-typed result.
|
|
263
|
+
"""
|
|
264
|
+
del resp
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
#: A reserved (RFC 2606) URL for the request an ``httpx2.Response`` must carry. Nothing is sent to it.
|
|
268
|
+
_UNSENT_URL: Final = "https://sse.invalid/"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def event_stream(resp: httpx2.Response, /) -> list[bytes]:
|
|
272
|
+
"""Return the ``data`` of each server-sent event in the buffered body, in order.
|
|
273
|
+
|
|
274
|
+
Only ``data`` is kept: the ``event``, ``id`` and ``retry`` fields are dropped. httpx2's ``EventSource`` parses the
|
|
275
|
+
grammar (multi-line ``data``, CRLF, comments) but requires a response that declares ``text/event-stream``, so the
|
|
276
|
+
body is framed into a response of its own. ``max_event_size=None`` because the body is already buffered and capped
|
|
277
|
+
by the client, and a large reply is legitimately one event, which httpx2's 1 MiB default would refuse.
|
|
278
|
+
|
|
279
|
+
``EventSource`` also yields a block that has no ``data`` field (a ``retry:`` hint, an ``event: ping``), which the
|
|
280
|
+
WHATWG grammar does not dispatch. Such a block is skipped. It cannot be told apart from an event whose ``data`` is
|
|
281
|
+
empty, so that is skipped too. Choosing whether a body is an event stream is the caller's job, usually by reading
|
|
282
|
+
the ``content-type``.
|
|
283
|
+
"""
|
|
284
|
+
framed = httpx2.Response(
|
|
285
|
+
200,
|
|
286
|
+
content=resp.content,
|
|
287
|
+
headers={"content-type": "text/event-stream"},
|
|
288
|
+
request=httpx2.Request("POST", _UNSENT_URL),
|
|
289
|
+
)
|
|
290
|
+
return [event.data.encode() for event in httpx2.EventSource(framed, max_event_size=None) if event.data]
|
headgate/_stack.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The transport stack every client is built on, assembled in one place.
|
|
5
|
+
|
|
6
|
+
:func:`build_http_client` returns the ``httpx2.AsyncClient`` an ``AsyncBaseClient`` holds, over the stack
|
|
7
|
+
:func:`build_transport_stack` builds::
|
|
8
|
+
|
|
9
|
+
RetryTransport( # re-sends per the RetryPolicy; begins each attempt on the call's context
|
|
10
|
+
AttemptTransport( # AttemptFinished per send (only with observers)
|
|
11
|
+
MiddlewareTransport( # HttpOptions.middleware, once per attempt, then the credential guard again
|
|
12
|
+
ResilientCacheTransport( # opt-in response cache; a hit stops here
|
|
13
|
+
RateLimitTransport( # the rate budget: every network send takes its turn and feeds back
|
|
14
|
+
SendTrackingTransport( # records whether each send connected and how many request bytes it wrote
|
|
15
|
+
network_transport)))))) # httpx2.AsyncHTTPTransport(trust_env=False, ...) or HttpOptions.transport
|
|
16
|
+
|
|
17
|
+
:func:`build_transport_stack` builds the layers bottom-up, one statement per layer: a new layer is one more statement in
|
|
18
|
+
its place there, wrapping the layer below it.
|
|
19
|
+
|
|
20
|
+
Above the stack, ``httpx2.AsyncClient`` runs the auth flow and the hooks, once per logical call and once per hop of a
|
|
21
|
+
redirect or an auth flow:
|
|
22
|
+
|
|
23
|
+
* request hooks: the credential guard (:class:`~headgate.security.CredentialGuard`, which also runs below the last
|
|
24
|
+
middleware, where what a middleware stamps is visible), then, over a custom network
|
|
25
|
+
transport with ``HttpOptions(allow_private_hosts=False)``, the SSRF guard's vetting
|
|
26
|
+
(:func:`~headgate.security.address_hook`); the default transport pins vetted addresses itself
|
|
27
|
+
(:class:`~headgate.security.PinnedHTTPTransport`);
|
|
28
|
+
* response hooks: the https-downgrade refusal, the refusal to re-send a one-shot body on a redirect, and the drop of a
|
|
29
|
+
followed redirect's body (:mod:`headgate._redirects`).
|
|
30
|
+
|
|
31
|
+
The circuit breaker and the deadline are not in this stack. ``AsyncBaseClient`` drives both around the send and the
|
|
32
|
+
body read, which no transport can span.
|
|
33
|
+
|
|
34
|
+
Nothing here reads the environment: the client and the default transport are built with ``trust_env=False``, and the
|
|
35
|
+
cookie jar refuses every cookie unless ``HttpOptions(cookies=True)``.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
|
42
|
+
from typing import TYPE_CHECKING
|
|
43
|
+
|
|
44
|
+
import httpx2
|
|
45
|
+
|
|
46
|
+
from headgate._attempts import AttemptTransport
|
|
47
|
+
from headgate._middleware import MiddlewareTransport
|
|
48
|
+
from headgate._options import httpx_timeout
|
|
49
|
+
from headgate._redirects import drop_followed_redirect_body, refuse_unreplayable_redirect
|
|
50
|
+
from headgate._user_agent import user_agent
|
|
51
|
+
from headgate.cache._credentials import credential_scope
|
|
52
|
+
from headgate.cache._transport import ResilientCacheTransport
|
|
53
|
+
from headgate.ratelimit import PathBudgets, RateLimitTransport
|
|
54
|
+
from headgate.retry import RetryTransport, SendTrackingTransport
|
|
55
|
+
from headgate.security import AddressGuard, CredentialGuard, PinnedHTTPTransport, address_hook, refuse_https_downgrade
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from collections.abc import Awaitable, Callable
|
|
59
|
+
|
|
60
|
+
from headgate._core import Timing
|
|
61
|
+
from headgate._options import HttpOptions, TransportPolicy
|
|
62
|
+
from headgate.ratelimit import Rate, RateBudget
|
|
63
|
+
|
|
64
|
+
__all__ = ["Credentials", "build_http_client", "build_transport_stack"]
|
|
65
|
+
|
|
66
|
+
#: The response hooks, in the order they run on every hop.
|
|
67
|
+
_RESPONSE_HOOKS = (refuse_https_downgrade, refuse_unreplayable_redirect, drop_followed_redirect_body)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class Credentials:
|
|
72
|
+
"""A client's credentials as its stack sees them: the auth, and the one guard that keeps them on trusted origins.
|
|
73
|
+
|
|
74
|
+
The same guard runs as the first request hook and below the last middleware (see :func:`build_transport_stack`).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
auth: httpx2.Auth | None
|
|
78
|
+
guard: CredentialGuard
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def of(cls, *, base_url: str, auth: httpx2.Auth | None, http: HttpOptions) -> Credentials:
|
|
82
|
+
"""Return *auth*, with a guard withholding its and *http*'s middleware's credentials from untrusted origins."""
|
|
83
|
+
guard = CredentialGuard(
|
|
84
|
+
auth=auth,
|
|
85
|
+
base_url=httpx2.URL(base_url),
|
|
86
|
+
trusted_origins=http.trusted_origins,
|
|
87
|
+
cookie_jar=http.cookies,
|
|
88
|
+
middleware=http.middleware,
|
|
89
|
+
)
|
|
90
|
+
return cls(auth, guard)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_http_client(
|
|
94
|
+
*, base_url: str, credentials: Credentials, http: HttpOptions, transport: httpx2.AsyncBaseTransport
|
|
95
|
+
) -> httpx2.AsyncClient:
|
|
96
|
+
"""Build a client's ``httpx2.AsyncClient`` over *transport*, the stack :func:`build_transport_stack` built."""
|
|
97
|
+
# httpx2 joins a relative path onto base_url only when base_url ends with "/".
|
|
98
|
+
base_url = base_url if base_url.endswith("/") else base_url + "/"
|
|
99
|
+
return httpx2.AsyncClient(
|
|
100
|
+
transport=transport,
|
|
101
|
+
auth=credentials.auth,
|
|
102
|
+
base_url=base_url,
|
|
103
|
+
headers=_default_headers(http),
|
|
104
|
+
cookies=None if http.cookies else _refusing_jar(),
|
|
105
|
+
follow_redirects=http.follow_redirects,
|
|
106
|
+
max_redirects=http.max_redirects,
|
|
107
|
+
event_hooks={
|
|
108
|
+
"request": _request_hooks(credentials.guard, http),
|
|
109
|
+
"response": list(_RESPONSE_HOOKS),
|
|
110
|
+
},
|
|
111
|
+
timeout=httpx_timeout(http.timeout),
|
|
112
|
+
trust_env=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_transport_stack(
|
|
117
|
+
rate: Rate | RateBudget,
|
|
118
|
+
policy: TransportPolicy,
|
|
119
|
+
http: HttpOptions,
|
|
120
|
+
timing: Timing,
|
|
121
|
+
credentials: Credentials,
|
|
122
|
+
) -> httpx2.AsyncBaseTransport:
|
|
123
|
+
"""Build the layers under ``httpx2.AsyncClient``, bottom-up: each statement wraps the layer before it.
|
|
124
|
+
|
|
125
|
+
The cache partitions its entries by *credentials*' auth, and its guard runs again below the last middleware, so a
|
|
126
|
+
middleware's declared credentials stay on trusted origins too.
|
|
127
|
+
"""
|
|
128
|
+
layer = SendTrackingTransport(transport=_network(http))
|
|
129
|
+
budgets = PathBudgets(default=rate, rules=policy.rate_limits, timing=timing)
|
|
130
|
+
layer = RateLimitTransport(budgets=budgets, transport=layer)
|
|
131
|
+
if (cache := policy.cache) is not None:
|
|
132
|
+
scope = credential_scope(cache, credentials.auth)
|
|
133
|
+
layer = ResilientCacheTransport(layer, cache, max_body_bytes=http.max_response_bytes, scope=scope)
|
|
134
|
+
if http.middleware:
|
|
135
|
+
layer = MiddlewareTransport(transport=layer, middleware=http.middleware, guard=credentials.guard)
|
|
136
|
+
if http.observers:
|
|
137
|
+
layer = AttemptTransport(transport=layer)
|
|
138
|
+
return RetryTransport(transport=layer, policy=policy.retry, sleep=timing.sleep, clock=timing.clock)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _network(http: HttpOptions) -> httpx2.AsyncBaseTransport:
|
|
142
|
+
"""Return the network transport: the caller's own, used as it is, or an HTTP/2 pool that reads no environment."""
|
|
143
|
+
if http.transport is not None:
|
|
144
|
+
return http.transport
|
|
145
|
+
guard = _address_guard(http)
|
|
146
|
+
if guard is not None:
|
|
147
|
+
return PinnedHTTPTransport(guard, ssl_context=http.ssl_context, limits=http.limits)
|
|
148
|
+
return httpx2.AsyncHTTPTransport(
|
|
149
|
+
verify=True if http.ssl_context is None else http.ssl_context,
|
|
150
|
+
http2=True,
|
|
151
|
+
limits=http.limits,
|
|
152
|
+
proxy=http.proxy,
|
|
153
|
+
trust_env=False,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _address_guard(http: HttpOptions) -> AddressGuard | None:
|
|
158
|
+
"""Return the SSRF guard *http* asks for, or ``None`` when it allows private hosts."""
|
|
159
|
+
if http.allow_private_hosts:
|
|
160
|
+
return None
|
|
161
|
+
return AddressGuard() if http.resolver is None else AddressGuard(http.resolver)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _request_hooks(
|
|
165
|
+
credentials: CredentialGuard, http: HttpOptions
|
|
166
|
+
) -> list[Callable[[httpx2.Request], Awaitable[None]]]:
|
|
167
|
+
"""Return the request hooks: the credential guard, then the SSRF guard when a custom transport cannot pin."""
|
|
168
|
+
hooks: list[Callable[[httpx2.Request], Awaitable[None]]] = [credentials]
|
|
169
|
+
addresses = _address_guard(http)
|
|
170
|
+
if addresses is not None and http.transport is not None:
|
|
171
|
+
hooks.append(address_hook(addresses))
|
|
172
|
+
return hooks
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _default_headers(http: HttpOptions) -> httpx2.Headers:
|
|
176
|
+
"""Return the headers every request carries: the ``User-Agent``, then the caller's, which win any clash."""
|
|
177
|
+
headers = httpx2.Headers({"User-Agent": user_agent(http.user_agent)})
|
|
178
|
+
# Headers.update replaces case-insensitively, so a caller's "user-agent" replaces headgate's rather than joining it.
|
|
179
|
+
headers.update(http.headers)
|
|
180
|
+
return headers
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _refusing_jar() -> CookieJar:
|
|
184
|
+
"""Return a cookie jar whose policy allows no domain, so it stores no cookie and sends none back."""
|
|
185
|
+
return CookieJar(policy=DefaultCookiePolicy(allowed_domains=[]))
|