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/_options.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The option objects an :class:`~headgate.AsyncBaseClient` is configured with, beyond its URL, rate and auth.
|
|
5
|
+
|
|
6
|
+
* :class:`TransportPolicy`: what the transport stack does for every request (retries, the deadline, the breaker,
|
|
7
|
+
per-path rates, the cache, idempotency keys, per-route overrides).
|
|
8
|
+
* :class:`HttpOptions`: how a request is sent and its response read (the network transport and its TLS, pool and proxy,
|
|
9
|
+
headers, timeouts, redirects, the body cap, cookies, observers, middleware).
|
|
10
|
+
* :class:`Timeouts`, :class:`IdempotencyKeys` and :class:`Route`, the values those hold.
|
|
11
|
+
|
|
12
|
+
Every one is frozen, slotted and keyword-only, validates on construction so a bad configuration fails where it is
|
|
13
|
+
written, and freezes the collections it is given.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import KW_ONLY, dataclass, field, replace
|
|
21
|
+
from types import MappingProxyType
|
|
22
|
+
from typing import TYPE_CHECKING, Final
|
|
23
|
+
|
|
24
|
+
import httpx2
|
|
25
|
+
|
|
26
|
+
from headgate._body import DEFAULT_MAX_RESPONSE_BYTES
|
|
27
|
+
from headgate._core import origin_of, origin_text
|
|
28
|
+
from headgate._failures import DEFAULT_REQUEST_ID_HEADERS
|
|
29
|
+
from headgate.breaker import BreakerPolicy
|
|
30
|
+
from headgate.retry import RetryPolicy
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
import ssl
|
|
34
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
35
|
+
|
|
36
|
+
from headgate._core import Observer
|
|
37
|
+
from headgate._middleware import Middleware
|
|
38
|
+
from headgate._responses import RefusalCheck
|
|
39
|
+
from headgate.cache import HttpCache
|
|
40
|
+
from headgate.ratelimit import RateRule
|
|
41
|
+
from headgate.security import Resolver
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"DEFAULT_LIMITS",
|
|
45
|
+
"KEYED_METHODS",
|
|
46
|
+
"HttpOptions",
|
|
47
|
+
"IdempotencyKeys",
|
|
48
|
+
"Route",
|
|
49
|
+
"Timeouts",
|
|
50
|
+
"TransportPolicy",
|
|
51
|
+
"httpx_timeout",
|
|
52
|
+
"match_route",
|
|
53
|
+
"reject_upstream_httpx",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: The connection pool of the default network transport: 10 connections, 5 kept alive. The rate budget is the
|
|
57
|
+
#: throttle; the pool only has to be wide enough never to bind first, and at 300 ms per request 10 connections sustain
|
|
58
|
+
#: ~33 req/s. A client budgeted above that passes wider ``limits``.
|
|
59
|
+
DEFAULT_LIMITS: Final = httpx2.Limits(max_connections=10, max_keepalive_connections=5)
|
|
60
|
+
|
|
61
|
+
#: The proxy URL schemes httpx2 can speak (``socks5`` through the ``httpx2[socks]`` extra).
|
|
62
|
+
_PROXY_SCHEMES: Final = frozenset({"http", "https", "socks5", "socks5h"})
|
|
63
|
+
|
|
64
|
+
#: The schemes a trusted origin may name.
|
|
65
|
+
_ORIGIN_SCHEMES: Final = frozenset({"http", "https"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reject_upstream_httpx(value: object, *, what: str) -> None:
|
|
69
|
+
"""Raise ``TypeError`` when *value* comes from upstream ``httpx``, which headgate does not support.
|
|
70
|
+
|
|
71
|
+
An ``httpx`` transport, auth or event hook passes no ``isinstance`` check against httpx2's classes but fails later
|
|
72
|
+
and obscurely, so the mistake is named where it is made: by the module of any class in the value's MRO, or of the
|
|
73
|
+
value itself for a function.
|
|
74
|
+
"""
|
|
75
|
+
modules = [cls.__module__ for cls in type(value).__mro__]
|
|
76
|
+
modules.append(str(getattr(value, "__module__", "")))
|
|
77
|
+
if any(module == "httpx" or module.startswith("httpx.") for module in modules):
|
|
78
|
+
msg = (
|
|
79
|
+
f"{what} is an upstream httpx object ({type(value).__qualname__}); headgate is built on httpx2, so pass "
|
|
80
|
+
f"the httpx2 equivalent"
|
|
81
|
+
)
|
|
82
|
+
raise TypeError(msg)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_positive(name: str, value: float) -> None:
|
|
86
|
+
"""Raise ``ValueError`` unless *value* is a positive number (NaN is not)."""
|
|
87
|
+
if not value > 0:
|
|
88
|
+
msg = f"{name} must be > 0 (got {value})"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
93
|
+
class Timeouts:
|
|
94
|
+
"""Per-phase timeouts in seconds: obtaining a connection from the pool, connecting, writing, reading."""
|
|
95
|
+
|
|
96
|
+
connect: float = 10.0
|
|
97
|
+
read: float = 30.0
|
|
98
|
+
write: float = 10.0
|
|
99
|
+
pool: float = 15.0
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
"""Refuse a timeout that is not positive."""
|
|
103
|
+
for name in ("connect", "read", "write", "pool"):
|
|
104
|
+
_check_positive(name, getattr(self, name))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def httpx_timeout(client: Timeouts | float, call: Timeouts | float | None = None) -> httpx2.Timeout:
|
|
108
|
+
"""Return the ``httpx2.Timeout`` for a request: the *client*'s timeouts with the *call*'s applied.
|
|
109
|
+
|
|
110
|
+
A bare number is a read timeout: on the client it keeps the other defaults, on a call the client's other values.
|
|
111
|
+
A ``Timeouts`` replaces all four.
|
|
112
|
+
"""
|
|
113
|
+
base = client if isinstance(client, Timeouts) else Timeouts(read=client)
|
|
114
|
+
if call is not None:
|
|
115
|
+
base = call if isinstance(call, Timeouts) else replace(base, read=call)
|
|
116
|
+
return httpx2.Timeout(connect=base.connect, read=base.read, write=base.write, pool=base.pool)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _uuid4_text() -> str:
|
|
120
|
+
"""Return a random UUID as text."""
|
|
121
|
+
return str(uuid.uuid4())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
125
|
+
class IdempotencyKeys:
|
|
126
|
+
"""Stamp a fresh idempotency key on every POST and PATCH a client sends, once per call, under ``header``.
|
|
127
|
+
|
|
128
|
+
The key is stamped before the retry layer, so every attempt of one call carries the same key and the upstream can
|
|
129
|
+
recognise a re-send. A call can pass its own with ``idempotency_key=``, and a key the caller set in ``headers=``
|
|
130
|
+
is kept.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
header: str = "Idempotency-Key"
|
|
134
|
+
factory: Callable[[], str] = field(default=_uuid4_text)
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
"""Refuse an empty header name."""
|
|
138
|
+
if not self.header:
|
|
139
|
+
msg = "header must name a header"
|
|
140
|
+
raise ValueError(msg)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
#: The methods :class:`IdempotencyKeys` stamps: the ones RFC 9110 does not define as idempotent.
|
|
144
|
+
KEYED_METHODS: Final = frozenset({"POST", "PATCH"})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class Route:
|
|
149
|
+
"""Overrides for the requests whose URL path contains ``fragment``: their own retry policy and deadline.
|
|
150
|
+
|
|
151
|
+
Matched like :class:`~headgate.RateRule`, the first matching route winning, so an endpoint's retry and deadline
|
|
152
|
+
change in the client's configuration rather than in the endpoint method. ``None`` keeps the policy's own value; a
|
|
153
|
+
call's ``retry=`` and ``deadline=`` win over both.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
fragment: str
|
|
157
|
+
_: KW_ONLY
|
|
158
|
+
retry: RetryPolicy | None = None
|
|
159
|
+
deadline: float | None = None
|
|
160
|
+
|
|
161
|
+
def __post_init__(self) -> None:
|
|
162
|
+
"""Refuse an empty fragment, which would match every path, and a deadline that is not positive."""
|
|
163
|
+
if not self.fragment:
|
|
164
|
+
msg = "fragment must not be empty"
|
|
165
|
+
raise ValueError(msg)
|
|
166
|
+
if self.deadline is not None:
|
|
167
|
+
_check_positive("deadline", self.deadline)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def match_route(routes: tuple[Route, ...], path: str) -> Route | None:
|
|
171
|
+
"""Return the first of *routes* whose fragment occurs in *path*, or ``None``."""
|
|
172
|
+
return next((route for route in routes if route.fragment in path), None)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
176
|
+
class TransportPolicy:
|
|
177
|
+
"""What the transport stack does for every request.
|
|
178
|
+
|
|
179
|
+
* ``retry``: how a call is re-sent (:class:`~headgate.RetryPolicy`). A call opts out with ``retry=False``.
|
|
180
|
+
* ``deadline``: seconds the whole logical call may take, every attempt, wait and the body read included; ``None``
|
|
181
|
+
for none. Past it the call raises ``ClientTimeoutError``. A call overrides it with ``deadline=``.
|
|
182
|
+
* ``breaker``: the circuit breaker (:class:`~headgate.BreakerPolicy`), or ``None`` for none.
|
|
183
|
+
* ``rate_limits``: per-path budgets (:class:`~headgate.RateRule`): a request whose path matches a rule spends from
|
|
184
|
+
the first matching rule's budget instead of the client's.
|
|
185
|
+
* ``cache``: an opt-in :class:`~headgate.HttpCache`.
|
|
186
|
+
* ``idempotency``: stamp idempotency keys on POST and PATCH (:class:`IdempotencyKeys`), or ``None``.
|
|
187
|
+
* ``routes``: per-route retry and deadline overrides (:class:`Route`).
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
191
|
+
deadline: float | None = 60.0
|
|
192
|
+
breaker: BreakerPolicy | None = field(default_factory=BreakerPolicy)
|
|
193
|
+
rate_limits: tuple[RateRule, ...] = ()
|
|
194
|
+
cache: HttpCache | None = None
|
|
195
|
+
idempotency: IdempotencyKeys | None = None
|
|
196
|
+
routes: tuple[Route, ...] = ()
|
|
197
|
+
|
|
198
|
+
def __post_init__(self) -> None:
|
|
199
|
+
"""Refuse a deadline that is not positive and finite, and freeze the rule and route sequences."""
|
|
200
|
+
if self.deadline is not None and not (self.deadline > 0 and math.isfinite(self.deadline)):
|
|
201
|
+
msg = f"deadline must be > 0 and finite, or None (got {self.deadline})"
|
|
202
|
+
raise ValueError(msg)
|
|
203
|
+
object.__setattr__(self, "rate_limits", tuple(self.rate_limits))
|
|
204
|
+
object.__setattr__(self, "routes", tuple(self.routes))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _is_bare_origin(url: httpx2.URL) -> bool:
|
|
208
|
+
"""Return whether *url* is an http(s) origin and nothing more: a host, no path, query, fragment or userinfo."""
|
|
209
|
+
return (
|
|
210
|
+
url.scheme in _ORIGIN_SCHEMES
|
|
211
|
+
and bool(url.host)
|
|
212
|
+
and url.path == "/"
|
|
213
|
+
and not (url.query or url.fragment or url.userinfo)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _normalized_origin(text: str) -> str:
|
|
218
|
+
"""Return *text*, an ``http(s)://host[:port]`` origin, normalised; ``ValueError`` for anything else."""
|
|
219
|
+
try:
|
|
220
|
+
url = httpx2.URL(text)
|
|
221
|
+
except httpx2.InvalidURL:
|
|
222
|
+
url = None
|
|
223
|
+
if url is None or not _is_bare_origin(url):
|
|
224
|
+
msg = f"trusted origin must be an http(s) origin such as 'https://host[:port]' (got {text!r})"
|
|
225
|
+
raise ValueError(msg)
|
|
226
|
+
return origin_text(origin_of(url))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
#: What a proxy URL's error calls a URL httpx2 cannot parse, or one with no scheme.
|
|
230
|
+
_MALFORMED = "malformed"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _check_proxy(proxy: str) -> None:
|
|
234
|
+
"""Raise ``ValueError`` for a proxy URL httpx2 cannot use."""
|
|
235
|
+
try:
|
|
236
|
+
scheme = httpx2.URL(proxy).scheme or _MALFORMED
|
|
237
|
+
except httpx2.InvalidURL:
|
|
238
|
+
scheme = _MALFORMED
|
|
239
|
+
if scheme not in _PROXY_SCHEMES:
|
|
240
|
+
msg = f"proxy must be an http, https or socks5 URL (got a {scheme} URL)"
|
|
241
|
+
raise ValueError(msg)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _check_hooks(name: str, values: Iterable[object]) -> None:
|
|
245
|
+
"""Refuse an upstream-httpx object among *values*."""
|
|
246
|
+
for value in values:
|
|
247
|
+
reject_upstream_httpx(value, what=f"an entry of {name}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
251
|
+
class HttpOptions:
|
|
252
|
+
"""How a request is sent and its response read.
|
|
253
|
+
|
|
254
|
+
* ``transport``: a custom network transport (``headgate.curl.AsyncCurlTransport``, a test double), which then owns
|
|
255
|
+
its own TLS, pool and proxy: ``ssl_context``, ``limits`` and ``proxy`` configure only the default transport, an
|
|
256
|
+
HTTP/2 ``httpx2.AsyncHTTPTransport``.
|
|
257
|
+
* ``headers``: sent on every request; a call's ``headers=`` wins a clash.
|
|
258
|
+
* ``timeout``: :class:`Timeouts`, or a number for the read timeout alone.
|
|
259
|
+
* ``follow_redirects`` and ``max_redirects``: the client's default, which a call can override, and the hop limit.
|
|
260
|
+
httpx2 counts an auth flow's hops in the same history as redirects, so each request a
|
|
261
|
+
:class:`~headgate.CachedTokenAuth` mint sends, and a 401 it re-mints after, uses one of ``max_redirects`` too.
|
|
262
|
+
* ``max_response_bytes``: the cap on a buffered body, counted after decompression.
|
|
263
|
+
* ``refusal_check``: a per-host check run on every final response (see :data:`~headgate.RefusalCheck`). It sees
|
|
264
|
+
only the final response, so a refusal written on a followed hop would be skipped, and it therefore requires
|
|
265
|
+
``follow_redirects=False``.
|
|
266
|
+
* ``trusted_origins``: origins besides ``base_url``'s that credentials may be sent to (``"https://host[:port]"``);
|
|
267
|
+
every other origin gets the request without them (see :mod:`headgate.security`).
|
|
268
|
+
* ``allow_private_hosts``: ``False`` turns on the SSRF guard: a request to a host that is, or resolves to, an
|
|
269
|
+
address that is not globally routable raises :class:`~headgate.errors.ForbiddenDestinationError`, on every hop,
|
|
270
|
+
and the default transport connects only to the address it checked. It cannot be combined with ``proxy``, since a
|
|
271
|
+
proxy resolves the target itself; over a custom ``transport`` every hop is checked but not pinned.
|
|
272
|
+
* ``resolver``: how the SSRF guard resolves names (:data:`~headgate.security.Resolver`), the event loop's
|
|
273
|
+
``getaddrinfo`` by default; only with ``allow_private_hosts=False``.
|
|
274
|
+
* ``user_agent``: a product token sent before headgate's own (``"<user_agent> headgate/<v> httpx2/<v>
|
|
275
|
+
python/<v>"``); without one, headgate's alone.
|
|
276
|
+
* ``cookies``: ``True`` keeps httpx2's cookie jar; by default no cookie a response sets is stored or sent back.
|
|
277
|
+
* ``request_id_headers``: the response headers a ``ClientError``'s ``request_id`` is read from, first match wins.
|
|
278
|
+
* ``observers``: receive the client's events (see :mod:`headgate.events`).
|
|
279
|
+
* ``middleware``: run once per attempt, in order, inside the retry layer (see :class:`~headgate.Middleware`).
|
|
280
|
+
|
|
281
|
+
Nothing is read from the environment: no proxy variable, no ``.netrc``, no ``SSL_CERT_FILE``.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
transport: httpx2.AsyncBaseTransport | None = None
|
|
285
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
286
|
+
timeout: Timeouts | float = field(default_factory=Timeouts)
|
|
287
|
+
follow_redirects: bool = True
|
|
288
|
+
max_redirects: int = 5
|
|
289
|
+
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES
|
|
290
|
+
refusal_check: RefusalCheck | None = None
|
|
291
|
+
trusted_origins: frozenset[str] = frozenset()
|
|
292
|
+
allow_private_hosts: bool = True
|
|
293
|
+
resolver: Resolver | None = None
|
|
294
|
+
ssl_context: ssl.SSLContext | None = None
|
|
295
|
+
limits: httpx2.Limits = field(default_factory=lambda: DEFAULT_LIMITS)
|
|
296
|
+
proxy: str | None = None
|
|
297
|
+
user_agent: str | None = None
|
|
298
|
+
cookies: bool = False
|
|
299
|
+
request_id_headers: tuple[str, ...] = DEFAULT_REQUEST_ID_HEADERS
|
|
300
|
+
observers: tuple[Observer, ...] = ()
|
|
301
|
+
middleware: tuple[Middleware, ...] = ()
|
|
302
|
+
|
|
303
|
+
def __post_init__(self) -> None:
|
|
304
|
+
"""Validate every option, and freeze and normalise the collections."""
|
|
305
|
+
self._check_transport()
|
|
306
|
+
if not isinstance(self.timeout, Timeouts):
|
|
307
|
+
_check_positive("timeout", self.timeout)
|
|
308
|
+
_check_positive("max_response_bytes", self.max_response_bytes)
|
|
309
|
+
if self.max_redirects < 0:
|
|
310
|
+
msg = f"max_redirects must be >= 0 (got {self.max_redirects})"
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
self._check_guard()
|
|
313
|
+
if self.refusal_check is not None and self.follow_redirects:
|
|
314
|
+
msg = "an HttpOptions with a refusal_check= must set follow_redirects=False"
|
|
315
|
+
raise ValueError(msg)
|
|
316
|
+
if self.user_agent is not None and (not self.user_agent or not self.user_agent.isprintable()):
|
|
317
|
+
msg = "user_agent must be a non-empty, printable product token"
|
|
318
|
+
raise ValueError(msg)
|
|
319
|
+
names = tuple(name.lower() for name in self.request_id_headers)
|
|
320
|
+
if not all(names):
|
|
321
|
+
msg = "request_id_headers must name headers"
|
|
322
|
+
raise ValueError(msg)
|
|
323
|
+
_check_hooks("observers", self.observers)
|
|
324
|
+
_check_hooks("middleware", self.middleware)
|
|
325
|
+
object.__setattr__(self, "headers", MappingProxyType(dict(self.headers)))
|
|
326
|
+
object.__setattr__(self, "trusted_origins", frozenset(map(_normalized_origin, self.trusted_origins)))
|
|
327
|
+
object.__setattr__(self, "request_id_headers", names)
|
|
328
|
+
object.__setattr__(self, "observers", tuple(self.observers))
|
|
329
|
+
object.__setattr__(self, "middleware", tuple(self.middleware))
|
|
330
|
+
|
|
331
|
+
def _check_guard(self) -> None:
|
|
332
|
+
"""Refuse a resolver without the SSRF guard, and the guard with a proxy, which would resolve past it."""
|
|
333
|
+
if self.allow_private_hosts and self.resolver is not None:
|
|
334
|
+
msg = "resolver= is used only by the SSRF guard, so it needs allow_private_hosts=False"
|
|
335
|
+
raise ValueError(msg)
|
|
336
|
+
if not self.allow_private_hosts and self.proxy is not None:
|
|
337
|
+
msg = "allow_private_hosts=False cannot be combined with proxy=: the proxy resolves the target itself"
|
|
338
|
+
raise ValueError(msg)
|
|
339
|
+
|
|
340
|
+
def _check_transport(self) -> None:
|
|
341
|
+
"""Refuse an upstream-httpx transport, and default-transport settings next to a custom transport."""
|
|
342
|
+
if self.transport is None:
|
|
343
|
+
if self.proxy is not None:
|
|
344
|
+
_check_proxy(self.proxy)
|
|
345
|
+
return
|
|
346
|
+
reject_upstream_httpx(self.transport, what="transport")
|
|
347
|
+
configured = [
|
|
348
|
+
name
|
|
349
|
+
for name, is_set in (
|
|
350
|
+
("ssl_context", self.ssl_context is not None),
|
|
351
|
+
("limits", self.limits != DEFAULT_LIMITS),
|
|
352
|
+
("proxy", self.proxy is not None),
|
|
353
|
+
)
|
|
354
|
+
if is_set
|
|
355
|
+
]
|
|
356
|
+
if configured:
|
|
357
|
+
msg = f"{', '.join(configured)} configure the default transport, so they cannot be combined with transport="
|
|
358
|
+
raise ValueError(msg)
|
headgate/_redirects.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""What a followed redirect may keep and re-send: two response hooks every client installs.
|
|
5
|
+
|
|
6
|
+
* :func:`drop_followed_redirect_body`: the body of a redirect httpx2 follows is dropped unread.
|
|
7
|
+
* :func:`refuse_unreplayable_redirect`: a redirect that would send a one-shot request body a second time is not
|
|
8
|
+
followed; its 3xx is the call's final response.
|
|
9
|
+
|
|
10
|
+
Both are installed by :func:`headgate._stack.build_http_client`; the call's :class:`~headgate._core.CallContext` tells
|
|
11
|
+
them whether this request's redirects are being followed. Where a redirect may take a request, and what credentials it
|
|
12
|
+
may carry there, is :mod:`headgate.security`'s.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
import httpx2
|
|
20
|
+
|
|
21
|
+
from headgate._core import CallContext
|
|
22
|
+
from headgate.retry import REPLAYABLE_BODY
|
|
23
|
+
|
|
24
|
+
__all__ = ["drop_followed_redirect_body", "refuse_unreplayable_redirect"]
|
|
25
|
+
|
|
26
|
+
#: The redirects httpx2 turns into a body-less GET for a POST (RFC 9110 section 15.4.2 and 15.4.3 allow it).
|
|
27
|
+
_POST_BECOMES_GET: Final = frozenset({httpx2.codes.MOVED_PERMANENTLY, httpx2.codes.FOUND})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def drop_followed_redirect_body(response: httpx2.Response) -> None:
|
|
31
|
+
"""Discard the body of a redirect httpx2 is about to follow (a response hook).
|
|
32
|
+
|
|
33
|
+
httpx2 reads a followed redirect's body in full inside its own redirect loop, before the next hop, and so below
|
|
34
|
+
:func:`~headgate._body.read_capped`, which only ever sees the final response. A 302 whose body decompressed far
|
|
35
|
+
past ``max_response_bytes`` would otherwise be held in memory, kept alive in the response history. Nothing reads a
|
|
36
|
+
followed redirect's body, so it is replaced with an empty stream rather than capped. A redirect that is not followed
|
|
37
|
+
is the final response, and keeps its body for ``read_capped``. httpx2 hands the call's extensions, and so its
|
|
38
|
+
context, to every hop.
|
|
39
|
+
|
|
40
|
+
An ``AsyncClient`` always hands its hooks an async stream; the ``isinstance`` check only narrows the type.
|
|
41
|
+
"""
|
|
42
|
+
stream = response.stream
|
|
43
|
+
context = CallContext.find(response.request)
|
|
44
|
+
if (
|
|
45
|
+
response.has_redirect_location
|
|
46
|
+
and context is not None
|
|
47
|
+
and context.follow_redirects
|
|
48
|
+
and isinstance(stream, httpx2.AsyncByteStream)
|
|
49
|
+
):
|
|
50
|
+
await stream.aclose()
|
|
51
|
+
response.stream = httpx2.ByteStream(b"")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resends_body(response: httpx2.Response) -> bool:
|
|
55
|
+
"""Return whether httpx2 follows the redirect *response* by sending its request's body again.
|
|
56
|
+
|
|
57
|
+
It does for every redirect but a 303, which becomes a GET, and a 301 or 302 answering a POST, which httpx2 turns
|
|
58
|
+
into a GET as browsers do.
|
|
59
|
+
"""
|
|
60
|
+
status = response.status_code
|
|
61
|
+
if status == httpx2.codes.SEE_OTHER:
|
|
62
|
+
return False
|
|
63
|
+
return not (status in _POST_BECOMES_GET and response.request.method == "POST")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def refuse_unreplayable_redirect(response: httpx2.Response) -> None:
|
|
67
|
+
"""Stop httpx2 following a redirect that would send a one-shot request body again (a response hook).
|
|
68
|
+
|
|
69
|
+
A ``content=`` given as an async iterable is consumed by the first send (:data:`~headgate.retry.REPLAYABLE_BODY`),
|
|
70
|
+
so following a 307 or 308 would fail inside httpx2 with ``StreamConsumed``. The ``Location`` is removed instead, as
|
|
71
|
+
:func:`~headgate.security.refuse_https_downgrade` does: the 3xx reaches the call as its final answer, raised through
|
|
72
|
+
the status ladder unless the call declares it, with a note saying why. A body given as a factory is built afresh for
|
|
73
|
+
the next hop, so its redirects are followed.
|
|
74
|
+
"""
|
|
75
|
+
if not response.has_redirect_location:
|
|
76
|
+
return
|
|
77
|
+
context = CallContext.find(response.request)
|
|
78
|
+
if context is None or not context.follow_redirects or context.get(REPLAYABLE_BODY) is not False:
|
|
79
|
+
return
|
|
80
|
+
if not _resends_body(response):
|
|
81
|
+
return
|
|
82
|
+
del response.headers["Location"]
|
|
83
|
+
context.add_note(
|
|
84
|
+
f"did not follow the {response.status_code} redirect: it asks for the request body again, and the body was a "
|
|
85
|
+
"one-shot stream (pass content= a factory to make it replayable)"
|
|
86
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""A call's request body: raw ``content=`` (bytes, a stream, or a stream factory) and multipart ``files=``.
|
|
5
|
+
|
|
6
|
+
A streamed body is either replayable or not, and the retry layer has to know which: a stream given as an iterable is
|
|
7
|
+
consumed by the first send, so the call is marked non-replayable (:data:`~headgate.retry.REPLAYABLE_BODY`) and never
|
|
8
|
+
re-sent; a stream given as a factory is built afresh, by calling the factory once, each time the request is sent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Callable, Mapping, Sequence
|
|
14
|
+
from typing import IO, override
|
|
15
|
+
|
|
16
|
+
import httpx2
|
|
17
|
+
|
|
18
|
+
__all__ = ["Body", "BodyFactory", "UploadFiles", "check_one_body", "request_content"]
|
|
19
|
+
|
|
20
|
+
type BodyFactory = Callable[[], AsyncIterable[bytes]]
|
|
21
|
+
"""Builds a fresh request body stream; called once for each send of the request."""
|
|
22
|
+
|
|
23
|
+
type Body = bytes | AsyncIterable[bytes] | BodyFactory
|
|
24
|
+
"""A raw request body: bytes, a one-shot stream, or a factory of streams (which keeps the call retryable)."""
|
|
25
|
+
|
|
26
|
+
type _FileContent = IO[bytes] | bytes | str
|
|
27
|
+
type _UploadFile = (
|
|
28
|
+
_FileContent
|
|
29
|
+
| tuple[str | None, _FileContent]
|
|
30
|
+
| tuple[str | None, _FileContent, str | None]
|
|
31
|
+
| tuple[str | None, _FileContent, str | None, Mapping[str, str]]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
type UploadFiles = Mapping[str, _UploadFile] | Sequence[tuple[str, _UploadFile]]
|
|
35
|
+
"""Multipart files, as httpx2 takes them: a file (or bytes), optionally with a filename, content type and headers."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Replayed(httpx2.AsyncByteStream):
|
|
39
|
+
"""A request body built from its factory each time it is sent, so every attempt sends the whole body."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, factory: BodyFactory) -> None:
|
|
42
|
+
"""Build the body from *factory* on every send."""
|
|
43
|
+
self._factory = factory
|
|
44
|
+
|
|
45
|
+
@override
|
|
46
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
47
|
+
"""Yield a fresh stream from the factory, closing it even when the send stops part-way."""
|
|
48
|
+
chunks = aiter(self._factory())
|
|
49
|
+
try:
|
|
50
|
+
async for chunk in chunks:
|
|
51
|
+
yield chunk
|
|
52
|
+
finally:
|
|
53
|
+
if isinstance(chunks, AsyncGenerator):
|
|
54
|
+
await chunks.aclose()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request_content(body: Body | None) -> tuple[bytes | AsyncIterable[bytes] | None, bool]:
|
|
58
|
+
"""Return what httpx2 is handed as ``content=`` for *body*, and whether the request can be sent again.
|
|
59
|
+
|
|
60
|
+
Bytes and a factory are replayable; any other async iterable is consumed by the first send and is not. A value
|
|
61
|
+
that is both callable and iterable counts as an iterable.
|
|
62
|
+
"""
|
|
63
|
+
if body is None or isinstance(body, bytes):
|
|
64
|
+
return body, True
|
|
65
|
+
if isinstance(body, AsyncIterable):
|
|
66
|
+
return body, False
|
|
67
|
+
return _Replayed(body), True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_one_body(*, content: Body | None, json: object, data: object, files: UploadFiles | None) -> None:
|
|
71
|
+
"""Raise ``ValueError`` for body options httpx2 would silently drop one of.
|
|
72
|
+
|
|
73
|
+
``content=`` is the whole body, so it combines with nothing; ``files=`` is a multipart body, which takes form
|
|
74
|
+
fields from ``data=`` but cannot carry ``json=``.
|
|
75
|
+
"""
|
|
76
|
+
if content is not None and (json is not None or data is not None or files is not None):
|
|
77
|
+
msg = "content= is the whole request body, so it cannot be combined with json=, data= or files="
|
|
78
|
+
raise ValueError(msg)
|
|
79
|
+
if files is not None and json is not None:
|
|
80
|
+
msg = "files= sends a multipart body, which cannot carry json=; pass form fields as data="
|
|
81
|
+
raise ValueError(msg)
|