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/_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)