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/_url.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""URL handling: path-segment escaping, and redacting credentials from a URL or a message.
|
|
5
|
+
|
|
6
|
+
**Escaping.** Interpolating a caller's value straight into an f-string path lets that value control the URL's
|
|
7
|
+
structure rather than occupy one segment of it::
|
|
8
|
+
|
|
9
|
+
f"/v2/positions/{symbol}" symbol = "../../v2/account"
|
|
10
|
+
-> httpx2 resolves the dot segments
|
|
11
|
+
-> DELETE https://api.example.test/v2/account
|
|
12
|
+
|
|
13
|
+
The same gap is an ordinary correctness bug wherever a legitimate identifier contains a slash: an unescaped ``BTC/USD``
|
|
14
|
+
builds ``/v2/positions/BTC/USD``, a different endpoint. Escaping belongs in the client because the client is the layer
|
|
15
|
+
that builds URLs; every layer above it deals in real, unescaped identifiers (``BTC/USD`` is the symbol), and pushing
|
|
16
|
+
escaping up would corrupt the value for display, comparison and the response models. :func:`render_path` renders a
|
|
17
|
+
t-string path and escapes every interpolation with :func:`escape_segment`. Query parameters need none of this: httpx2
|
|
18
|
+
encodes ``params=`` itself.
|
|
19
|
+
|
|
20
|
+
**Redaction.** :data:`SECRET_QUERY_PARAMS` names the query parameters that carry a credential, and a ``QueryParamAuth``
|
|
21
|
+
adds the names it injects with :func:`register_secret_params`. :func:`sanitize_url` redacts every URL a ``ClientError``
|
|
22
|
+
message names, :func:`redact_secrets` redacts ``name=value`` pairs and URL userinfo in free text such as an httpx2
|
|
23
|
+
exception message, and the HTTP cache strips the same parameters before it stores a request. A URL's whole userinfo is a
|
|
24
|
+
credential, a bare ``user@`` included: a token sent as the username (``https://ghp_…@host``) is how several hosts
|
|
25
|
+
take one.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from string.templatelib import Template
|
|
32
|
+
from typing import TYPE_CHECKING, Final
|
|
33
|
+
from urllib.parse import quote, unquote_plus, urlsplit, urlunsplit
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Iterable
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"SECRET_QUERY_PARAMS",
|
|
40
|
+
"escape_segment",
|
|
41
|
+
"is_secret_param",
|
|
42
|
+
"redact_secrets",
|
|
43
|
+
"register_secret_params",
|
|
44
|
+
"render_path",
|
|
45
|
+
"route_template",
|
|
46
|
+
"sanitize_url",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
#: Query parameter names that carry a credential, compared after :func:`_fold` (case-insensitive, ``_`` and ``-``
|
|
50
|
+
#: ignored).
|
|
51
|
+
SECRET_QUERY_PARAMS: Final = frozenset(
|
|
52
|
+
{
|
|
53
|
+
# API keys and generic secrets
|
|
54
|
+
"apikey",
|
|
55
|
+
"key",
|
|
56
|
+
"accesskey",
|
|
57
|
+
"privatekey",
|
|
58
|
+
"secret",
|
|
59
|
+
"clientsecret",
|
|
60
|
+
"auth",
|
|
61
|
+
"password",
|
|
62
|
+
"passwd",
|
|
63
|
+
"pwd",
|
|
64
|
+
# tokens (OAuth 2.0, OpenID Connect, sessions)
|
|
65
|
+
"token",
|
|
66
|
+
"accesstoken",
|
|
67
|
+
"authtoken",
|
|
68
|
+
"refreshtoken",
|
|
69
|
+
"idtoken",
|
|
70
|
+
"clientassertion",
|
|
71
|
+
"jwt",
|
|
72
|
+
"session",
|
|
73
|
+
"sessionid",
|
|
74
|
+
"crumb",
|
|
75
|
+
# signatures, including AWS SigV4 and Google Cloud Storage signed URLs
|
|
76
|
+
"sig",
|
|
77
|
+
"signature",
|
|
78
|
+
"xamzsignature",
|
|
79
|
+
"xamzcredential",
|
|
80
|
+
"xamzsecuritytoken",
|
|
81
|
+
"xgoogsignature",
|
|
82
|
+
"xgoogcredential",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
#: Names added by :func:`register_secret_params`, folded. Process-global and add-only: over-redacting is safe.
|
|
87
|
+
_registered_secret_params: set[str] = set()
|
|
88
|
+
|
|
89
|
+
#: The placeholder that replaces a credential.
|
|
90
|
+
_REDACTED: Final = "***"
|
|
91
|
+
|
|
92
|
+
#: A ``name=value`` pair in free text. The value stops at ``&``, whitespace or a quote, which is where an URL embedded
|
|
93
|
+
#: in an exception message ends.
|
|
94
|
+
_PAIR: Final = re.compile(r"([A-Za-z][\w-]*)=([^&\s'\"]+)")
|
|
95
|
+
|
|
96
|
+
#: The userinfo of a URL in free text: everything between ``//`` and an ``@`` before the first ``/``, ``?`` or ``#``.
|
|
97
|
+
_USERINFO: Final = re.compile(r"//[^/?#\s@'\"]+@")
|
|
98
|
+
|
|
99
|
+
#: A value that escapes to nothing but dots is a structural dot segment, not data. See :func:`escape_segment`.
|
|
100
|
+
_DOT_SEGMENTS: Final = {".": "%2E", "..": "%2E%2E"}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fold(name: str) -> str:
|
|
104
|
+
"""Normalize a parameter name for comparison: lower case, with ``_`` and ``-`` removed."""
|
|
105
|
+
return name.lower().replace("_", "").replace("-", "")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def register_secret_params(names: Iterable[str]) -> None:
|
|
109
|
+
"""Treat *names* as credential parameters from now on, in every client in the process."""
|
|
110
|
+
_registered_secret_params.update(_fold(n) for n in names)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_secret_param(name: str) -> bool:
|
|
114
|
+
"""Return whether the query parameter *name* carries a credential."""
|
|
115
|
+
folded = _fold(name)
|
|
116
|
+
return folded in SECRET_QUERY_PARAMS or folded in _registered_secret_params
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def redact_secrets(text: str) -> str:
|
|
120
|
+
"""Return *text* with every URL userinfo, and the value of every credential ``name=value`` pair, replaced.
|
|
121
|
+
|
|
122
|
+
A userinfo (``//user:pw@`` or ``//token@``) becomes ``//***@``; a pair whose name is a credential keeps its name.
|
|
123
|
+
"""
|
|
124
|
+
text = _USERINFO.sub(f"//{_REDACTED}@", text)
|
|
125
|
+
return _PAIR.sub(lambda m: f"{m[1]}={_REDACTED}" if is_secret_param(m[1]) else m[0], text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def escape_segment(value: object) -> str:
|
|
129
|
+
"""Escape *value* as exactly one URL path segment.
|
|
130
|
+
|
|
131
|
+
*value* is stringified first, since call sites pass ``UUID`` and ``int`` ids as well as ``str``. ``safe=""`` is the
|
|
132
|
+
point: :func:`urllib.parse.quote` keeps ``/`` by default. Everything structural (``/``, ``?``, ``#``, whitespace)
|
|
133
|
+
is percent-encoded, while characters legal inside a segment survive, notably the ``.`` in ``BRK.B``.
|
|
134
|
+
|
|
135
|
+
Keeping ``.`` legal means a value made only of dots would reach the URL as a live dot segment, which httpx2
|
|
136
|
+
resolves: ``DELETE /v2/positions/.`` collapses to ``DELETE /v2/positions``, which closes **every** position
|
|
137
|
+
instead of one. So ``.`` and ``..`` map to their percent-encoded forms, which httpx2 does not resolve. The mapping
|
|
138
|
+
applies only when the whole escaped value is dots, so ``BRK.B`` and a literal ``..`` between two holes (a
|
|
139
|
+
``{start}..{end}`` range) are untouched.
|
|
140
|
+
|
|
141
|
+
:raises ValueError: if *value* is empty. ``t"/v2/positions/{symbol}"`` with ``""`` would render
|
|
142
|
+
``/v2/positions/``, one trailing slash from the collection endpoint, and a router that strips the slash with a
|
|
143
|
+
301 or 308 re-sends the request there. No path segment is legitimately empty.
|
|
144
|
+
"""
|
|
145
|
+
escaped = quote(str(value), safe="")
|
|
146
|
+
if not escaped:
|
|
147
|
+
msg = "empty URL path segment"
|
|
148
|
+
raise ValueError(msg)
|
|
149
|
+
return _DOT_SEGMENTS.get(escaped, escaped)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def render_path(template: Template) -> str:
|
|
153
|
+
"""Render a t-string URL path, escaping every interpolation with :func:`escape_segment` by default.
|
|
154
|
+
|
|
155
|
+
An f-string is one flat ``str`` by the time anything sees it, but a :class:`~string.templatelib.Template` keeps
|
|
156
|
+
its literals and its holes apart, so ``t"/v2/positions/{symbol}"`` yields ``BTC%2FUSD`` with nothing for the
|
|
157
|
+
author to remember.
|
|
158
|
+
|
|
159
|
+
Only two things splice verbatim:
|
|
160
|
+
|
|
161
|
+
* A nested ``Template``, trusted for what it is rather than what it says, so shared prefixes stay unannotated:
|
|
162
|
+
``render_path(t"{_PREFIX}/accounts")`` with ``_PREFIX = t"/api/v3"`` gives ``"/api/v3/accounts"``. A nested
|
|
163
|
+
template's own holes are still escaped, so trust attaches to structure, never to the values inside it. A
|
|
164
|
+
``:raw`` spec on a ``Template`` is ignored, so it cannot launder those values.
|
|
165
|
+
* A value formatted with the reserved spec ``raw`` (``t"/x/{fragment:raw}"``), the one escape hatch for a pre-built
|
|
166
|
+
fragment. It is a spec rather than a second function so that every unescaped interpolation is written ``:raw}``
|
|
167
|
+
at its point of use. A spec can itself be interpolated (``t"/x/{v:{spec}}"`` with ``spec = "raw"``), which
|
|
168
|
+
renders unescaped without matching a search for ``:raw}``.
|
|
169
|
+
|
|
170
|
+
Any other ``format_spec`` is applied first and the result escaped, so ``t"/shvol{d:%Y%m%d}.txt"`` reads as its
|
|
171
|
+
f-string counterpart did. Recursion is bounded: ``Template`` is immutable, so a cycle cannot be built.
|
|
172
|
+
|
|
173
|
+
:raises TypeError: if *template* is not a ``Template``. A ``str`` would otherwise iterate as literal characters and
|
|
174
|
+
come back unchanged: an f-string's already-interpolated, unescaped text passing silently through.
|
|
175
|
+
:raises ValueError: if an interpolation carries a conversion (``!r``, ``!s``, ``!a``), which a URL segment almost
|
|
176
|
+
never wants and which dropping silently would change what the equivalent f-string did.
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(template, Template):
|
|
179
|
+
msg = f"render_path() takes a t-string Template, got {type(template).__name__}"
|
|
180
|
+
raise TypeError(msg)
|
|
181
|
+
|
|
182
|
+
out: list[str] = []
|
|
183
|
+
for part in template:
|
|
184
|
+
if isinstance(part, str):
|
|
185
|
+
out.append(part)
|
|
186
|
+
continue
|
|
187
|
+
if part.conversion is not None:
|
|
188
|
+
msg = f"conversion !{part.conversion} is not supported in a URL path: {part.expression!r}"
|
|
189
|
+
raise ValueError(msg)
|
|
190
|
+
if isinstance(part.value, Template):
|
|
191
|
+
out.append(render_path(part.value))
|
|
192
|
+
elif part.format_spec == "raw":
|
|
193
|
+
out.append(str(part.value))
|
|
194
|
+
else:
|
|
195
|
+
value = format(part.value, part.format_spec) if part.format_spec else str(part.value)
|
|
196
|
+
out.append(escape_segment(value))
|
|
197
|
+
return "".join(out)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def route_template(template: Template) -> str:
|
|
201
|
+
"""Return the route a t-string URL path names: its literal text with each hole written as ``{expression}``.
|
|
202
|
+
|
|
203
|
+
``t"series/{series_id}/observations"`` gives ``"series/{series_id}/observations"`` whatever the id, so events and
|
|
204
|
+
traces can group calls by endpoint without the values in them. A nested ``Template`` contributes its own route; a
|
|
205
|
+
format spec, ``:raw`` included, is not shown.
|
|
206
|
+
"""
|
|
207
|
+
out: list[str] = []
|
|
208
|
+
for part in template:
|
|
209
|
+
if isinstance(part, str):
|
|
210
|
+
out.append(part)
|
|
211
|
+
elif isinstance(part.value, Template):
|
|
212
|
+
out.append(route_template(part.value))
|
|
213
|
+
else:
|
|
214
|
+
out.append(f"{{{part.expression}}}")
|
|
215
|
+
return "".join(out)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _redact_query(query: str) -> str:
|
|
219
|
+
"""Return *query* with the value of every credential parameter replaced, and every other byte kept as it was.
|
|
220
|
+
|
|
221
|
+
Names are compared decoded, so ``api%5Fkey`` is caught too. The placeholder is written percent-encoded
|
|
222
|
+
(``%2A%2A%2A``), as a query value is.
|
|
223
|
+
"""
|
|
224
|
+
placeholder = quote(_REDACTED)
|
|
225
|
+
pairs: list[str] = []
|
|
226
|
+
for pair in query.split("&"):
|
|
227
|
+
name = pair.partition("=")[0]
|
|
228
|
+
pairs.append(f"{name}={placeholder}" if is_secret_param(unquote_plus(name)) else pair)
|
|
229
|
+
return "&".join(pairs)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _redact_userinfo(netloc: str) -> str:
|
|
233
|
+
"""Return *netloc* with its whole userinfo, when it has one, replaced by ``***``."""
|
|
234
|
+
userinfo, _, hostport = netloc.rpartition("@")
|
|
235
|
+
return f"{_REDACTED}@{hostport}" if userinfo else netloc
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def sanitize_url(url: str) -> str:
|
|
239
|
+
"""Return *url* with its credentials replaced by ``***``; it never raises.
|
|
240
|
+
|
|
241
|
+
That covers secret query parameters, the whole userinfo, and secret ``name=value`` pairs in the fragment (an OAuth
|
|
242
|
+
implicit-flow ``#access_token=``). Everything else in the URL is kept byte for byte, so the URL a message names is
|
|
243
|
+
the one that was sent. A URL too malformed to split (an unclosed IPv6 bracket) is redacted as free text with
|
|
244
|
+
:func:`redact_secrets` instead, since its message still has to be safe to show.
|
|
245
|
+
"""
|
|
246
|
+
if not any(marker in url for marker in ("?", "#", "@")):
|
|
247
|
+
return url
|
|
248
|
+
try:
|
|
249
|
+
parts = urlsplit(url)
|
|
250
|
+
except ValueError:
|
|
251
|
+
return redact_secrets(url)
|
|
252
|
+
return urlunsplit(
|
|
253
|
+
parts._replace(
|
|
254
|
+
netloc=_redact_userinfo(parts.netloc),
|
|
255
|
+
query=_redact_query(parts.query),
|
|
256
|
+
fragment=redact_secrets(parts.fragment),
|
|
257
|
+
)
|
|
258
|
+
)
|
headgate/_user_agent.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The ``User-Agent`` every client sends: the caller's product token, then headgate's, httpx2's and Python's."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import platform
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
|
|
13
|
+
__all__ = ["DEFAULT_USER_AGENT", "headgate_version", "user_agent"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def headgate_version() -> str:
|
|
17
|
+
"""Return the installed headgate version, or ``"unknown"`` for a copy not installed as a distribution."""
|
|
18
|
+
try:
|
|
19
|
+
return metadata.version("headgate")
|
|
20
|
+
except metadata.PackageNotFoundError:
|
|
21
|
+
return "unknown"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
#: What a client without ``HttpOptions(user_agent=...)`` sends.
|
|
25
|
+
DEFAULT_USER_AGENT = f"headgate/{headgate_version()} httpx2/{httpx2.__version__} python/{platform.python_version()}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def user_agent(product: str | None) -> str:
|
|
29
|
+
"""Return the ``User-Agent`` for a client whose own product token is *product* (``None``: headgate's alone)."""
|
|
30
|
+
return DEFAULT_USER_AGENT if product is None else f"{product} {DEFAULT_USER_AGENT}"
|
headgate/_view.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The defaults a view made by :meth:`~headgate.AsyncBaseClient.with_options` lays over its client's calls.
|
|
5
|
+
|
|
6
|
+
A view is the same client with different defaults for its calls: extra headers, a retry policy and a deadline (its
|
|
7
|
+
timeouts replace the client's directly). :class:`ViewDefaults` holds them, and the client consults it where it applies
|
|
8
|
+
a call's own options, so the precedence is the call's, then the view's, then the route's, then the policy's.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import TYPE_CHECKING, Final, Literal
|
|
17
|
+
|
|
18
|
+
import httpx2
|
|
19
|
+
|
|
20
|
+
from headgate._options import Timeouts
|
|
21
|
+
from headgate.retry import NO_RETRY
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
from headgate.retry import RetryPolicy
|
|
27
|
+
|
|
28
|
+
__all__ = ["KEEP", "ROOT_DEFAULTS", "Keep", "ViewDefaults", "overlay_timeout"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Keep(Enum):
|
|
32
|
+
"""The ``with_options`` default that keeps a setting as it is, where ``None`` is itself a value."""
|
|
33
|
+
|
|
34
|
+
KEEP = "keep"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Keep the setting the client already has.
|
|
38
|
+
KEEP: Final = Keep.KEEP
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ViewDefaults:
|
|
43
|
+
"""What a view adds to each of its calls: ``headers`` under the call's own, and a ``deadline`` and ``retry``.
|
|
44
|
+
|
|
45
|
+
``headers`` is a tuple of pairs so the value stays immutable; ``deadline`` is ``KEEP`` when the view sets none.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
headers: tuple[tuple[str, str], ...] = ()
|
|
49
|
+
deadline: float | Literal[Keep.KEEP] | None = KEEP
|
|
50
|
+
retry: RetryPolicy | None = None
|
|
51
|
+
|
|
52
|
+
def headers_for(self, call: Mapping[str, str] | None) -> Mapping[str, str] | httpx2.Headers | None:
|
|
53
|
+
"""Return the headers a call sends beyond the client's: the view's, then *call*'s, which win a clash."""
|
|
54
|
+
if not self.headers:
|
|
55
|
+
return call
|
|
56
|
+
merged = httpx2.Headers(list(self.headers))
|
|
57
|
+
if call is not None:
|
|
58
|
+
merged.update(call)
|
|
59
|
+
return merged
|
|
60
|
+
|
|
61
|
+
def layered(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
headers: Mapping[str, str] | None,
|
|
65
|
+
deadline: float | Literal[Keep.KEEP] | None,
|
|
66
|
+
retry: bool | RetryPolicy | None,
|
|
67
|
+
) -> ViewDefaults:
|
|
68
|
+
"""Return these defaults with a further view's laid over them; ``None`` / ``KEEP`` keeps a setting.
|
|
69
|
+
|
|
70
|
+
:raises ValueError: for a deadline that is not positive and finite.
|
|
71
|
+
"""
|
|
72
|
+
changed = self
|
|
73
|
+
if headers is not None:
|
|
74
|
+
merged = httpx2.Headers(list(self.headers))
|
|
75
|
+
merged.update(headers)
|
|
76
|
+
changed = replace(changed, headers=tuple(merged.items()))
|
|
77
|
+
if deadline is not KEEP:
|
|
78
|
+
if deadline is not None and not (deadline > 0 and math.isfinite(deadline)):
|
|
79
|
+
msg = f"deadline must be > 0 and finite, or None (got {deadline})"
|
|
80
|
+
raise ValueError(msg)
|
|
81
|
+
changed = replace(changed, deadline=deadline)
|
|
82
|
+
if retry is not None:
|
|
83
|
+
changed = replace(changed, retry=_retry_override(retry=retry))
|
|
84
|
+
return changed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _retry_override(*, retry: bool | RetryPolicy) -> RetryPolicy | None:
|
|
88
|
+
"""Return the retry policy a view's ``retry=`` installs: ``True`` is the client's own (no override)."""
|
|
89
|
+
if retry is True:
|
|
90
|
+
return None
|
|
91
|
+
if retry is False:
|
|
92
|
+
return NO_RETRY
|
|
93
|
+
return retry
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def overlay_timeout(base: Timeouts | float, view: Timeouts | float) -> Timeouts:
|
|
97
|
+
"""Return *base* with a view's timeout applied: a ``Timeouts`` replaces all four, a number the read timeout."""
|
|
98
|
+
if isinstance(view, Timeouts):
|
|
99
|
+
return view
|
|
100
|
+
current = base if isinstance(base, Timeouts) else Timeouts(read=base)
|
|
101
|
+
return replace(current, read=view)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
#: The defaults of a client that is not a view: nothing added.
|
|
105
|
+
ROOT_DEFAULTS: Final = ViewDefaults()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The circuit breaker: :class:`BreakerPolicy` configures it, :class:`BreakerRegistry` shares it between clients.
|
|
5
|
+
|
|
6
|
+
Self-contained: it imports only ``httpx2``, the standard library and :mod:`headgate._core`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from headgate._core import BreakerState
|
|
10
|
+
from headgate.breaker._breaker import BreakerOpenError, CircuitBreaker, Transition, Verdict
|
|
11
|
+
from headgate.breaker._policy import BreakerPolicy
|
|
12
|
+
from headgate.breaker._registry import DEFAULT_MAX_HOSTS, DEFAULT_REGISTRY, BreakerRegistry
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_MAX_HOSTS",
|
|
16
|
+
"DEFAULT_REGISTRY",
|
|
17
|
+
"BreakerOpenError",
|
|
18
|
+
"BreakerPolicy",
|
|
19
|
+
"BreakerRegistry",
|
|
20
|
+
"BreakerState",
|
|
21
|
+
"CircuitBreaker",
|
|
22
|
+
"Transition",
|
|
23
|
+
"Verdict",
|
|
24
|
+
]
|