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/_auth.py ADDED
@@ -0,0 +1,504 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Provider-agnostic ``httpx2.Auth`` classes: static headers, query parameters, and a cached minted token.
5
+
6
+ ``httpx2.AsyncClient`` runs ``auth.async_auth_flow(request)`` once per logical request, above the transport stack, so a
7
+ re-send by the retry transport repeats the already-signed request. Only a re-send the flow itself yields signs again:
8
+ the single 401 re-mint in :class:`CachedTokenAuth`. An auth that needs a provider's own signer (a JWT signed with a
9
+ vendor key) belongs in that provider's client.
10
+
11
+ **An explicit ``headers=`` or ``params=`` from the caller wins a clash.** Every class here merges setdefault-style,
12
+ because the flow runs at send time and a plain assignment would override the caller.
13
+
14
+ Every class here is a :class:`~headgate._core.CredentialStamper`: it declares the headers and query parameters it
15
+ stamps (``credential_headers``, ``credential_params``), so the client withholds them from any origin it does not trust
16
+ (:mod:`headgate.security`), and it names the credential it carries (``credential_identity()``), so a cache can keep one
17
+ credential's responses from another's. The identity is the secret material itself; it is hashed, never logged or
18
+ stored.
19
+
20
+ Only the async flow is implemented; the clients are async-only.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import math
27
+ import secrets
28
+ import time
29
+ from dataclasses import dataclass
30
+ from typing import TYPE_CHECKING, ClassVar, override
31
+
32
+ import httpx2
33
+
34
+ from headgate._body import read_capped
35
+ from headgate._core import CallContext, origin_text
36
+ from headgate._failures import CallSite, loggable_url, mint_error
37
+ from headgate._url import register_secret_params
38
+ from headgate.errors import ClientConfigurationError
39
+ from headgate.security import CHAIN_ORIGIN
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Iterable, Mapping
43
+
44
+ __all__ = [
45
+ "AUTH_HOP",
46
+ "FRESH_TOKEN_S",
47
+ "MAX_MINT_RESPONSE_BYTES",
48
+ "CachedTokenAuth",
49
+ "Minted",
50
+ "QueryParamAuth",
51
+ "StaticHeaderAuth",
52
+ "failed_on_auth_hop",
53
+ ]
54
+
55
+ #: The request extension marking a request an auth flow yields to acquire a credential, as opposed to the call's own
56
+ #: request. :class:`CachedTokenAuth` sets it on every request its :meth:`~CachedTokenAuth._mint_flow` yields. A fixture
57
+ #: recorder can read it to skip the hop, which is not the endpoint's answer.
58
+ AUTH_HOP = "headgate.auth_hop"
59
+
60
+ #: The most of a mint flow's hop response that is read, in decompressed bytes. A token response is a few hundred bytes,
61
+ #: and a cookie-plus-crumb page a few dozen KiB; past this the hop is refused as ``ResponseTooLargeError``.
62
+ MAX_MINT_RESPONSE_BYTES = 1024 * 1024
63
+
64
+ #: A token rejected within this many seconds of being minted is not re-minted: it is not stale, so a fresh one carries
65
+ #: the same authority and is rejected the same way. See :class:`CachedTokenAuth`.
66
+ FRESH_TOKEN_S = 60.0
67
+
68
+
69
+ @dataclass(frozen=True, slots=True, kw_only=True)
70
+ class _Held[T]:
71
+ """A cached token, with when it was minted and when it is due for a refresh: ``time.monotonic()`` instants.
72
+
73
+ A monotonic clock is immune to wall-clock steps. ``minted_at`` is ``-inf`` for a token whose age is unknown.
74
+ """
75
+
76
+ token: T
77
+ minted_at: float
78
+ due_at: float
79
+
80
+
81
+ def _identity(pairs: Iterable[tuple[str, str]]) -> bytes:
82
+ """Encode name-value *pairs* as unambiguous bytes, in sorted order: each part length-prefixed, as a netstring."""
83
+ encoded = bytearray()
84
+ for part in (part for pair in sorted(pairs) for part in pair):
85
+ raw = part.encode()
86
+ encoded += b"%d:%b," % (len(raw), raw)
87
+ return bytes(encoded)
88
+
89
+
90
+ def failed_on_auth_hop(exc: BaseException) -> bool:
91
+ """Return whether *exc* was raised by a request an auth flow yielded (see :data:`AUTH_HOP`), not the call's own.
92
+
93
+ httpx2 stamps the failing request on every ``RequestError`` it raises, so the marker survives to the catch site. The
94
+ circuit breaker uses this to charge such a failure to nobody: a credential host being unreachable says nothing
95
+ about the host the call was addressed to.
96
+ """
97
+ if not isinstance(exc, httpx2.RequestError):
98
+ return False
99
+ try:
100
+ request = exc.request
101
+ except RuntimeError: # httpx2 raises when the error was built without a request, as a transport double may do
102
+ return False
103
+ return bool(request.extensions.get(AUTH_HOP))
104
+
105
+
106
+ class _Recorded(httpx2.AsyncByteStream):
107
+ """Pass a response's raw body through, keeping a copy of each chunk, so the body can be replayed once it is read."""
108
+
109
+ def __init__(self, raw: AsyncGenerator[bytes]) -> None:
110
+ """Pass *raw*'s chunks through."""
111
+ self._raw = raw
112
+ self.chunks: list[bytes] = []
113
+
114
+ @override
115
+ async def __aiter__(self) -> AsyncIterator[bytes]:
116
+ async for chunk in self._raw:
117
+ self.chunks.append(chunk)
118
+ yield chunk
119
+
120
+ @override
121
+ async def aclose(self) -> None:
122
+ """Close the raw body, and so the response it comes from."""
123
+ await self._raw.aclose()
124
+
125
+
126
+ async def _read_hop(response: httpx2.Response, source: str) -> httpx2.Response:
127
+ """Read the response to a mint flow's hop, up to :data:`MAX_MINT_RESPONSE_BYTES`, and return it read.
128
+
129
+ httpx2 hands an auth flow each response unread, and reads it itself only after the flow has answered, so the flow
130
+ would see ``ResponseNotRead`` on ``.content`` or ``.json()``. The body is read through a copy that counts the
131
+ decompressed bytes (:func:`~headgate._body.read_capped`), and *response* is then given its raw bytes back to read
132
+ for itself: httpx2 reads the same response again to keep it in the call's history. Past the cap the read raises
133
+ ``ResponseTooLargeError``, naming the token mint, so a hostile token host cannot make the flow buffer without end.
134
+ """
135
+ request = response.request
136
+ site = CallSite(source, request.method, f"{loggable_url(str(request.url))} (token mint)")
137
+ raw = _Recorded(httpx2.Response(response.status_code, stream=response.stream).aiter_raw())
138
+ decoded = httpx2.Response(response.status_code, headers=response.headers, stream=raw, request=request)
139
+ try:
140
+ await read_capped(decoded, MAX_MINT_RESPONSE_BYTES, site)
141
+ finally:
142
+ response.stream = httpx2.ByteStream(b"".join(raw.chunks))
143
+ await response.aread()
144
+ return response
145
+
146
+
147
+ def _prepare_hop(hop: httpx2.Request, call: httpx2.Request) -> None:
148
+ """Mark *hop*, a request a mint flow yielded for *call*, as an auth hop, and give it the call's timeouts.
149
+
150
+ The marks are :data:`AUTH_HOP` and the :data:`~headgate.security.CHAIN_ORIGIN` the credential guard trusts for the
151
+ hop. A hop that sets no ``timeout`` extension of its own takes the call's: a request built by hand has none, and a
152
+ token host that never answered would hold the call, and the mint's lock, until the deadline. Either way the hop's
153
+ timeouts are cut to the time the call's deadline leaves.
154
+ """
155
+ extensions = {**hop.extensions, AUTH_HOP: True, CHAIN_ORIGIN: origin_text(hop.url.origin)}
156
+ timeout = call.extensions.get("timeout")
157
+ if "timeout" not in extensions and isinstance(timeout, dict):
158
+ extensions["timeout"] = dict(timeout)
159
+ hop.extensions = extensions
160
+ context = CallContext.find(call)
161
+ if context is not None:
162
+ context.fit_timeouts(hop)
163
+
164
+
165
+ class StaticHeaderAuth(httpx2.Auth):
166
+ """Stamp a fixed set of headers onto every request, without overriding a header the caller set.
167
+
168
+ Covers the static API key and static bearer cases (an ``Authorization: Bearer`` or ``x-api-key`` header). An empty
169
+ mapping is a no-op, so a builder with no key configured returns ``StaticHeaderAuth({})`` rather than ``None``.
170
+ """
171
+
172
+ def __init__(self, headers: Mapping[str, str]) -> None:
173
+ """Copy *headers*, so a later change to the caller's mapping does not reach the auth."""
174
+ self._headers = dict(headers)
175
+
176
+ @property
177
+ def credential_headers(self) -> frozenset[str]:
178
+ """The headers this auth stamps, lower-cased: withheld from any origin the client does not trust."""
179
+ return frozenset(name.lower() for name in self._headers)
180
+
181
+ @property
182
+ def credential_params(self) -> frozenset[str]:
183
+ """No query parameters: this auth stamps headers only."""
184
+ return frozenset[str]()
185
+
186
+ def credential_identity(self) -> bytes:
187
+ """Return the headers and their values as bytes, names lower-cased: the same headers give the same bytes."""
188
+ return _identity((name.lower(), value) for name, value in self._headers.items())
189
+
190
+ @override
191
+ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
192
+ for key, value in self._headers.items():
193
+ request.headers.setdefault(key, value)
194
+ yield request
195
+
196
+
197
+ class QueryParamAuth(httpx2.Auth):
198
+ """Merge a fixed set of query params into the request URL, without overriding a param the caller set.
199
+
200
+ **The key rides the URL, and URLs are not secret to observability.** A tracing integration that records the full
201
+ request URL (OpenTelemetry's httpx instrumentation, for one) records the key in cleartext, so exclude the host of
202
+ any client using this class from URL capture. Error messages and the HTTP cache are safe regardless: the
203
+ well-known secret parameter names and the names given here are redacted from both. The names are registered
204
+ process-wide, which only ever over-redacts.
205
+ """
206
+
207
+ def __init__(self, params: Mapping[str, str]) -> None:
208
+ """Copy *params* and register their names as secret, for redaction from error messages and the cache."""
209
+ self._params = dict(params)
210
+ register_secret_params(self._params)
211
+
212
+ @property
213
+ def credential_headers(self) -> frozenset[str]:
214
+ """No headers: this auth stamps query parameters only."""
215
+ return frozenset[str]()
216
+
217
+ @property
218
+ def credential_params(self) -> frozenset[str]:
219
+ """The query parameters this auth stamps: withheld from any origin the client does not trust."""
220
+ return frozenset(self._params)
221
+
222
+ def credential_identity(self) -> bytes:
223
+ """Return the parameters and their values as bytes: the same parameters give the same bytes."""
224
+ return _identity(self._params.items())
225
+
226
+ @override
227
+ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
228
+ existing = request.url.params
229
+ additions = {k: v for k, v in self._params.items() if k not in existing}
230
+ if additions:
231
+ request.url = request.url.copy_merge_params(additions)
232
+ yield request
233
+
234
+
235
+ @dataclass(frozen=True, slots=True)
236
+ class Minted[TokenT]:
237
+ """The last step of a mint flow: the credential, and how long the provider says it lives (seconds)."""
238
+
239
+ token: TokenT
240
+ lifetime: float
241
+
242
+
243
+ class CachedTokenAuth[TokenT = str](httpx2.Auth):
244
+ """Bearer auth for providers that mint a short-lived token over the network.
245
+
246
+ The token comes from an endpoint, so it is cached, refreshed before it expires, and refreshed again if it is
247
+ rejected early. A subclass gets three behaviours:
248
+
249
+ * **A lock-guarded mint.** httpx2 runs requests concurrently, so an unguarded refresh would let N in-flight
250
+ requests each fire their own token exchange.
251
+ * **An expiry margin.** The token is re-minted ``refresh_margin_s`` before it expires (at half-life when the
252
+ lifetime is shorter than twice the margin), because a token that expires mid-flight fails a request that may be
253
+ a write that cannot be repeated.
254
+ * **One forced re-mint on a 401**, never a loop, for a token revoked server-side before its stated lifetime, and
255
+ none for a token minted in the last :data:`FRESH_TOKEN_S` seconds. A 401 on a token that new is a permissions
256
+ problem, not staleness, and re-minting cannot fix it: a gateway that rejects valid tokens (a revoked scope, a
257
+ changed entitlement) costs at most one mint per window, and the 401 reaches the caller as the ``AuthError`` it
258
+ is.
259
+
260
+ The exchange itself is the subclass's, because that is where providers differ (form-encoded or JSON, a separate
261
+ sign-in host, their own field names). It supplies one of:
262
+
263
+ * :meth:`_mint_token`, a coroutine that makes the exchange on a client of its own and returns
264
+ ``(token, lifetime)``. The usual shape.
265
+ * :meth:`_mint_flow`, an async generator that yields the requests the exchange needs, so they travel through the
266
+ owning client's own stack (its transport, rate budget, retries and error mapping), marked :data:`AUTH_HOP`. It
267
+ ends by yielding :class:`Minted`. A cookie-plus-crumb exchange has this shape.
268
+
269
+ Supplying neither fails when the class is defined; with both, the flow runs :meth:`_mint_flow`. Two more hooks let a
270
+ credential that is not an ``Authorization`` bearer ride the same cache: :meth:`_attach` (how a token is put on a
271
+ request) and :meth:`_skips` (a request that needs none). ``TokenT`` is the credential's type, ``str`` for every
272
+ bearer. :meth:`seed` caches a token obtained elsewhere.
273
+
274
+ **What it stamps is declared.** :attr:`token_headers` names the headers :meth:`_attach` sets (``Authorization`` by
275
+ default), and is what :attr:`credential_headers` reports, so the client withholds exactly those from an untrusted
276
+ origin. A subclass that overrides :meth:`_attach` must declare its own :attr:`token_headers` (or override
277
+ :attr:`credential_headers`), or the class definition fails: an undeclared header would follow a request anywhere.
278
+
279
+ **Its identity is the long-lived credential, not the token.** :meth:`credential_identity` names what the minted
280
+ tokens stand for, so a cache keeps one credential's responses apart from another's across token refreshes. The base
281
+ class cannot see the credential a subclass mints with, so by default each instance is its own identity (random
282
+ bytes drawn once): never shared, which is safe, but two instances holding the same credential share nothing either.
283
+ A subclass that wants them to overrides :meth:`credential_identity` to return its long-lived secret material (the
284
+ API key, or client id and secret), never the short-lived token.
285
+
286
+ The hops a :meth:`_mint_flow` yields are addressed by the subclass's own code, so each may carry credentials to the
287
+ origin it names, and the redirects it is answered with stay pinned to that origin (see :mod:`headgate.security`).
288
+
289
+ **Every hop counts toward** ``HttpOptions.max_redirects``. httpx2 keeps one history per logical request, and puts
290
+ each response an auth flow answers with in it beside the redirects, so a mint of *n* hops (and the response a 401
291
+ re-mint answers) uses *n* of the call's redirect allowance. A client whose mint makes several hops, or whose
292
+ endpoints redirect too, raises ``max_redirects`` to cover both; past it the call raises ``TransportError``
293
+ ("Exceeded maximum allowed redirects").
294
+
295
+ The lock is held while a mint flow's requests are in flight, so requests that need a token wait for the mint to
296
+ finish or fail, bounded by the hop's timeouts (the call's, unless the hop sets its own) and the call's deadline.
297
+ Closing the flow (an error, or the caller's cancellation) releases it.
298
+
299
+ **A failed mint is reported as the token endpoint's failure**, never the API call's (see
300
+ :func:`~headgate._failures.mint_error`): an exception the mint code raises (a ``ConnectError`` on its own client,
301
+ a ``raise_for_status()``, an unreadable token response) becomes the ``ClientError`` it means, named for this auth
302
+ class and the token endpoint, chained to the original. It is raised from the auth flow, so the API host's circuit
303
+ breaker records nothing for it. A hop :meth:`_mint_flow` yields travels the owning client's stack and fails as any
304
+ request there does; reading its response (bounded by :data:`MAX_MINT_RESPONSE_BYTES`) fails through the same
305
+ mapping, so a body that is too large is ``ResponseTooLargeError`` and a dropped connection ``TransportError``.
306
+ """
307
+
308
+ #: The headers :meth:`_attach` sets. A subclass whose :meth:`_attach` sets others declares them here.
309
+ token_headers: ClassVar[frozenset[str]] = frozenset({"Authorization"})
310
+
311
+ def __init__(self, *, refresh_margin_s: float = 120.0) -> None:
312
+ """Start with an empty cache that refreshes *refresh_margin_s* seconds before a token expires."""
313
+ self._identity = secrets.token_bytes(32)
314
+ self._refresh_margin_s = refresh_margin_s
315
+ self._held: _Held[TokenT] | None = None
316
+ self._lock = asyncio.Lock()
317
+
318
+ # -- Subclass contract ---------------------------------------------------
319
+
320
+ def __init_subclass__(cls, **kwargs: object) -> None:
321
+ """Refuse a subclass with no mint, or whose :meth:`_attach` sets headers it has not declared.
322
+
323
+ A subclass must inherit a :meth:`_mint_token` or a :meth:`_mint_flow` override; and when it overrides
324
+ :meth:`_attach`, that class or one below it must declare :attr:`token_headers` or override
325
+ :attr:`credential_headers`.
326
+ """
327
+ super().__init_subclass__(**kwargs)
328
+ # Not @abstractmethod: either of two methods satisfies the contract.
329
+ supplied = any(
330
+ getattr(cls, name) is not getattr(CachedTokenAuth, name) for name in ("_mint_token", "_mint_flow")
331
+ )
332
+ if not supplied:
333
+ msg = f"{cls.__name__} must override _mint_token or _mint_flow"
334
+ raise TypeError(msg)
335
+ mro = cls.__mro__
336
+ attaching = next(klass for klass in mro if "_attach" in vars(klass))
337
+ declared = any(
338
+ "token_headers" in vars(klass) or "credential_headers" in vars(klass)
339
+ for klass in mro[: mro.index(attaching) + 1]
340
+ )
341
+ if not declared:
342
+ msg = (
343
+ f"{cls.__name__} overrides _attach, so it must declare the headers _attach sets in token_headers "
344
+ f"(or override credential_headers)"
345
+ )
346
+ raise TypeError(msg)
347
+
348
+ # -- What it stamps (CredentialStamper) ----------------------------------
349
+
350
+ @property
351
+ def credential_headers(self) -> frozenset[str]:
352
+ """The headers :meth:`_attach` sets (:attr:`token_headers`), lower-cased."""
353
+ return frozenset(name.lower() for name in self.token_headers)
354
+
355
+ @property
356
+ def credential_params(self) -> frozenset[str]:
357
+ """No query parameters: a minted token rides a header."""
358
+ return frozenset[str]()
359
+
360
+ def credential_identity(self) -> bytes:
361
+ """Return bytes naming the long-lived credential this auth mints with: by default, this instance's own.
362
+
363
+ A subclass whose instances should share a cache partition when they hold the same credential returns that
364
+ credential's secret material here, never the short-lived token, which changes on every refresh.
365
+ """
366
+ return self._identity
367
+
368
+ async def _mint_token(self) -> tuple[TokenT, float]:
369
+ """Exchange the provider's long-lived credential for ``(access_token, lifetime_seconds)``.
370
+
371
+ Called with the lock held, so an implementation need not guard against concurrent mints. Raise rather than
372
+ return an empty token. An implementation whose provider may rotate its long-lived credential calls
373
+ :meth:`reject_credential_rotation` with the returned value.
374
+ """
375
+ msg = f"{type(self).__name__} mints through _mint_flow, not _mint_token"
376
+ raise NotImplementedError(msg)
377
+
378
+ async def _mint_flow(
379
+ self, request: httpx2.Request
380
+ ) -> AsyncGenerator[httpx2.Request | Minted[TokenT], httpx2.Response]:
381
+ """Mint through the owning client's stack: yield each request the exchange needs, end with :class:`Minted`.
382
+
383
+ Each response is sent back in already read (up to :data:`MAX_MINT_RESPONSE_BYTES`), so ``.content`` and
384
+ ``.json()`` work on it. A hop that sets no ``timeout`` extension is sent with the call's timeouts, and every
385
+ hop's timeouts are cut to what the call's deadline leaves. *request* is the call that needs the credential, for
386
+ a hop that wants another of its extensions. Called with the lock held, like :meth:`_mint_token`; the default
387
+ delegates to it.
388
+ """
389
+ del request # only a flow that yields its own requests needs the call it is minting for
390
+ token, lifetime = await self._mint_token()
391
+ yield Minted(token, lifetime)
392
+
393
+ def _attach(self, request: httpx2.Request, token: TokenT) -> None:
394
+ """Put *token* on *request*, replacing any earlier one: the re-send after a 401 calls this a second time.
395
+
396
+ An override declares the headers it sets in :attr:`token_headers`.
397
+ """
398
+ request.headers["Authorization"] = f"Bearer {token}"
399
+
400
+ def _skips(self, request: httpx2.Request) -> bool:
401
+ """Return whether *request* needs no credential from this auth: by default, one carrying a token header."""
402
+ return any(name in request.headers for name in self.token_headers)
403
+
404
+ @staticmethod
405
+ def reject_credential_rotation(returned: str | None, configured: str, *, provider: str, setting: str) -> None:
406
+ """Raise ``ClientConfigurationError`` when a token endpoint returns a replacement for the configured credential.
407
+
408
+ When a token endpoint returns a replacement for the credential used to call it, the configured one is dead and
409
+ the replacement exists only in this response. An in-memory cache cannot persist it, so the only safe answer is
410
+ to fail on the first exchange and say why: discarding the replacement silently buys one working call, then an
411
+ opaque ``400`` once the access token expires. An absent or empty *returned*, or one equal to *configured*, is
412
+ benign and common. *setting* names where the operator configured the credential, for the error message.
413
+ """
414
+ if returned is None or returned in {"", configured}:
415
+ return
416
+ msg = (
417
+ f"{provider} returned a new long-lived credential, which means this key is configured for rotating "
418
+ f"credentials. This client cannot use that: the replacement would have to be persisted durably on every "
419
+ f"exchange, and the configured {setting} is now dead. Re-authorise with a non-rotating key and set "
420
+ f"{setting} to the new value."
421
+ )
422
+ raise ClientConfigurationError(msg, source=provider)
423
+
424
+ # -- Cache ---------------------------------------------------------------
425
+
426
+ def seed(self, token: TokenT, lifetime: float) -> None:
427
+ """Cache *token*, obtained outside this auth, with *lifetime* seconds left to live.
428
+
429
+ For a token restored from storage, or a test's fixed bearer. Its age is unknown, so it never counts as fresh:
430
+ a 401 on it re-mints.
431
+ """
432
+ self._cache(token, lifetime, minted_at=-math.inf)
433
+
434
+ def _cache(self, token: TokenT, lifetime: float, *, minted_at: float) -> None:
435
+ # A lifetime at or under the margin would read as due the moment it was cached, so every request would mint
436
+ # again: refresh at half-life instead.
437
+ margin = min(self._refresh_margin_s, lifetime / 2)
438
+ self._held = _Held(token=token, minted_at=minted_at, due_at=time.monotonic() + lifetime - margin)
439
+
440
+ def _usable(self, rejected: TokenT | None) -> TokenT | None:
441
+ """Return the cached token unless it is absent, the one a 401 just rejected, or within the refresh margin."""
442
+ held = self._held
443
+ if held is not None and held.token != rejected and time.monotonic() < held.due_at:
444
+ return held.token
445
+ return None
446
+
447
+ def _is_fresh(self, token: TokenT) -> bool:
448
+ """Return whether *token* is still the cached one and was minted within :data:`FRESH_TOKEN_S`.
449
+
450
+ A rejected token that is no longer cached was already replaced by another request, so the replacement is worth
451
+ a retry, and the cache hands it over without minting.
452
+ """
453
+ held = self._held
454
+ return held is not None and token == held.token and time.monotonic() - held.minted_at < FRESH_TOKEN_S
455
+
456
+ async def _mint_step[T](self, step: Awaitable[T]) -> T:
457
+ """Await a *step* of the mint, raising a failure in it as the ``ClientError`` it means."""
458
+ try:
459
+ return await step
460
+ except Exception as exc:
461
+ mapped = mint_error(exc, source=type(self).__name__)
462
+ if mapped is exc:
463
+ raise
464
+ raise mapped from exc
465
+
466
+ @override
467
+ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
468
+ if self._skips(request):
469
+ yield request
470
+ return
471
+
472
+ # The 401 path re-sends this request, so a streamed body is buffered before the first send. httpx2 honours
473
+ # ``requires_request_body`` only in its default flows, which this method replaces.
474
+ await request.aread()
475
+ # The token a 401 came back for, rather than a "force" flag: each waiter on the lock re-checks the cache
476
+ # against the token it was rejected for, so concurrent 401s share the first replacement instead of each minting.
477
+ rejected: TokenT | None = None
478
+ while True:
479
+ async with self._lock:
480
+ token = self._usable(rejected)
481
+ if token is None:
482
+ # Only this generator can yield to the client, so it drives the mint flow: httpx2 sends each
483
+ # request the mint yields and hands its response back in.
484
+ mint = self._mint_flow(request)
485
+ try:
486
+ step = await self._mint_step(anext(mint))
487
+ while isinstance(step, httpx2.Request):
488
+ _prepare_hop(step, request)
489
+ hop_response = yield step
490
+ hop_response = await self._mint_step(_read_hop(hop_response, type(self).__name__))
491
+ step = await self._mint_step(mint.asend(hop_response))
492
+ except StopAsyncIteration:
493
+ msg = f"{type(self).__name__}._mint_flow ended without yielding Minted"
494
+ raise RuntimeError(msg) from None
495
+ finally:
496
+ await mint.aclose()
497
+ token = step.token
498
+ self._cache(token, step.lifetime, minted_at=time.monotonic())
499
+ self._attach(request, token)
500
+ response = yield request
501
+ # ``rejected`` is set only after the first 401, so this re-sends at most once.
502
+ if response.status_code != httpx2.codes.UNAUTHORIZED or rejected is not None or self._is_fresh(token):
503
+ return
504
+ rejected = token
headgate/_body.py ADDED
@@ -0,0 +1,115 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """A response's body, read up to a size cap, and the response rebuilt around it.
5
+
6
+ Every buffered call reads the whole body before a reader sees it, so an unbounded response is an OOM vector.
7
+ :data:`DEFAULT_MAX_RESPONSE_BYTES` is the ceiling and :func:`read_capped` is the one function that enforces it.
8
+
9
+ Every count here is of **decompressed** bytes: ``aiter_bytes()`` yields the body after content-decoding, and httpx2
10
+ decompresses in slices of at most 1 MiB, so a gzip bomb is counted before it has materialized
11
+ (``tests/client/test_response_size_cap.py`` pins that slice bound). Each reader closes the stream when it is done,
12
+ suppressing a close failure: closing a partly read compressed stream makes httpx2 finalize its decoder, which raises
13
+ ``DecodingError`` on the truncated input, and that must not replace the error in flight.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ from typing import TYPE_CHECKING
20
+
21
+ import httpx2
22
+
23
+ from headgate.errors import ResponseTooLargeError
24
+
25
+ if TYPE_CHECKING:
26
+ from headgate._failures import CallSite
27
+
28
+ __all__ = ["DEFAULT_MAX_RESPONSE_BYTES", "read_capped", "read_head"]
29
+
30
+ #: Ceiling on a single **decompressed** response body, in bytes. httpx2 has no body-size ceiling of its own
31
+ #: (``httpx2.Limits`` caps connections only), and a ~500 KB gzipped body can expand to 524 MB. Raise it per client
32
+ #: with ``max_response_bytes=``, or use ``stream()`` for a body too large to hold.
33
+ DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
34
+
35
+ #: Headers describing the on-the-wire encoding, dropped from a rebuilt response because its body is already decoded.
36
+ _WIRE_ENCODING_HEADERS = ("content-encoding", "content-length")
37
+
38
+
39
+ async def read_capped(streamed: httpx2.Response, cap: int, site: CallSite) -> httpx2.Response:
40
+ """Read *streamed*'s body up to *cap* decompressed bytes, close it, and return the response rebuilt around it.
41
+
42
+ Raises :class:`~headgate.errors.ResponseTooLargeError` past the cap, naming the call at *site*.
43
+ ``Content-Length`` is checked first as a cheap refusal of an honestly oversized body, but it is never the control:
44
+ it reports the compressed size (so a gzip bomb passes it) and is absent on a chunked response. The control is the
45
+ running count of decoded bytes.
46
+
47
+ Bodies httpx2 reads itself are outside this function: a followed redirect's body is dropped unread by
48
+ :func:`~headgate._redirects.drop_followed_redirect_body`, and a 401 an auth flow answers by re-sending is read in
49
+ full by httpx2 before the retry.
50
+ """
51
+ try:
52
+ declared = streamed.headers.get("content-length")
53
+ # isascii: str.isdigit() also accepts digits int() rejects, such as a latin-1 "²".
54
+ if declared is not None and declared.isascii() and declared.isdigit() and int(declared) > cap:
55
+ msg = f"declared response size {int(declared)} exceeds cap {cap} for {site.url}"
56
+ raise ResponseTooLargeError(msg, **site.details(streamed))
57
+ content = await _read_past(streamed, cap)
58
+ finally:
59
+ await _close(streamed)
60
+ if len(content) > cap:
61
+ msg = f"response body exceeded cap {cap} bytes for {site.url}"
62
+ raise ResponseTooLargeError(msg, **site.details(streamed))
63
+ return _rebuilt(streamed, content)
64
+
65
+
66
+ async def read_head(streamed: httpx2.Response, limit: int) -> httpx2.Response:
67
+ """Read at most *limit* decompressed bytes of *streamed*, close it, and return the response rebuilt around them.
68
+
69
+ For a streamed response that will not be consumed (a refusal or a non-success status), where only the status, the
70
+ headers and a bounded snippet of the body feed the ``ClientError``. It never raises for a long body: the rest is
71
+ simply not read.
72
+ """
73
+ try:
74
+ content = await _read_past(streamed, limit)
75
+ finally:
76
+ await _close(streamed)
77
+ return _rebuilt(streamed, content[:limit])
78
+
79
+
80
+ async def _read_past(streamed: httpx2.Response, limit: int) -> bytes:
81
+ """Read *streamed* until more than *limit* bytes have arrived or the body ends, and return what was read.
82
+
83
+ Stopping one chunk past *limit* lets the caller tell a body of exactly *limit* bytes from a longer one.
84
+ """
85
+ chunks: list[bytes] = []
86
+ total = 0
87
+ async for chunk in streamed.aiter_bytes():
88
+ chunks.append(chunk)
89
+ total += len(chunk)
90
+ if total > limit:
91
+ break
92
+ return b"".join(chunks)
93
+
94
+
95
+ async def _close(streamed: httpx2.Response) -> None:
96
+ """Close *streamed*, suppressing the ``DecodingError`` a truncated compressed stream raises on close."""
97
+ with contextlib.suppress(httpx2.HTTPError):
98
+ await streamed.aclose()
99
+
100
+
101
+ def _rebuilt(streamed: httpx2.Response, content: bytes) -> httpx2.Response:
102
+ """Return a fully read copy of *streamed* carrying *content*, for readers that use ``.content`` or ``.json()``.
103
+
104
+ Built on ``streamed.request``, not the request that was sent: httpx2 derives ``Response.url`` from the request,
105
+ and after a followed redirect the two differ. A consumer that detects a bad key by the redirect it causes needs
106
+ the URL that answered.
107
+ """
108
+ return httpx2.Response(
109
+ status_code=streamed.status_code,
110
+ headers=[(k, v) for k, v in streamed.headers.multi_items() if k.lower() not in _WIRE_ENCODING_HEADERS],
111
+ content=content,
112
+ request=streamed.request,
113
+ history=streamed.history,
114
+ extensions=streamed.extensions,
115
+ )