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.
Files changed (80) hide show
  1. headgate/__init__.py +131 -0
  2. headgate/_attempts.py +50 -0
  3. headgate/_auth.py +504 -0
  4. headgate/_body.py +115 -0
  5. headgate/_client.py +927 -0
  6. headgate/_codec.py +24 -0
  7. headgate/_core/__init__.py +88 -0
  8. headgate/_core/_context.py +228 -0
  9. headgate/_core/_events.py +248 -0
  10. headgate/_core/_origin.py +39 -0
  11. headgate/_core/_retry_after.py +76 -0
  12. headgate/_core/_seams.py +50 -0
  13. headgate/_core/_switch.py +40 -0
  14. headgate/_core/_time.py +102 -0
  15. headgate/_curl.py +287 -0
  16. headgate/_deprecation.py +101 -0
  17. headgate/_failures.py +439 -0
  18. headgate/_middleware.py +90 -0
  19. headgate/_options.py +358 -0
  20. headgate/_redirects.py +86 -0
  21. headgate/_request_body.py +81 -0
  22. headgate/_responses.py +290 -0
  23. headgate/_stack.py +185 -0
  24. headgate/_url.py +258 -0
  25. headgate/_user_agent.py +30 -0
  26. headgate/_view.py +105 -0
  27. headgate/breaker/__init__.py +24 -0
  28. headgate/breaker/_breaker.py +302 -0
  29. headgate/breaker/_policy.py +37 -0
  30. headgate/breaker/_registry.py +168 -0
  31. headgate/breaker/_scope.py +43 -0
  32. headgate/cache/__init__.py +35 -0
  33. headgate/cache/_conditional.py +89 -0
  34. headgate/cache/_credentials.py +143 -0
  35. headgate/cache/_policy.py +131 -0
  36. headgate/cache/_transport.py +568 -0
  37. headgate/curl.py +8 -0
  38. headgate/errors.py +329 -0
  39. headgate/events.py +45 -0
  40. headgate/health.py +73 -0
  41. headgate/msgspec.py +125 -0
  42. headgate/otel.py +236 -0
  43. headgate/pagination/__init__.py +25 -0
  44. headgate/pagination/_link.py +141 -0
  45. headgate/pagination/_pager.py +197 -0
  46. headgate/pagination/_paginated.py +82 -0
  47. headgate/policy.py +43 -0
  48. headgate/py.typed +0 -0
  49. headgate/pydantic.py +131 -0
  50. headgate/ratelimit/__init__.py +23 -0
  51. headgate/ratelimit/_budget.py +275 -0
  52. headgate/ratelimit/_budgets.py +62 -0
  53. headgate/ratelimit/_feedback.py +133 -0
  54. headgate/ratelimit/_fields.py +187 -0
  55. headgate/ratelimit/_inflight.py +92 -0
  56. headgate/ratelimit/_rate.py +103 -0
  57. headgate/ratelimit/_transport.py +106 -0
  58. headgate/retry/__init__.py +40 -0
  59. headgate/retry/_budget.py +73 -0
  60. headgate/retry/_classify.py +135 -0
  61. headgate/retry/_policy.py +254 -0
  62. headgate/retry/_tracking.py +121 -0
  63. headgate/retry/_transport.py +448 -0
  64. headgate/security/__init__.py +36 -0
  65. headgate/security/_addresses.py +120 -0
  66. headgate/security/_credentials.py +124 -0
  67. headgate/security/_downgrade.py +42 -0
  68. headgate/security/_transport.py +119 -0
  69. headgate/streaming/__init__.py +12 -0
  70. headgate/streaming/_stream.py +164 -0
  71. headgate/testing/__init__.py +58 -0
  72. headgate/testing/_breakers.py +33 -0
  73. headgate/testing/_recording.py +120 -0
  74. headgate/testing/_time.py +84 -0
  75. headgate/testing/_transport.py +115 -0
  76. headgate/testing/faults.py +275 -0
  77. headgate-0.2.0.dist-info/METADATA +373 -0
  78. headgate-0.2.0.dist-info/RECORD +80 -0
  79. headgate-0.2.0.dist-info/WHEEL +4 -0
  80. 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=[]))