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/_failures.py ADDED
@@ -0,0 +1,439 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """The ``ClientError`` a failed exchange becomes: pure builders.
5
+
6
+ Each builder returns its error rather than raising it, so the caller raises it with the right cause (``from exc`` for
7
+ a transport failure, ``from refused`` for a reader's :class:`~headgate._responses.RefusedError`). Each takes the
8
+ :class:`CallSite` it failed at: the client class's name, the request line and the attempt count, which every
9
+ ``ClientError`` carries with the response's request id, whether a retry could succeed, and any ``Retry-After``.
10
+
11
+ An ``application/problem+json`` body (RFC 9457) is parsed into a :class:`~headgate.errors.Problem` on the status
12
+ errors, bounded to :data:`_PROBLEM_SCAN_BYTES`, and its title and detail replace the raw body snippet in the message.
13
+
14
+ Every message here can reach an application boundary's error detail, so no builder puts a raw URL, an unbounded text
15
+ or a credential in one. A ``log_url`` argument is already sanitized and shortened (:func:`loggable_url`); every other
16
+ text that comes from outside (an exception's message, a body) is bounded with :func:`shorten`. The status errors name
17
+ the request line as ``METHOD log_url``, so a 404, a surviving 503 and an undecodable 200 read alike.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from collections.abc import Mapping
25
+ from dataclasses import dataclass
26
+ from typing import TYPE_CHECKING, Final
27
+
28
+ import httpx2
29
+
30
+ from headgate._core import retry_after_of
31
+ from headgate._url import redact_secrets, sanitize_url
32
+ from headgate.errors import (
33
+ AuthError,
34
+ ClientError,
35
+ ClientTimeoutError,
36
+ DecodeError,
37
+ ErrorDetails,
38
+ Problem,
39
+ RateLimitError,
40
+ ResponseParseError,
41
+ TransportError,
42
+ UpstreamStatusError,
43
+ )
44
+ from headgate.retry._policy import UNRETRYABLE_ERRORS
45
+
46
+ if TYPE_CHECKING:
47
+ from headgate._responses import RefusedError
48
+ from headgate.retry._policy import RetryOutcome
49
+
50
+ __all__ = [
51
+ "DEFAULT_REQUEST_ID_HEADERS",
52
+ "MESSAGE_TEXT_LIMIT",
53
+ "SHAPE_ERRORS",
54
+ "CallSite",
55
+ "body_snippet",
56
+ "is_transient_status",
57
+ "loggable_url",
58
+ "mint_error",
59
+ "parse_error",
60
+ "problem_of",
61
+ "refusal_error",
62
+ "shorten",
63
+ "status_error",
64
+ "transient_status_error",
65
+ "transport_error",
66
+ ]
67
+
68
+ #: Cap on the error-body snippet folded into a ClientError message: enough for a provider's structured reason, not
69
+ #: enough to turn an HTML error page into a payload.
70
+ _BODY_SNIPPET_LIMIT = 300
71
+
72
+ #: How much of an error body is decoded to find the snippet. Bounds the work on a body up to the response cap, while
73
+ #: leaving room for the whitespace an HTML page spends before its text.
74
+ _SNIPPET_SCAN_BYTES = 16 * 1024
75
+
76
+ #: Cap on a URL named in a message. A URL may legitimately run to httpx2's 65,536-character limit, and a message that
77
+ #: repeats it whole is a log line nobody can read.
78
+ _URL_LIMIT = 300
79
+
80
+ #: The control characters (C0 and DEL) a URL named in a message may not carry as they are.
81
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]")
82
+
83
+ #: Cap on any other outside text folded into a message: an exception's own message, a decoder's diagnosis.
84
+ MESSAGE_TEXT_LIMIT = 1000
85
+
86
+ #: What httpx2 raises for a response a mint handled out of order (its body read before it was fetched, or twice) or a
87
+ #: cookie it asked for by a name two cookies share: the token response could not be read as the mint meant to.
88
+ _MISREAD_RESPONSE_ERRORS: tuple[type[Exception], ...] = (httpx2.StreamError, httpx2.CookieConflict)
89
+
90
+ #: The statuses that name our own credential as the failure.
91
+ _AUTH_STATUSES = frozenset({httpx2.codes.UNAUTHORIZED, httpx2.codes.FORBIDDEN})
92
+
93
+ #: What a reader or a token mint raises for a body that is not the shape it expected: a codec's ``DecodeError``, and
94
+ #: what hand-written code over ``response.json()`` raises (``JSONDecodeError`` is a ``ValueError``; a missing key, an
95
+ #: index past the end, or indexing the wrong container).
96
+ SHAPE_ERRORS: tuple[type[Exception], ...] = (DecodeError, ValueError, LookupError, TypeError)
97
+
98
+ #: A header name as RFC 9110 defines it (a ``token``), the only kind of name a message repeats.
99
+ _HEADER_NAME = re.compile(rb"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
100
+
101
+ #: A header value h11 accepts: bytes other than NUL and whitespace, with spaces or tabs only between them.
102
+ _HEADER_VALUE = re.compile(rb"(?:[^\x00\s]+(?:[ \t]+[^\x00\s]+)*)?")
103
+
104
+ #: The response headers an upstream's request id is read from, first match wins: the de facto names, AWS's, Azure's and
105
+ #: Cloudflare's.
106
+ DEFAULT_REQUEST_ID_HEADERS: Final = ("x-request-id", "request-id", "x-amzn-requestid", "x-ms-request-id", "cf-ray")
107
+
108
+ #: A request id longer than this is cut, since it is upstream text that reaches an error.
109
+ _REQUEST_ID_LIMIT = 200
110
+
111
+ #: The largest problem document parsed. A problem detail is a few hundred bytes; past this the body is not one.
112
+ _PROBLEM_SCAN_BYTES = 64 * 1024
113
+
114
+ #: The media type RFC 9457 registers for a JSON problem detail.
115
+ _PROBLEM_MEDIA_TYPE = "application/problem+json"
116
+
117
+ #: What a malformed problem document raises while it is parsed: bad JSON, bad UTF-8, or nesting past the recursion
118
+ #: limit.
119
+ _PROBLEM_ERRORS = (ValueError, RecursionError)
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class CallSite:
124
+ """Where a failure happened: the client, the request line, how many sends the call made, and its request-id headers.
125
+
126
+ ``url`` is already sanitized and bounded (:func:`loggable_url`). ``retry`` is the retry layer's verdict on the
127
+ call's last attempt, if it made one. It decides whether the error is retryable (see :meth:`details`), and supplies
128
+ the delay the upstream asked for when the builder has none of its own.
129
+ """
130
+
131
+ source: str
132
+ method: str
133
+ url: str
134
+ attempts: int | None = None
135
+ request_id_headers: tuple[str, ...] = DEFAULT_REQUEST_ID_HEADERS
136
+ retry: RetryOutcome | None = None
137
+
138
+ def details(
139
+ self,
140
+ resp: httpx2.Response | None = None,
141
+ *,
142
+ failure: Exception | None = None,
143
+ retryable: bool = False,
144
+ retry_after: float | None = None,
145
+ ) -> ErrorDetails:
146
+ """Return the :class:`~headgate.errors.ErrorDetails` of an error raised here, about *resp* if there is one.
147
+
148
+ ``retryable`` is the retry policy's verdict on what failed: *failure* if given, else *resp*, else the call's
149
+ last attempt. So it is ``True`` exactly when the active :class:`~headgate.RetryPolicy` would re-send after this
150
+ outcome: a status in its ``statuses`` or a network failure, and only when sending the request again is safe for
151
+ its method. The *retryable* argument is the fallback for a call no retry layer judged (a token mint on a client
152
+ of its own).
153
+ """
154
+ if self.retry is not None:
155
+ result = failure if failure is not None else resp
156
+ judged = self.retry if result is None else self.retry.about(result)
157
+ retryable = judged.retryable
158
+ retry_after = judged.retry_after if retry_after is None else retry_after
159
+ return ErrorDetails(
160
+ source=self.source,
161
+ method=self.method,
162
+ url=self.url,
163
+ attempts=self.attempts,
164
+ request_id=None if resp is None else self._request_id(resp),
165
+ retryable=retryable,
166
+ retry_after=retry_after,
167
+ )
168
+
169
+ def retries_status(self, resp: httpx2.Response) -> bool:
170
+ """Return whether the call's retry policy counts *resp*'s status as a failure to re-send, not an answer.
171
+
172
+ That is a status in its ``statuses``, or the server's ``x-should-retry: true``. Method safety plays no part: a
173
+ 503 to a POST is a failure the policy would re-send were the POST safe to send again.
174
+ """
175
+ return self.retry is not None and self.retry.about(resp).failure
176
+
177
+ def _request_id(self, resp: httpx2.Response) -> str | None:
178
+ """Return the first request-id header *resp* carries, bounded, or ``None``."""
179
+ for name in self.request_id_headers:
180
+ value = resp.headers.get(name, "")
181
+ if value:
182
+ return shorten(value, _REQUEST_ID_LIMIT)
183
+ return None
184
+
185
+
186
+ def shorten(text: str, limit: int = MESSAGE_TEXT_LIMIT) -> str:
187
+ """Return *text* cut to at most *limit* characters, marked with a trailing ``…`` when cut."""
188
+ return text if len(text) <= limit else text[: limit - 1] + "…"
189
+
190
+
191
+ def loggable_url(url: str) -> str:
192
+ """Return *url* as every message names it: credentials redacted (:func:`sanitize_url`), then shortened.
193
+
194
+ A control character is percent-encoded, so a URL httpx2 refused (one taken from a response, say) cannot start a
195
+ forged line in a log that records the message.
196
+ """
197
+ return shorten(_CONTROL.sub(_percent_encoded, sanitize_url(url)), _URL_LIMIT)
198
+
199
+
200
+ def _percent_encoded(match: re.Match[str]) -> str:
201
+ """Return the matched character as ``%XX``."""
202
+ return f"%{ord(match[0]):02X}"
203
+
204
+
205
+ def body_snippet(resp: httpx2.Response) -> str | None:
206
+ """Return a bounded, single-line view of an error response body, or ``None`` for a blank one.
207
+
208
+ The upstream's own reason for a 4xx is the diagnosis (a 400 that names the missing field), so it is folded into the
209
+ ``ClientError`` message, bounded and whitespace-collapsed because that message may reach an API response.
210
+ """
211
+ head = resp.content[:_SNIPPET_SCAN_BYTES].decode(resp.encoding or "utf-8", errors="replace")
212
+ text = " ".join(head.split())
213
+ if not text:
214
+ return None
215
+ if len(text) > _BODY_SNIPPET_LIMIT:
216
+ return text[:_BODY_SNIPPET_LIMIT] + "…"
217
+ return text
218
+
219
+
220
+ def is_transient_status(status: int) -> bool:
221
+ """Return whether *status* is raised by :func:`transient_status_error`, not the status ladder: 429 or any 5xx.
222
+
223
+ Whether it is retryable is the retry policy's call (``RetryPolicy.statuses``), not this function's.
224
+ """
225
+ return status == httpx2.codes.TOO_MANY_REQUESTS or status >= httpx2.codes.INTERNAL_SERVER_ERROR
226
+
227
+
228
+ def problem_of(resp: httpx2.Response) -> Problem | None:
229
+ """Return the RFC 9457 problem detail *resp*'s body carries, or ``None`` when it is not one.
230
+
231
+ Only a body declared ``application/problem+json``, no larger than :data:`_PROBLEM_SCAN_BYTES`, holding a JSON object
232
+ is read. A standard member of the wrong JSON type is ignored, as the RFC says; every other member is an extension.
233
+ """
234
+ media_type = resp.headers.get("content-type", "").partition(";")[0].strip().lower()
235
+ content = resp.content
236
+ if media_type != _PROBLEM_MEDIA_TYPE or len(content) > _PROBLEM_SCAN_BYTES:
237
+ return None
238
+ try:
239
+ document = json.loads(content)
240
+ except _PROBLEM_ERRORS:
241
+ return None
242
+ if not isinstance(document, Mapping):
243
+ return None
244
+ members = {str(key): value for key, value in document.items()}
245
+ status = members.pop("status", None)
246
+ return Problem(
247
+ type=_text_member(members.pop("type", None)) or "about:blank",
248
+ title=_text_member(members.pop("title", None)),
249
+ status=status if isinstance(status, int) and not isinstance(status, bool) else None,
250
+ detail=_text_member(members.pop("detail", None)),
251
+ instance=_text_member(members.pop("instance", None)),
252
+ extensions=members,
253
+ )
254
+
255
+
256
+ def _text_member(value: object) -> str | None:
257
+ """Return a problem member's value if it is a JSON string, else ``None``."""
258
+ return value if isinstance(value, str) else None
259
+
260
+
261
+ def _summary(resp: httpx2.Response, problem: Problem | None) -> str | None:
262
+ """Return what a message says of *resp*'s body: the problem's title and detail if it has them, else a snippet.
263
+
264
+ Bounded and whitespace-collapsed like :func:`body_snippet`.
265
+ """
266
+ members = () if problem is None else (problem.title, problem.detail)
267
+ named = [text for text in members if text is not None and text.strip()]
268
+ if not named:
269
+ return body_snippet(resp)
270
+ text = " ".join(": ".join(named).split())
271
+ return text if len(text) <= _BODY_SNIPPET_LIMIT else text[:_BODY_SNIPPET_LIMIT] + "…"
272
+
273
+
274
+ def status_error(resp: httpx2.Response, site: CallSite) -> UpstreamStatusError:
275
+ """Build the ``ClientError`` for a non-success status the call did not declare: the status ladder.
276
+
277
+ 401/403 become :class:`~headgate.errors.AuthError`, because a bad credential is this client's configuration
278
+ failing against the upstream, not the caller's request. Any other status becomes
279
+ :class:`~headgate.errors.UpstreamStatusError`. Both carry the real status, the problem detail if the body is one,
280
+ and a bounded summary of the body.
281
+ """
282
+ kind = AuthError if resp.status_code in _AUTH_STATUSES else UpstreamStatusError
283
+ problem = problem_of(resp)
284
+ return kind(
285
+ f"http error for {site.method} {site.url}",
286
+ status_code=resp.status_code,
287
+ body=_summary(resp, problem),
288
+ problem=problem,
289
+ **site.details(resp),
290
+ )
291
+
292
+
293
+ def refusal_error(refused: RefusedError, resp: httpx2.Response, site: CallSite) -> UpstreamStatusError:
294
+ """Build the ``ClientError`` for a provider's :class:`RefusedError`, from a reader or the host's ``refusal_check``.
295
+
296
+ Both paths build the error here, so both carry the provider's code, the client's name and the body snippet.
297
+ ``refused.kind`` names the class, and the status is the one the wire sent: a refusal written inside a 200 body
298
+ carries 200, and a host's key-error redirect carries its 302.
299
+ """
300
+ return refused.kind(
301
+ f"{refused.message} ({site.url})",
302
+ status_code=resp.status_code,
303
+ code=refused.code,
304
+ body=body_snippet(resp),
305
+ **site.details(resp),
306
+ )
307
+
308
+
309
+ def parse_error(exc: Exception, resp: httpx2.Response, site: CallSite) -> ResponseParseError:
310
+ """Build the ``ClientError`` for a 2xx body its reader could not read (one of :data:`SHAPE_ERRORS`).
311
+
312
+ The message names the status and the request line, and the reader's diagnosis bounded by :func:`shorten`.
313
+ """
314
+ detail = shorten(str(exc)) or type(exc).__name__
315
+ return ResponseParseError(
316
+ f"unreadable {resp.status_code} body for {site.method} {site.url}: {detail}",
317
+ status_code=resp.status_code,
318
+ content_type=resp.headers.get("content-type"),
319
+ **site.details(resp),
320
+ )
321
+
322
+
323
+ def transport_error(exc: httpx2.HTTPError | OSError, site: CallSite) -> TransportError:
324
+ """Build the ``ClientError`` for a network, protocol or timeout failure: nothing usable came back.
325
+
326
+ httpx2 names the URL in most of its own messages but a bare ``OSError`` names nothing, so the request line is
327
+ stated explicitly. Many httpx2 exceptions stringify to ``""`` (a dropped connection, a read timeout), and the type
328
+ name is then the only record of what failed. A timeout becomes :class:`~headgate.errors.ClientTimeoutError`.
329
+
330
+ An ``httpx2.LocalProtocolError`` gets a fixed message instead (see :func:`_unsendable_detail`): it is the HTTP
331
+ library refusing to encode the request, and its own message quotes the offending header value, which is usually
332
+ the credential. Its caller raises the result ``from None`` for the same reason.
333
+ """
334
+ if isinstance(exc, httpx2.LocalProtocolError):
335
+ detail = _unsendable_detail(exc)
336
+ else:
337
+ detail = shorten(redact_secrets(str(exc))) or type(exc).__name__
338
+ message = f"{site.method} {site.url}: {detail}"
339
+ details = site.details(failure=exc, retryable=not isinstance(exc, UNRETRYABLE_ERRORS))
340
+ if isinstance(exc, httpx2.TimeoutException):
341
+ return ClientTimeoutError(message, **details)
342
+ return TransportError(message, **details)
343
+
344
+
345
+ def _unsendable_detail(exc: httpx2.LocalProtocolError) -> str:
346
+ """Describe a request the HTTP library refused to encode without repeating any of its values.
347
+
348
+ The names of the headers whose values are not legal field values are given when the request is known, since that
349
+ is what an operator needs to find the bad setting; a name that is not itself a legal token is not repeated.
350
+ """
351
+ detail = "the request could not be encoded (LocalProtocolError)"
352
+ try:
353
+ request = exc.request
354
+ except RuntimeError: # httpx2 raises when the error was built without a request
355
+ return detail
356
+ bad = [name for name, value in request.headers.raw if not _HEADER_VALUE.fullmatch(value)]
357
+ if not bad:
358
+ return detail
359
+ shown = ", ".join(name.decode() if _HEADER_NAME.fullmatch(name) else "<invalid name>" for name in bad)
360
+ return f"{detail}: illegal value in header {shown}"
361
+
362
+
363
+ def transient_status_error(resp: httpx2.Response, site: CallSite) -> UpstreamStatusError:
364
+ """Build the ``ClientError`` for a 429 or 5xx that reached the client, past whatever retries the policy made.
365
+
366
+ A 429 becomes :class:`~headgate.errors.RateLimitError`, anything else
367
+ :class:`~headgate.errors.UpstreamStatusError`. *resp* carries a bounded head of the body, which becomes the
368
+ summary, as it does for :func:`status_error`. The message calls the status ``transient`` when the retry policy
369
+ counts it as a failure to re-send (:meth:`CallSite.retries_status`), and ``upstream`` otherwise (a 501, a 505). The
370
+ error is retryable when the retry policy would re-send after *resp* (see :meth:`CallSite.details`): a 5xx outside
371
+ its ``statuses`` is not, nor is a 503 on a POST without an idempotency key. A 429's or 503's ``retry-after-ms`` or
372
+ ``Retry-After`` is its ``retry_after``.
373
+ """
374
+ code = resp.status_code
375
+ kind = RateLimitError if code == httpx2.codes.TOO_MANY_REQUESTS else UpstreamStatusError
376
+ problem = problem_of(resp)
377
+ wording = "transient" if site.retries_status(resp) else "upstream"
378
+ return kind(
379
+ f"{wording} {code} for {site.method} {site.url}",
380
+ status_code=code,
381
+ body=_summary(resp, problem),
382
+ problem=problem,
383
+ **site.details(resp, retry_after=retry_after_of(resp)),
384
+ )
385
+
386
+
387
+ def mint_error(exc: Exception, *, source: str) -> Exception:
388
+ """Map a failure raised while a :class:`~headgate.CachedTokenAuth` minted its token onto the ``ClientError`` tree.
389
+
390
+ The mint usually runs on a client of its own, so its failure names the token endpoint, never the API URL of the
391
+ call that needed the token, and it is raised from the auth flow rather than the transport, so the API host's
392
+ circuit breaker records nothing for it:
393
+
394
+ * a ``ClientError`` is returned as it is;
395
+ * ``httpx2.HTTPStatusError`` (a mint's ``raise_for_status()``) becomes the status ladder's error for the token
396
+ endpoint: ``AuthError`` for 401/403, ``UpstreamStatusError`` otherwise;
397
+ * any other ``httpx2.HTTPError`` or an ``OSError`` becomes ``TransportError`` / ``ClientTimeoutError``;
398
+ * one of :data:`SHAPE_ERRORS` (an unreadable token response), or an ``httpx2.StreamError`` or
399
+ ``httpx2.CookieConflict`` (a response handled out of order, a cookie name two cookies share), becomes
400
+ ``ResponseParseError``.
401
+
402
+ Anything else is returned as it is, for the caller to re-raise: it is a bug in the mint, not a failure to report.
403
+ """
404
+ if isinstance(exc, ClientError):
405
+ return exc
406
+ if isinstance(exc, httpx2.HTTPStatusError):
407
+ resp = exc.response
408
+ method, log_url = _request_line(exc)
409
+ kind = AuthError if resp.status_code in _AUTH_STATUSES else UpstreamStatusError
410
+ try:
411
+ body = body_snippet(resp)
412
+ except httpx2.ResponseNotRead: # a mint that checked the status of a streamed response before reading it
413
+ body = None
414
+ return kind(
415
+ f"token mint failed for {method} {log_url}",
416
+ status_code=resp.status_code,
417
+ body=body,
418
+ source=source,
419
+ method=method,
420
+ url=log_url,
421
+ )
422
+ if isinstance(exc, httpx2.HTTPError | OSError):
423
+ method, log_url = _request_line(exc)
424
+ return transport_error(exc, CallSite(source, method, f"{log_url} (token mint)"))
425
+ if isinstance(exc, SHAPE_ERRORS + _MISREAD_RESPONSE_ERRORS):
426
+ detail = shorten(redact_secrets(str(exc))) or type(exc).__name__
427
+ return ResponseParseError(f"unreadable token response: {detail}", source=source)
428
+ return exc
429
+
430
+
431
+ def _request_line(exc: httpx2.HTTPError | OSError) -> tuple[str, str]:
432
+ """Return the method and loggable URL of the request *exc* names, or placeholders when it names none."""
433
+ try:
434
+ request = exc.request if isinstance(exc, httpx2.HTTPError) else None
435
+ except RuntimeError: # httpx2 raises when the error was built without a request
436
+ request = None
437
+ if request is None:
438
+ return "token", "endpoint"
439
+ return request.method, loggable_url(str(request.url))
@@ -0,0 +1,90 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Per-attempt middleware: caller code that sees, and may change, every request each time it is sent.
5
+
6
+ :class:`MiddlewareTransport` sits inside the retry layer and above the cache (see :mod:`headgate._stack`), so a
7
+ middleware runs once per attempt: a request signer stamps a fresh timestamp and nonce on every re-send, where an
8
+ ``httpx2.Auth`` flow, which runs once per call above the retry layer, would repeat the first signature. Middleware run
9
+ in the order ``HttpOptions(middleware=...)`` lists them, the first outermost; each calls ``call_next`` to continue, or
10
+ returns a response of its own to stop there.
11
+
12
+ **Credentials a middleware adds stay on trusted origins** when it declares them. The client's credential guard
13
+ (:class:`~headgate.security.CredentialGuard`) runs as a request hook, before the transport stack, so it cannot see what
14
+ a middleware adds; :class:`MiddlewareTransport` runs it again below the last middleware, on every attempt of every hop.
15
+ A middleware that declares the headers and query parameters it stamps (``credential_headers`` and
16
+ ``credential_params``, the shape every headgate auth has) has them removed from a hop addressed to any origin other than
17
+ ``base_url``'s and ``HttpOptions.trusted_origins``. One that declares nothing is trusted to check the destination
18
+ itself.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, Protocol, override
24
+
25
+ import httpx2
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Awaitable, Callable
29
+
30
+ __all__ = ["Middleware", "MiddlewareTransport", "Next"]
31
+
32
+ type Next = Callable[[httpx2.Request], Awaitable[httpx2.Response]]
33
+ """Send a request through the rest of the stack, returning its response."""
34
+
35
+
36
+ class Middleware(Protocol):
37
+ """Handle one attempt of a request: change it, then ``await call_next(request)``, or answer it outright.
38
+
39
+ A middleware that adds a credential (a signature, a key) declares its names as ``credential_headers`` and
40
+ ``credential_params`` (frozensets, either empty), so the client withholds them from any untrusted origin, a
41
+ cross-origin redirect hop included (see the module docstring). Without the declaration they ride every hop.
42
+ """
43
+
44
+ async def __call__(self, request: httpx2.Request, call_next: Next) -> httpx2.Response:
45
+ """Return the response for *request*, usually ``await call_next(request)``."""
46
+ ...
47
+
48
+
49
+ def _link(middleware: Middleware, call_next: Next) -> Next:
50
+ """Return a step that runs *middleware* with *call_next* as the rest of the chain."""
51
+
52
+ async def step(request: httpx2.Request) -> httpx2.Response:
53
+ return await middleware(request, call_next)
54
+
55
+ return step
56
+
57
+
58
+ class MiddlewareTransport(httpx2.AsyncBaseTransport):
59
+ """Run a request through *middleware*, in order, then *guard*, then the transport below; close it on ``aclose``."""
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ transport: httpx2.AsyncBaseTransport,
65
+ middleware: tuple[Middleware, ...],
66
+ guard: Callable[[httpx2.Request], Awaitable[None]],
67
+ ) -> None:
68
+ """Chain *middleware* in front of *guard* and *transport*, once, so each attempt only walks the chain.
69
+
70
+ *guard* is the client's credential guard, run on what the last middleware passes on.
71
+ """
72
+ self._transport = transport
73
+
74
+ async def guarded(request: httpx2.Request) -> httpx2.Response:
75
+ await guard(request)
76
+ return await transport.handle_async_request(request)
77
+
78
+ chain: Next = guarded
79
+ for item in reversed(middleware):
80
+ chain = _link(item, chain)
81
+ self._chain = chain
82
+
83
+ @override
84
+ async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
85
+ return await self._chain(request)
86
+
87
+ @override
88
+ async def aclose(self) -> None:
89
+ """Close the wrapped transport."""
90
+ await self._transport.aclose()