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/_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))
|
headgate/_middleware.py
ADDED
|
@@ -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()
|