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