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/_client.py
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The base client: :class:`AsyncBaseClient`, a policy layer over ``httpx2.AsyncClient``.
|
|
5
|
+
|
|
6
|
+
httpx2 does the URL joining, headers, timeouts and connection pooling. This adds retries, rate limiting, a per-origin
|
|
7
|
+
circuit breaker, a deadline, a response-size cap, events, and the mapping of every failure onto the ``ClientError``
|
|
8
|
+
tree. It orchestrates; each policy lives in its own module, and the transport stack under ``httpx2.AsyncClient`` is
|
|
9
|
+
assembled in :mod:`headgate._stack`.
|
|
10
|
+
|
|
11
|
+
Every call (a verb, :meth:`~AsyncBaseClient.stream`, each page of :meth:`~AsyncBaseClient.paginate`) is packed into a
|
|
12
|
+
:class:`_Call` and runs through the same steps:
|
|
13
|
+
|
|
14
|
+
1. :meth:`~AsyncBaseClient._prepare` renders the URL, builds the ``httpx2.Request`` once, and stamps the call's
|
|
15
|
+
:class:`~headgate._core.CallContext` on it: method, sanitized URL, route template, deadline, retry override, body
|
|
16
|
+
replayability, idempotency key and redirect choice;
|
|
17
|
+
2. :meth:`~AsyncBaseClient._reported` emits ``CallStarted`` and ``CallFinished`` around the call and adds its notes to
|
|
18
|
+
the error it raises; :meth:`~AsyncBaseClient._deadline` bounds it by its deadline;
|
|
19
|
+
3. :meth:`~AsyncBaseClient._exchange` sends the request under the origin's circuit breaker, through the transport
|
|
20
|
+
stack, and :meth:`~AsyncBaseClient._sent` maps a network failure or a surviving 429/5xx onto a ``ClientError``
|
|
21
|
+
(:mod:`headgate._failures`), charging the breaker with what the exchange showed;
|
|
22
|
+
4. a verb caps the body (:func:`headgate._body.read_capped`) and reads it through the call's declared responses
|
|
23
|
+
(:meth:`~AsyncBaseClient._dispatch`); ``stream()`` hands the open response to the caller's block instead.
|
|
24
|
+
|
|
25
|
+
The six verbs are written out rather than generated so that each keeps an exact signature for type checkers, IDEs
|
|
26
|
+
and subclasses that override one; every body is a single call into :meth:`AsyncBaseClient._request`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import contextlib
|
|
33
|
+
import copy
|
|
34
|
+
import functools
|
|
35
|
+
from dataclasses import dataclass, field, replace
|
|
36
|
+
from string.templatelib import Template
|
|
37
|
+
from typing import TYPE_CHECKING, Literal, Self, TypedDict, Unpack
|
|
38
|
+
|
|
39
|
+
import httpx2
|
|
40
|
+
|
|
41
|
+
from headgate._auth import failed_on_auth_hop
|
|
42
|
+
from headgate._body import read_capped, read_head
|
|
43
|
+
from headgate._core import (
|
|
44
|
+
BREAKER_GATE,
|
|
45
|
+
SILENT,
|
|
46
|
+
BreakerChanged,
|
|
47
|
+
CallContext,
|
|
48
|
+
CallFinished,
|
|
49
|
+
CallInfo,
|
|
50
|
+
CallStarted,
|
|
51
|
+
Emitter,
|
|
52
|
+
current_timing,
|
|
53
|
+
policy_is_disabled,
|
|
54
|
+
)
|
|
55
|
+
from headgate._deprecation import announce_deprecation
|
|
56
|
+
from headgate._failures import (
|
|
57
|
+
SHAPE_ERRORS,
|
|
58
|
+
CallSite,
|
|
59
|
+
is_transient_status,
|
|
60
|
+
loggable_url,
|
|
61
|
+
parse_error,
|
|
62
|
+
refusal_error,
|
|
63
|
+
shorten,
|
|
64
|
+
status_error,
|
|
65
|
+
transient_status_error,
|
|
66
|
+
transport_error,
|
|
67
|
+
)
|
|
68
|
+
from headgate._options import (
|
|
69
|
+
KEYED_METHODS,
|
|
70
|
+
HttpOptions,
|
|
71
|
+
Timeouts,
|
|
72
|
+
TransportPolicy,
|
|
73
|
+
httpx_timeout,
|
|
74
|
+
match_route,
|
|
75
|
+
reject_upstream_httpx,
|
|
76
|
+
)
|
|
77
|
+
from headgate._request_body import check_one_body, request_content
|
|
78
|
+
from headgate._responses import Expect, Reader, RefusedError
|
|
79
|
+
from headgate._stack import Credentials, build_http_client, build_transport_stack
|
|
80
|
+
from headgate._url import render_path, route_template, sanitize_url
|
|
81
|
+
from headgate._view import KEEP, ROOT_DEFAULTS, Keep, overlay_timeout
|
|
82
|
+
from headgate.breaker import BreakerOpenError, CircuitBreaker
|
|
83
|
+
from headgate.breaker._registry import NO_BREAKER
|
|
84
|
+
from headgate.breaker._scope import scoped_registry
|
|
85
|
+
from headgate.cache._transport import host_untested
|
|
86
|
+
from headgate.errors import (
|
|
87
|
+
CircuitOpenError,
|
|
88
|
+
ClientError,
|
|
89
|
+
ClientTimeoutError,
|
|
90
|
+
DecodeError,
|
|
91
|
+
ForbiddenDestinationError,
|
|
92
|
+
RateLimitError,
|
|
93
|
+
)
|
|
94
|
+
from headgate.pagination import PageRequest, Paginated
|
|
95
|
+
from headgate.ratelimit import BudgetPausedError
|
|
96
|
+
from headgate.retry import NO_RETRY, REPLAYABLE_BODY, RetryPolicy
|
|
97
|
+
from headgate.retry._policy import NETWORK_ERRORS, RETRY_OUTCOME, RETRY_POLICY
|
|
98
|
+
from headgate.streaming import Stream
|
|
99
|
+
|
|
100
|
+
if TYPE_CHECKING:
|
|
101
|
+
from collections.abc import AsyncGenerator, Callable, Mapping
|
|
102
|
+
from types import TracebackType
|
|
103
|
+
|
|
104
|
+
from headgate._options import Route
|
|
105
|
+
from headgate._request_body import Body, UploadFiles
|
|
106
|
+
from headgate._responses import RefusalCheck
|
|
107
|
+
from headgate.breaker import Transition, Verdict
|
|
108
|
+
from headgate.breaker._registry import Breaker
|
|
109
|
+
from headgate.pagination import Pager
|
|
110
|
+
from headgate.ratelimit import Rate, RateBudget
|
|
111
|
+
|
|
112
|
+
__all__ = ["AsyncBaseClient", "Method", "RequestOptions"]
|
|
113
|
+
|
|
114
|
+
type Method = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
|
|
115
|
+
"""The HTTP methods a client sends: one verb each, and :meth:`AsyncBaseClient.stream`'s ``method``."""
|
|
116
|
+
|
|
117
|
+
#: A query value httpx2 encodes itself.
|
|
118
|
+
type _QueryValue = str | int | float | bool | None
|
|
119
|
+
|
|
120
|
+
#: How much of a refused or failed streamed response is read, for the ``ClientError``'s body snippet.
|
|
121
|
+
_STREAM_ERROR_HEAD_BYTES = 4096
|
|
122
|
+
|
|
123
|
+
#: Network errors that say nothing of the origin's health, so its breaker is not charged with them: a request httpx2
|
|
124
|
+
#: refused to write, a scheme it cannot speak, our own connection pool running out of connections, and a redirect chain
|
|
125
|
+
#: past ``max_redirects`` (the origin answered every hop; the loop is one URL's configuration, as a 4xx is).
|
|
126
|
+
_LOCAL_FAILURES = (httpx2.LocalProtocolError, httpx2.UnsupportedProtocol, httpx2.PoolTimeout, httpx2.TooManyRedirects)
|
|
127
|
+
|
|
128
|
+
#: The request options that make a body, which a page (a GET) cannot carry.
|
|
129
|
+
_BODY_OPTIONS = ("json", "data", "content", "files")
|
|
130
|
+
|
|
131
|
+
#: The option groups a client gets when it passes none: frozen, so one instance serves every client.
|
|
132
|
+
_DEFAULT_POLICY = TransportPolicy()
|
|
133
|
+
_DEFAULT_HTTP = HttpOptions()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class RequestOptions(TypedDict, total=False):
|
|
137
|
+
"""The per-call options a verb takes beyond its URL, ``expect=`` and the three explicit overrides.
|
|
138
|
+
|
|
139
|
+
* ``params``: the query, ``Mapping[str, object]`` so that a client can pass a TypedDict (a TypedDict is assignable
|
|
140
|
+
to no narrower mapping). Its values are encoded the way httpx2 encodes a mapping: a list or tuple repeats the key
|
|
141
|
+
once per item, and any value that is not a ``str``, number, bool or ``None`` is sent as its ``str()``.
|
|
142
|
+
* ``headers``: added to the client's; a clash is won by the call.
|
|
143
|
+
* ``json`` / ``data``: a JSON body (build it with a codec flavor's ``to_builtins``) or a form body.
|
|
144
|
+
* ``content``: a raw body: ``bytes``; an async iterable of ``bytes``, streamed once, so the call is never re-sent
|
|
145
|
+
and a redirect that asks for the body again is not followed; or a callable returning one, called once for each
|
|
146
|
+
send, so the call stays retryable. It combines with no other body option.
|
|
147
|
+
* ``files``: a multipart body, as httpx2 takes it (``{"name": file}``, or ``(filename, file, content_type)``
|
|
148
|
+
tuples), with ``data=`` as its form fields.
|
|
149
|
+
* ``timeout``: :class:`~headgate.Timeouts` replacing the client's, or a number replacing its read timeout.
|
|
150
|
+
* ``deadline``: seconds this call may take in all, or ``None`` for no deadline; absent, the view's, the route's or
|
|
151
|
+
the policy's applies.
|
|
152
|
+
* ``idempotency_key``: the idempotency key this call carries on every attempt, under the policy's
|
|
153
|
+
``IdempotencyKeys`` header (``Idempotency-Key`` by default).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
params: Mapping[str, object] | None
|
|
157
|
+
headers: Mapping[str, str]
|
|
158
|
+
json: object
|
|
159
|
+
data: Mapping[str, object]
|
|
160
|
+
content: Body
|
|
161
|
+
files: UploadFiles
|
|
162
|
+
timeout: Timeouts | float
|
|
163
|
+
deadline: float | None
|
|
164
|
+
idempotency_key: str
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class _Call:
|
|
169
|
+
"""One call as a verb received it: what to send, and the per-call overrides."""
|
|
170
|
+
|
|
171
|
+
method: Method
|
|
172
|
+
url: str | Template
|
|
173
|
+
options: RequestOptions
|
|
174
|
+
retry: bool | RetryPolicy = True
|
|
175
|
+
follow_redirects: bool | None = None
|
|
176
|
+
cache_ttl: float | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _Report:
|
|
180
|
+
"""The final status of a call, for its ``CallFinished`` event: set as soon as a final response head arrives."""
|
|
181
|
+
|
|
182
|
+
__slots__ = ("status_code",)
|
|
183
|
+
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
"""Start with no status."""
|
|
186
|
+
self.status_code: int | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True, slots=True)
|
|
190
|
+
class _Prepared:
|
|
191
|
+
"""A built request, its call's context, whether its redirects are followed, and what its events report."""
|
|
192
|
+
|
|
193
|
+
request: httpx2.Request
|
|
194
|
+
context: CallContext
|
|
195
|
+
follow_redirects: bool
|
|
196
|
+
report: _Report = field(default_factory=_Report)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AsyncBaseClient:
|
|
200
|
+
"""Async base for HTTP API clients. Configured through ``__init__``, which builds the transport stack once.
|
|
201
|
+
|
|
202
|
+
**Failure policy.** Every verb takes the call's declared responses as ``expect=`` and returns what the matching
|
|
203
|
+
reader read, or raises a :class:`~headgate.errors.ClientError` subtype:
|
|
204
|
+
|
|
205
|
+
* ``TransportError`` / ``ClientTimeoutError``: a network failure or timeout, or the call's deadline passing;
|
|
206
|
+
* ``CircuitOpenError``: the origin's breaker refused the call without sending it;
|
|
207
|
+
* ``RateLimitError``: a 429 that survived retries, a rate budget the server paused past the call's deadline, or a
|
|
208
|
+
quota refusal a reader found inside a 2xx;
|
|
209
|
+
* ``UpstreamStatusError``: a 5xx that survived retries, a status the call did not declare, or a refusal carrying
|
|
210
|
+
the status it arrived with (401/403 become ``AuthError``);
|
|
211
|
+
* ``ForbiddenDestinationError``: a hop the SSRF guard refused (``HttpOptions(allow_private_hosts=False)``);
|
|
212
|
+
* ``ResponseParseError``: a 2xx body the reader could not read;
|
|
213
|
+
* ``ResponseTooLargeError``: a body past ``HttpOptions.max_response_bytes``.
|
|
214
|
+
|
|
215
|
+
There is no ``None`` return except from a reader that returns it (:func:`~headgate._responses.nothing`): every
|
|
216
|
+
undeclared non-2xx raises, a 404 included. A non-2xx the provider documents as an answer is declared with
|
|
217
|
+
:meth:`Expect.on <headgate._responses.Expect.on>` and comes back as its own wire type.
|
|
218
|
+
|
|
219
|
+
**Per-call overrides.** ``retry=False`` sends once and never re-sends (a POST or PATCH is already re-sent only
|
|
220
|
+
when that is safe, see :class:`~headgate.RetryPolicy`); ``retry=RetryPolicy(...)`` replaces the client's policy for
|
|
221
|
+
the call. ``follow_redirects=False`` makes a redirect raise as its 3xx. ``cache_ttl=`` sets this request's cache
|
|
222
|
+
lifetime. ``timeout=``, ``deadline=`` and ``idempotency_key=`` ride :class:`RequestOptions`. :meth:`with_options`
|
|
223
|
+
makes a view whose calls take other defaults for headers, timeouts, deadline and retry.
|
|
224
|
+
|
|
225
|
+
**URLs.** A t-string URL (``t"series/{series_id}"``) is rendered by :func:`headgate._url.render_path`, which
|
|
226
|
+
escapes every interpolation as one path segment and raises ``ValueError`` for a value no segment can hold (as
|
|
227
|
+
:func:`~headgate._url.escape_segment` does): that is a programming error at the call site. Its template is the
|
|
228
|
+
call's route in events. A ``str`` URL is passed through verbatim, so a paginator can forward an absolute cursor URL
|
|
229
|
+
from a response body; one httpx2 cannot parse, or a redirect joined past its length limit, raises the root
|
|
230
|
+
``ClientError``, since such a URL often comes from the wire rather than the code. Every message names a URL
|
|
231
|
+
redacted and cut to a few hundred characters.
|
|
232
|
+
|
|
233
|
+
**Time.** The client takes its clock and sleep from :func:`headgate._core.current_timing` when it is built.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
base_url: str,
|
|
240
|
+
rate: Rate | RateBudget,
|
|
241
|
+
auth: httpx2.Auth | None = None,
|
|
242
|
+
policy: TransportPolicy = _DEFAULT_POLICY,
|
|
243
|
+
http: HttpOptions = _DEFAULT_HTTP,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Build the transport stack for *base_url*, spending from *rate*, signing requests with *auth*.
|
|
246
|
+
|
|
247
|
+
*rate* is a :class:`~headgate.Rate`, from which the client builds a budget of its own, or a
|
|
248
|
+
:class:`~headgate.RateBudget` it shares with every other client given the same one.
|
|
249
|
+
|
|
250
|
+
*policy* configures retries, the deadline, the breaker, per-path rates, the cache, idempotency keys and
|
|
251
|
+
per-route overrides (:class:`~headgate.TransportPolicy`); *http* the network transport, headers, timeouts,
|
|
252
|
+
redirects, the body cap, cookies, observers and middleware (:class:`~headgate.HttpOptions`).
|
|
253
|
+
|
|
254
|
+
:raises TypeError: for an upstream ``httpx`` *auth*.
|
|
255
|
+
:raises ClientConfigurationError: for a cache partitioned by credential with an *auth* that does not declare
|
|
256
|
+
its credentials (see :class:`~headgate.cache.Partition`).
|
|
257
|
+
"""
|
|
258
|
+
reject_upstream_httpx(auth, what="auth")
|
|
259
|
+
self._policy_off = policy_is_disabled()
|
|
260
|
+
self._policy = policy = _effective_policy(policy, off=self._policy_off)
|
|
261
|
+
self._source = type(self).__name__
|
|
262
|
+
self._timing = current_timing()
|
|
263
|
+
self._refusal_check = http.refusal_check
|
|
264
|
+
self._max_response_bytes = http.max_response_bytes
|
|
265
|
+
self._request_id_headers = http.request_id_headers
|
|
266
|
+
self._timeout = http.timeout
|
|
267
|
+
self._emitter = Emitter(http.observers) if http.observers else SILENT
|
|
268
|
+
self._view = ROOT_DEFAULTS
|
|
269
|
+
self._owns_pool = True
|
|
270
|
+
credentials = Credentials.of(base_url=base_url, auth=auth, http=http)
|
|
271
|
+
stack = build_transport_stack(rate, policy, http, self._timing, credentials)
|
|
272
|
+
self._http_client = build_http_client(base_url=base_url, credentials=credentials, http=http, transport=stack)
|
|
273
|
+
|
|
274
|
+
async def __aenter__(self) -> Self:
|
|
275
|
+
"""Return the client; leaving the block closes it (a view's block closes nothing, see :meth:`with_options`)."""
|
|
276
|
+
return self
|
|
277
|
+
|
|
278
|
+
async def __aexit__(
|
|
279
|
+
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Close the client."""
|
|
282
|
+
await self.aclose()
|
|
283
|
+
|
|
284
|
+
async def aclose(self) -> None:
|
|
285
|
+
"""Close the connection pool and the transport stack under it; a cache's storage is left to whoever opened it.
|
|
286
|
+
|
|
287
|
+
On a view (see :meth:`with_options`) it does nothing: the client the view was made from owns the pool.
|
|
288
|
+
"""
|
|
289
|
+
if self._owns_pool:
|
|
290
|
+
await self._http_client.aclose()
|
|
291
|
+
|
|
292
|
+
def with_options(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
headers: Mapping[str, str] | None = None,
|
|
296
|
+
timeout: Timeouts | float | None = None,
|
|
297
|
+
deadline: float | Literal[Keep.KEEP] | None = KEEP,
|
|
298
|
+
retry: bool | RetryPolicy | None = None,
|
|
299
|
+
) -> Self:
|
|
300
|
+
"""Return a view of this client whose calls take different defaults; the client itself is unchanged.
|
|
301
|
+
|
|
302
|
+
* ``headers``: sent on every call of the view, over the client's; a call's ``headers=`` still wins a clash.
|
|
303
|
+
* ``timeout``: replaces the client's :class:`~headgate.Timeouts` (a number replaces the read timeout only).
|
|
304
|
+
* ``deadline``: seconds each call may take, or ``None`` for no deadline.
|
|
305
|
+
* ``retry``: ``False`` sends once, a :class:`~headgate.RetryPolicy` replaces the client's, ``True`` restores it.
|
|
306
|
+
|
|
307
|
+
An option left out keeps the client's (or, on a view of a view, the outer view's). A call's own options win
|
|
308
|
+
over the view's, and the view's over a :class:`~headgate.Route`'s.
|
|
309
|
+
|
|
310
|
+
The view shares everything that makes calls cooperate: the connection pool and transport stack, so the rate
|
|
311
|
+
budget and the retry budget; the circuit breakers; the auth and its cached token; the observers. It is a
|
|
312
|
+
shallow copy, so a subclass's own attributes are shared too. **The client it was made from owns the pool**:
|
|
313
|
+
closing a view, or leaving its ``async with`` block, closes nothing, and a view stops working once that client
|
|
314
|
+
is closed.
|
|
315
|
+
|
|
316
|
+
:raises ValueError: for a timeout or deadline that is not positive.
|
|
317
|
+
"""
|
|
318
|
+
layered = self._view.layered(headers=headers, deadline=deadline, retry=retry)
|
|
319
|
+
view = copy.copy(self)
|
|
320
|
+
# A shallow copy shares the pool, the stack and any subclass state; only the view's own settings differ.
|
|
321
|
+
vars(view).update(
|
|
322
|
+
_view=layered,
|
|
323
|
+
_owns_pool=False,
|
|
324
|
+
_timeout=self._timeout if timeout is None else overlay_timeout(self._timeout, timeout),
|
|
325
|
+
)
|
|
326
|
+
return view
|
|
327
|
+
|
|
328
|
+
# The verbs.
|
|
329
|
+
|
|
330
|
+
async def get[T](
|
|
331
|
+
self,
|
|
332
|
+
url: str | Template,
|
|
333
|
+
*,
|
|
334
|
+
expect: Reader[T] | Expect[T],
|
|
335
|
+
retry: bool | RetryPolicy = True,
|
|
336
|
+
follow_redirects: bool | None = None,
|
|
337
|
+
cache_ttl: float | None = None,
|
|
338
|
+
**options: Unpack[RequestOptions],
|
|
339
|
+
) -> T:
|
|
340
|
+
"""Send a GET and read the reply through *expect*. See :class:`AsyncBaseClient`."""
|
|
341
|
+
call = _Call("GET", url, options, retry=retry, follow_redirects=follow_redirects, cache_ttl=cache_ttl)
|
|
342
|
+
return await self._request(call, expect)
|
|
343
|
+
|
|
344
|
+
async def delete[T](
|
|
345
|
+
self,
|
|
346
|
+
url: str | Template,
|
|
347
|
+
*,
|
|
348
|
+
expect: Reader[T] | Expect[T],
|
|
349
|
+
retry: bool | RetryPolicy = True,
|
|
350
|
+
follow_redirects: bool | None = None,
|
|
351
|
+
cache_ttl: float | None = None,
|
|
352
|
+
**options: Unpack[RequestOptions],
|
|
353
|
+
) -> T:
|
|
354
|
+
"""Send a DELETE and read the reply through *expect*. See :class:`AsyncBaseClient`."""
|
|
355
|
+
call = _Call("DELETE", url, options, retry=retry, follow_redirects=follow_redirects, cache_ttl=cache_ttl)
|
|
356
|
+
return await self._request(call, expect)
|
|
357
|
+
|
|
358
|
+
async def post[T](
|
|
359
|
+
self,
|
|
360
|
+
url: str | Template,
|
|
361
|
+
*,
|
|
362
|
+
expect: Reader[T] | Expect[T],
|
|
363
|
+
retry: bool | RetryPolicy = True,
|
|
364
|
+
follow_redirects: bool | None = None,
|
|
365
|
+
cache_ttl: float | None = None,
|
|
366
|
+
**options: Unpack[RequestOptions],
|
|
367
|
+
) -> T:
|
|
368
|
+
"""Send a POST and read the reply through *expect*. See :class:`AsyncBaseClient`."""
|
|
369
|
+
call = _Call("POST", url, options, retry=retry, follow_redirects=follow_redirects, cache_ttl=cache_ttl)
|
|
370
|
+
return await self._request(call, expect)
|
|
371
|
+
|
|
372
|
+
async def put[T](
|
|
373
|
+
self,
|
|
374
|
+
url: str | Template,
|
|
375
|
+
*,
|
|
376
|
+
expect: Reader[T] | Expect[T],
|
|
377
|
+
retry: bool | RetryPolicy = True,
|
|
378
|
+
follow_redirects: bool | None = None,
|
|
379
|
+
cache_ttl: float | None = None,
|
|
380
|
+
**options: Unpack[RequestOptions],
|
|
381
|
+
) -> T:
|
|
382
|
+
"""Send a PUT and read the reply through *expect*. See :class:`AsyncBaseClient`."""
|
|
383
|
+
call = _Call("PUT", url, options, retry=retry, follow_redirects=follow_redirects, cache_ttl=cache_ttl)
|
|
384
|
+
return await self._request(call, expect)
|
|
385
|
+
|
|
386
|
+
async def patch[T](
|
|
387
|
+
self,
|
|
388
|
+
url: str | Template,
|
|
389
|
+
*,
|
|
390
|
+
expect: Reader[T] | Expect[T],
|
|
391
|
+
retry: bool | RetryPolicy = True,
|
|
392
|
+
follow_redirects: bool | None = None,
|
|
393
|
+
cache_ttl: float | None = None,
|
|
394
|
+
**options: Unpack[RequestOptions],
|
|
395
|
+
) -> T:
|
|
396
|
+
"""Send a PATCH and read the reply through *expect*. See :class:`AsyncBaseClient`."""
|
|
397
|
+
call = _Call("PATCH", url, options, retry=retry, follow_redirects=follow_redirects, cache_ttl=cache_ttl)
|
|
398
|
+
return await self._request(call, expect)
|
|
399
|
+
|
|
400
|
+
async def head[T](
|
|
401
|
+
self,
|
|
402
|
+
url: str | Template,
|
|
403
|
+
*,
|
|
404
|
+
expect: Reader[T] | Expect[T],
|
|
405
|
+
retry: bool | RetryPolicy = True,
|
|
406
|
+
follow_redirects: bool | None = None,
|
|
407
|
+
cache_ttl: float | None = None,
|
|
408
|
+
**options: Unpack[RequestOptions],
|
|
409
|
+
) -> T:
|
|
410
|
+
"""Send a HEAD and read the reply through *expect*; a HEAD has no body, so ``expect=nothing`` is usual."""
|
|
411
|
+
call = _Call("HEAD", url, options, retry=retry, follow_redirects=follow_redirects, cache_ttl=cache_ttl)
|
|
412
|
+
return await self._request(call, expect)
|
|
413
|
+
|
|
414
|
+
# Streams and pages.
|
|
415
|
+
|
|
416
|
+
def stream(
|
|
417
|
+
self,
|
|
418
|
+
method: Method,
|
|
419
|
+
url: str | Template,
|
|
420
|
+
*,
|
|
421
|
+
retry: bool | RetryPolicy = True,
|
|
422
|
+
follow_redirects: bool | None = None,
|
|
423
|
+
last_event_id: str | None = None,
|
|
424
|
+
**options: Unpack[RequestOptions],
|
|
425
|
+
) -> contextlib.AbstractAsyncContextManager[Stream]:
|
|
426
|
+
"""Send a request and open its 2xx response as a :class:`~headgate.streaming.Stream`, read as it arrives.
|
|
427
|
+
|
|
428
|
+
``async with client.stream("GET", "export.csv") as stream:`` then ``async for chunk in
|
|
429
|
+
stream.aiter_bytes():``. The body is never buffered and never capped, and the response is closed when the block
|
|
430
|
+
exits. The call runs through the same stack and failure ladder as a verb, except that:
|
|
431
|
+
|
|
432
|
+
* a 2xx is the only success: there is no ``expect=``, and a 3xx raises unless redirects are followed;
|
|
433
|
+
* the client's ``refusal_check`` runs on the response head, before the block is entered;
|
|
434
|
+
* the deadline bounds opening the stream (every attempt, backoff and the head checks), not reading it, since
|
|
435
|
+
the caller's pace of iteration decides how long the body takes. Each read is bounded by the read timeout;
|
|
436
|
+
to bound the whole body, wrap the block in ``asyncio.timeout``;
|
|
437
|
+
* a retry covers opening only; a failure while the body arrives raises ``TransportError`` or
|
|
438
|
+
``ClientTimeoutError`` from the iteration;
|
|
439
|
+
* the origin's circuit breaker spans the whole block: a network failure mid-body that leaves the block counts
|
|
440
|
+
against the origin, and a clean exit as a success.
|
|
441
|
+
|
|
442
|
+
``last_event_id=`` sends ``Last-Event-ID`` (when not empty), to resume a server-sent event stream after the
|
|
443
|
+
last event a previous :class:`~headgate.streaming.Stream` saw (its ``last_event_id``).
|
|
444
|
+
|
|
445
|
+
The request goes out when the block is entered.
|
|
446
|
+
"""
|
|
447
|
+
if bool(last_event_id):
|
|
448
|
+
options = options.copy()
|
|
449
|
+
options["headers"] = {**options.get("headers", {}), "Last-Event-ID": last_event_id}
|
|
450
|
+
return self._streamed(_Call(method, url, options, retry=retry, follow_redirects=follow_redirects))
|
|
451
|
+
|
|
452
|
+
def paginate[P, I](
|
|
453
|
+
self,
|
|
454
|
+
url: str | Template,
|
|
455
|
+
*,
|
|
456
|
+
expect: Reader[P] | Expect[P],
|
|
457
|
+
pages: Pager[P, I],
|
|
458
|
+
retry: bool | RetryPolicy = True,
|
|
459
|
+
**options: Unpack[RequestOptions],
|
|
460
|
+
) -> Paginated[P, I]:
|
|
461
|
+
"""Return the pages *pages* follows from the GET of *url*, each read through *expect*, iterated lazily.
|
|
462
|
+
|
|
463
|
+
``async for item in client.paginate(...)`` yields the items of each page in turn; ``.pages()`` yields the
|
|
464
|
+
pages. Each page is a GET through the whole stack, fetched only when the iteration reaches it, and a next-page
|
|
465
|
+
URL taken from a response is sent as an absolute URL, under the same credential and destination rules as any
|
|
466
|
+
other. Iterating again starts again from the first page.
|
|
467
|
+
|
|
468
|
+
:raises ValueError: for a body option (``json``, ``data``, ``content``, ``files``): pages are GETs.
|
|
469
|
+
"""
|
|
470
|
+
if any(name in options for name in _BODY_OPTIONS):
|
|
471
|
+
msg = "paginate sends GETs, so it takes no request body"
|
|
472
|
+
raise ValueError(msg)
|
|
473
|
+
spec: Expect[P] = expect if isinstance(expect, Expect) else Expect(expect)
|
|
474
|
+
paired = Expect(_paired(spec.ok), {status: _paired(read) for status, read in spec.answers.items()})
|
|
475
|
+
call_options = options.copy()
|
|
476
|
+
first = PageRequest(url, call_options.pop("params", None))
|
|
477
|
+
|
|
478
|
+
async def fetch(request: PageRequest) -> tuple[P, httpx2.Response]:
|
|
479
|
+
page_options = call_options.copy()
|
|
480
|
+
page_options["params"] = request.params
|
|
481
|
+
return await self.get(request.url, expect=paired, retry=retry, **page_options)
|
|
482
|
+
|
|
483
|
+
return Paginated(fetch, pages, first, source=self._source)
|
|
484
|
+
|
|
485
|
+
# A call, end to end.
|
|
486
|
+
|
|
487
|
+
async def _request[T](self, call: _Call, expect: Reader[T] | Expect[T]) -> T:
|
|
488
|
+
"""Send *call* and read the reply through its declared responses.
|
|
489
|
+
|
|
490
|
+
Every response passes four layers, in order:
|
|
491
|
+
|
|
492
|
+
1. **transport** (:meth:`_exchange`): a transport failure, a surviving 429/5xx, or an open breaker raises;
|
|
493
|
+
2. **host**: the client's ``refusal_check`` runs on the final response;
|
|
494
|
+
3. **endpoint**: a status the call declared exactly goes to its reader; any other 2xx to the success reader;
|
|
495
|
+
4. **default**: any other non-2xx raises through :func:`~headgate._failures.status_error`.
|
|
496
|
+
|
|
497
|
+
See :meth:`_dispatch` for how the last three read the body.
|
|
498
|
+
"""
|
|
499
|
+
spec: Expect[T] = expect if isinstance(expect, Expect) else Expect(expect)
|
|
500
|
+
prepared = self._prepare(call)
|
|
501
|
+
for name, value in spec.headers.items():
|
|
502
|
+
prepared.request.headers.setdefault(name, value)
|
|
503
|
+
if spec.accept is not None:
|
|
504
|
+
_stamp_accept(prepared.request, self._view.headers_for(call.options.get("headers")), spec.accept)
|
|
505
|
+
async with self._reported(prepared):
|
|
506
|
+
async with self._deadline(prepared), self._exchange(prepared) as (streamed, _):
|
|
507
|
+
announce_deprecation(prepared.context, streamed)
|
|
508
|
+
resp = await read_capped(streamed, self._max_response_bytes, self._site(prepared))
|
|
509
|
+
return self._dispatch(resp, spec, self._site(prepared))
|
|
510
|
+
|
|
511
|
+
@contextlib.asynccontextmanager
|
|
512
|
+
async def _streamed(self, call: _Call) -> AsyncGenerator[Stream]:
|
|
513
|
+
"""Open *call*'s response as a :class:`~headgate.streaming.Stream` (see :meth:`stream`).
|
|
514
|
+
|
|
515
|
+
The exchange is entered under the deadline and left open past it, on the exit stack, so the deadline bounds the
|
|
516
|
+
send and the head checks while the breaker scope and the response last until the caller's block exits.
|
|
517
|
+
"""
|
|
518
|
+
prepared = self._prepare(call)
|
|
519
|
+
async with self._reported(prepared), contextlib.AsyncExitStack() as held:
|
|
520
|
+
async with self._deadline(prepared):
|
|
521
|
+
streamed, verdict = await held.enter_async_context(self._exchange(prepared))
|
|
522
|
+
announce_deprecation(prepared.context, streamed)
|
|
523
|
+
if (refused := _refusal(self._refusal_check, streamed)) is not None:
|
|
524
|
+
head = await read_head(streamed, _STREAM_ERROR_HEAD_BYTES)
|
|
525
|
+
raise refusal_error(refused, head, self._site(prepared)) from refused
|
|
526
|
+
if not streamed.is_success:
|
|
527
|
+
head = await read_head(streamed, _STREAM_ERROR_HEAD_BYTES)
|
|
528
|
+
raise status_error(head, self._site(prepared))
|
|
529
|
+
yield Stream(streamed, self._site(prepared), functools.partial(self._network_failure, prepared, verdict))
|
|
530
|
+
|
|
531
|
+
# Preparing the request.
|
|
532
|
+
|
|
533
|
+
def _prepare(self, call: _Call) -> _Prepared:
|
|
534
|
+
"""Build *call*'s request and stamp its context on it.
|
|
535
|
+
|
|
536
|
+
Raises ``ValueError`` for a t-string value no path segment can hold, for a call that would follow redirects
|
|
537
|
+
on a client with a ``refusal_check``, for a non-positive ``deadline=`` and for body options that do not
|
|
538
|
+
combine; and ``ClientError`` for a URL httpx2 cannot parse.
|
|
539
|
+
"""
|
|
540
|
+
if isinstance(call.url, Template):
|
|
541
|
+
url, route = render_path(call.url), route_template(call.url)
|
|
542
|
+
else:
|
|
543
|
+
url, route = call.url, None
|
|
544
|
+
log_url = loggable_url(url)
|
|
545
|
+
follow = self._http_client.follow_redirects if call.follow_redirects is None else call.follow_redirects
|
|
546
|
+
# The per-call half of the constructor's check: the client itself never follows with a refusal_check.
|
|
547
|
+
if self._refusal_check is not None and follow:
|
|
548
|
+
msg = f"{self._source} has a refusal_check=, so no call may follow redirects"
|
|
549
|
+
raise ValueError(msg)
|
|
550
|
+
options = call.options
|
|
551
|
+
params = options.get("params")
|
|
552
|
+
files = options.get("files")
|
|
553
|
+
check_one_body(content=options.get("content"), json=options.get("json"), data=options.get("data"), files=files)
|
|
554
|
+
content, replayable = request_content(options.get("content"))
|
|
555
|
+
try:
|
|
556
|
+
request = self._http_client.build_request(
|
|
557
|
+
call.method,
|
|
558
|
+
url,
|
|
559
|
+
params=None if params is None else _query_pairs(params),
|
|
560
|
+
headers=self._view.headers_for(options.get("headers")),
|
|
561
|
+
json=options.get("json"),
|
|
562
|
+
data=options.get("data"),
|
|
563
|
+
content=content,
|
|
564
|
+
files=files,
|
|
565
|
+
timeout=httpx_timeout(self._timeout, options.get("timeout")),
|
|
566
|
+
)
|
|
567
|
+
except httpx2.InvalidURL as exc:
|
|
568
|
+
raise _invalid_url(exc, CallSite(self._source, call.method, log_url)) from exc
|
|
569
|
+
# The absolute URL is for observers only (an OpenTelemetry span's url.full and server.address).
|
|
570
|
+
full_url = sanitize_url(str(request.url)) if self._emitter.active else None
|
|
571
|
+
info = CallInfo(method=call.method, url=log_url, route=route, full_url=full_url)
|
|
572
|
+
route_policy = match_route(self._policy.routes, request.url.path)
|
|
573
|
+
context = CallContext(
|
|
574
|
+
info, clock=self._timing.clock, deadline_after=self._deadline_of(call, route_policy), emitter=self._emitter
|
|
575
|
+
)
|
|
576
|
+
context.follow_redirects = follow
|
|
577
|
+
if (retry := self._retry_of(call, route_policy)) is not None:
|
|
578
|
+
context.put(RETRY_POLICY, retry)
|
|
579
|
+
if not replayable:
|
|
580
|
+
context.put(REPLAYABLE_BODY, replayable)
|
|
581
|
+
self._stamp_idempotency_key(request, context, options.get("idempotency_key"))
|
|
582
|
+
# The cache transport only setdefaults the client's own TTL, so a per-call TTL stamped here wins.
|
|
583
|
+
if call.cache_ttl is not None:
|
|
584
|
+
request.extensions["hishel_ttl"] = call.cache_ttl
|
|
585
|
+
context.attach(request)
|
|
586
|
+
return _Prepared(request, context, follow)
|
|
587
|
+
|
|
588
|
+
def _deadline_of(self, call: _Call, route: Route | None) -> float | None:
|
|
589
|
+
"""Return the seconds *call* may take: its own ``deadline=``, else the view's, its route's, the policy's."""
|
|
590
|
+
if "deadline" in call.options:
|
|
591
|
+
deadline = call.options["deadline"]
|
|
592
|
+
if deadline is not None and not deadline > 0:
|
|
593
|
+
msg = f"deadline must be > 0, or None (got {deadline})"
|
|
594
|
+
raise ValueError(msg)
|
|
595
|
+
return deadline
|
|
596
|
+
if self._view.deadline is not KEEP:
|
|
597
|
+
return self._view.deadline
|
|
598
|
+
if route is not None and route.deadline is not None:
|
|
599
|
+
return route.deadline
|
|
600
|
+
return self._policy.deadline
|
|
601
|
+
|
|
602
|
+
def _retry_of(self, call: _Call, route: Route | None) -> RetryPolicy | None:
|
|
603
|
+
"""Return *call*'s own retry policy when it overrides the client's: per call, view or route; else ``None``."""
|
|
604
|
+
if self._policy_off or call.retry is False:
|
|
605
|
+
return NO_RETRY
|
|
606
|
+
if isinstance(call.retry, RetryPolicy):
|
|
607
|
+
return call.retry
|
|
608
|
+
if self._view.retry is not None:
|
|
609
|
+
return self._view.retry
|
|
610
|
+
return None if route is None else route.retry
|
|
611
|
+
|
|
612
|
+
def _stamp_idempotency_key(self, request: httpx2.Request, context: CallContext, key: str | None) -> None:
|
|
613
|
+
"""Put the call's idempotency key on *request* once, before the retry layer, so every attempt carries it.
|
|
614
|
+
|
|
615
|
+
The key is the call's ``idempotency_key=``, else a fresh one from the policy's ``IdempotencyKeys`` for a POST
|
|
616
|
+
or PATCH. A key the caller already set in ``headers=`` is kept.
|
|
617
|
+
"""
|
|
618
|
+
keys = self._policy.idempotency
|
|
619
|
+
header = "Idempotency-Key" if keys is None else keys.header
|
|
620
|
+
if key is None and keys is not None and request.method in KEYED_METHODS:
|
|
621
|
+
key = keys.factory()
|
|
622
|
+
if key is not None:
|
|
623
|
+
request.headers.setdefault(header, key)
|
|
624
|
+
context.idempotency_key = request.headers.get(header)
|
|
625
|
+
|
|
626
|
+
# Around the exchange: events and the deadline.
|
|
627
|
+
|
|
628
|
+
@contextlib.asynccontextmanager
|
|
629
|
+
async def _reported(self, prepared: _Prepared) -> AsyncGenerator[None]:
|
|
630
|
+
"""Emit ``CallStarted`` and, on the way out, ``CallFinished``; add the call's notes to a ``ClientError``."""
|
|
631
|
+
context = prepared.context
|
|
632
|
+
if context.observed:
|
|
633
|
+
context.emit(CallStarted(call=context.info))
|
|
634
|
+
error: str | None = None
|
|
635
|
+
try:
|
|
636
|
+
yield
|
|
637
|
+
except BaseException as exc:
|
|
638
|
+
error = type(exc).__name__
|
|
639
|
+
if isinstance(exc, ClientError):
|
|
640
|
+
for note in context.notes:
|
|
641
|
+
exc.add_note(note)
|
|
642
|
+
raise
|
|
643
|
+
finally:
|
|
644
|
+
if context.observed:
|
|
645
|
+
context.emit(
|
|
646
|
+
CallFinished(
|
|
647
|
+
call=context.info,
|
|
648
|
+
status_code=prepared.report.status_code,
|
|
649
|
+
error=error,
|
|
650
|
+
attempts=context.attempt,
|
|
651
|
+
elapsed=context.elapsed(),
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
@contextlib.asynccontextmanager
|
|
656
|
+
async def _deadline(self, prepared: _Prepared) -> AsyncGenerator[None]:
|
|
657
|
+
"""Bound the block by the call's deadline, raising ``ClientTimeoutError`` when it passes.
|
|
658
|
+
|
|
659
|
+
The deadline is absolute on the client's clock; the block is cancelled after the seconds that remained when it
|
|
660
|
+
began, so everything in it (rate-budget waits, every attempt and backoff, auth hops, the body read) counts.
|
|
661
|
+
"""
|
|
662
|
+
remaining = prepared.context.remaining()
|
|
663
|
+
if remaining is None:
|
|
664
|
+
yield
|
|
665
|
+
return
|
|
666
|
+
# Every TimeoutError raised inside the block is an OSError the exchange has already mapped onto a
|
|
667
|
+
# ClientError, so one that reaches here is the deadline's own.
|
|
668
|
+
try:
|
|
669
|
+
async with asyncio.timeout(remaining):
|
|
670
|
+
yield
|
|
671
|
+
except TimeoutError:
|
|
672
|
+
site = self._site(prepared)
|
|
673
|
+
msg = f"{site.method} {site.url}: deadline exceeded after {prepared.context.elapsed():.3g}s"
|
|
674
|
+
raise ClientTimeoutError(msg, **site.details()) from None
|
|
675
|
+
|
|
676
|
+
# The exchange: one send under the origin's breaker.
|
|
677
|
+
|
|
678
|
+
@contextlib.asynccontextmanager
|
|
679
|
+
async def _exchange(self, prepared: _Prepared) -> AsyncGenerator[tuple[httpx2.Response, Verdict]]:
|
|
680
|
+
"""Send the request under its origin's circuit breaker; yield the streamed response, and the breaker's verdict.
|
|
681
|
+
|
|
682
|
+
The response is closed afterwards. The verdict lets a reader of the body outside this scope charge a network
|
|
683
|
+
failure to the origin (:meth:`_network_failure`).
|
|
684
|
+
|
|
685
|
+
The response yielded is anything but a 429 or 5xx: one of those means the retry transport ran out of
|
|
686
|
+
attempts, and it is raised here, so no call can declare either as an answer. A network failure or timeout,
|
|
687
|
+
in the send or in the caller's body read inside the block, becomes ``TransportError`` /
|
|
688
|
+
``ClientTimeoutError``; an open breaker ``CircuitOpenError``; a rate budget the server paused past the
|
|
689
|
+
call's deadline ``RateLimitError``; and a hop the SSRF guard refused ``ForbiddenDestinationError``.
|
|
690
|
+
|
|
691
|
+
The breaker is driven here rather than by a transport layer because this is the only scope that spans both the
|
|
692
|
+
send and the body read. It is keyed on the origin the request is addressed to, so a followed redirect's hops
|
|
693
|
+
are charged to that origin; what it is charged with is decided in :meth:`_sent`. Every transition the call
|
|
694
|
+
causes is emitted as ``BreakerChanged``, and the call's context carries :data:`~headgate._core.BREAKER_GATE`,
|
|
695
|
+
so the retry layer stops re-sending once the breaker refuses.
|
|
696
|
+
"""
|
|
697
|
+
context = prepared.context
|
|
698
|
+
breaker = self._breaker_for(prepared.request.url)
|
|
699
|
+
try:
|
|
700
|
+
with breaker.track(self._breaker_listener(breaker, context)) as verdict:
|
|
701
|
+
context.put(BREAKER_GATE, _breaker_gate(breaker, verdict))
|
|
702
|
+
async with self._sent(prepared, verdict) as streamed:
|
|
703
|
+
yield streamed, verdict
|
|
704
|
+
except BreakerOpenError as exc:
|
|
705
|
+
details = self._site(prepared).details(retry_after=exc.retry_after)
|
|
706
|
+
raise CircuitOpenError(str(exc), **details) from None
|
|
707
|
+
except BudgetPausedError as exc:
|
|
708
|
+
# Not the retry policy's call: the budget says when sending again will be admitted.
|
|
709
|
+
details = self._site(prepared).details(retry_after=exc.retry_after)
|
|
710
|
+
details["retryable"] = True
|
|
711
|
+
raise RateLimitError(str(exc), status_code=exc.status_code, **details) from None
|
|
712
|
+
except httpx2.InvalidURL as exc:
|
|
713
|
+
# A redirect joined past httpx2's length limit. Not an HTTPError, and not an origin failure.
|
|
714
|
+
raise _invalid_url(exc, self._site(prepared)) from exc
|
|
715
|
+
except ForbiddenDestinationError as exc:
|
|
716
|
+
# The SSRF guard refused a hop before it was sent; it names the destination, the site names the call.
|
|
717
|
+
site = self._site(prepared)
|
|
718
|
+
msg = f"{site.method} {site.url}: {exc}"
|
|
719
|
+
raise ForbiddenDestinationError(msg, **site.details()) from None
|
|
720
|
+
|
|
721
|
+
@contextlib.asynccontextmanager
|
|
722
|
+
async def _sent(self, prepared: _Prepared, verdict: Verdict) -> AsyncGenerator[httpx2.Response]:
|
|
723
|
+
"""Send the request and yield its streamed response, marking *verdict* with what the exchange showed.
|
|
724
|
+
|
|
725
|
+
The final status is recorded for ``CallFinished`` as soon as the head arrives. A surviving 429/5xx is raised
|
|
726
|
+
with a bounded head of its body, read here while the response is still a stream. What the origin's breaker is
|
|
727
|
+
charged with:
|
|
728
|
+
|
|
729
|
+
* **a failure**: a 5xx this request fetched, whether or not the retry policy re-sends it (a 501 is the origin
|
|
730
|
+
failing to serve the request as surely as a 503), and a network failure or timeout;
|
|
731
|
+
* **nothing**: a 429 (the origin is up and asked us to slow down, which the rate budget handles); a network
|
|
732
|
+
error of our own side's making, or a redirect loop (:data:`_LOCAL_FAILURES`, and an invalid URL); a failure
|
|
733
|
+
on a hop an auth flow made to mint a credential (that is the credential host's failure); and an outcome
|
|
734
|
+
another request's attempt produced (a cache hit, a stored 5xx served from the cache, or a download or failure
|
|
735
|
+
the cache shared from an identical GET, :data:`~headgate.cache._transport.HOST_UNTESTED`), since only the
|
|
736
|
+
request that fetched tested the origin.
|
|
737
|
+
"""
|
|
738
|
+
request = prepared.request
|
|
739
|
+
try:
|
|
740
|
+
streamed = await self._http_client.send(request, stream=True, follow_redirects=prepared.follow_redirects)
|
|
741
|
+
prepared.report.status_code = streamed.status_code
|
|
742
|
+
try:
|
|
743
|
+
_charge_response(streamed, verdict)
|
|
744
|
+
if is_transient_status(streamed.status_code):
|
|
745
|
+
head = await read_head(streamed, _STREAM_ERROR_HEAD_BYTES)
|
|
746
|
+
raise transient_status_error(head, self._site(prepared))
|
|
747
|
+
yield streamed
|
|
748
|
+
finally:
|
|
749
|
+
# Closing a partly read compressed stream can raise DecodingError; it must not mask the error in flight.
|
|
750
|
+
with contextlib.suppress(httpx2.HTTPError):
|
|
751
|
+
await streamed.aclose()
|
|
752
|
+
except NETWORK_ERRORS as exc:
|
|
753
|
+
error = self._network_failure(prepared, verdict, exc)
|
|
754
|
+
if isinstance(exc, httpx2.LocalProtocolError):
|
|
755
|
+
# Its message, and so its traceback, quotes the header value it refused, usually the credential.
|
|
756
|
+
raise error from None
|
|
757
|
+
raise error from exc
|
|
758
|
+
|
|
759
|
+
def _network_failure(self, prepared: _Prepared, verdict: Verdict, exc: httpx2.HTTPError | OSError) -> ClientError:
|
|
760
|
+
"""Charge the network failure *exc* to the origin on *verdict*, and return the ``ClientError`` it becomes."""
|
|
761
|
+
if _network_failure_charges_origin(exc):
|
|
762
|
+
verdict.mark_failed()
|
|
763
|
+
return transport_error(exc, self._site(prepared))
|
|
764
|
+
|
|
765
|
+
def _breaker_for(self, url: httpx2.URL) -> Breaker:
|
|
766
|
+
"""Return *url*'s origin's breaker from the policy's registry, or a no-op one when the client has no breaker."""
|
|
767
|
+
policy = self._policy.breaker
|
|
768
|
+
if policy is None:
|
|
769
|
+
return NO_BREAKER
|
|
770
|
+
return policy.registry.breaker_for(
|
|
771
|
+
url, failure_threshold=policy.failure_threshold, recovery_timeout=policy.recovery_timeout
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
@staticmethod
|
|
775
|
+
def _breaker_listener(breaker: Breaker, context: CallContext) -> Callable[[Transition], None] | None:
|
|
776
|
+
"""Return what emits *breaker*'s transitions as ``BreakerChanged`` events, or ``None`` when nobody listens."""
|
|
777
|
+
if not context.observed or not isinstance(breaker, CircuitBreaker):
|
|
778
|
+
return None
|
|
779
|
+
|
|
780
|
+
def emit(transition: Transition) -> None:
|
|
781
|
+
context.emit(
|
|
782
|
+
BreakerChanged(
|
|
783
|
+
origin=breaker.name,
|
|
784
|
+
previous=transition.previous,
|
|
785
|
+
state=transition.state,
|
|
786
|
+
failures=transition.failures,
|
|
787
|
+
call=context.info,
|
|
788
|
+
)
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
return emit
|
|
792
|
+
|
|
793
|
+
# Reading the answer.
|
|
794
|
+
|
|
795
|
+
def _dispatch[T](self, resp: httpx2.Response, spec: Expect[T], site: CallSite) -> T:
|
|
796
|
+
"""Read the final *resp* through the host check, the call's declared responses and the status ladder.
|
|
797
|
+
|
|
798
|
+
A ``RefusedError`` from a reader, or raised or returned by the ``refusal_check``, becomes the ``ClientError``
|
|
799
|
+
its kind names, with the real status. A reader that finds the body is not the shape it expected (a codec's
|
|
800
|
+
``DecodeError``, or the ``ValueError``, ``LookupError`` or ``TypeError`` hand-written code over
|
|
801
|
+
``response.json()`` raises) on a 2xx raises ``ResponseParseError`` naming the status and request line; on a
|
|
802
|
+
declared non-2xx it falls through to the status ladder, because a declaration covers only the body shape it
|
|
803
|
+
names. A body whose ``Content-Type`` is not the one ``Expect(accept=...)`` asked for is such a shape error.
|
|
804
|
+
"""
|
|
805
|
+
if (refused := _refusal(self._refusal_check, resp)) is not None:
|
|
806
|
+
raise refusal_error(refused, resp, site) from refused
|
|
807
|
+
read = spec.answers.get(resp.status_code)
|
|
808
|
+
if read is None:
|
|
809
|
+
if not resp.is_success:
|
|
810
|
+
raise status_error(resp, site)
|
|
811
|
+
read = spec.ok
|
|
812
|
+
try:
|
|
813
|
+
if not spec.accepts(resp):
|
|
814
|
+
content_type = resp.headers.get("content-type") or "no Content-Type"
|
|
815
|
+
msg = f"expected {spec.accept}, got {content_type}"
|
|
816
|
+
raise DecodeError(msg)
|
|
817
|
+
return read(resp)
|
|
818
|
+
# The one place a reader's RefusedError is caught: it becomes the ClientError carrying the status the wire sent.
|
|
819
|
+
except RefusedError as refused:
|
|
820
|
+
raise refusal_error(refused, resp, site) from refused
|
|
821
|
+
except SHAPE_ERRORS as exc:
|
|
822
|
+
if resp.is_success:
|
|
823
|
+
raise parse_error(exc, resp, site) from exc
|
|
824
|
+
raise status_error(resp, site) from exc
|
|
825
|
+
|
|
826
|
+
def _site(self, prepared: _Prepared) -> CallSite:
|
|
827
|
+
"""Return where *prepared*'s call is now, for an error raised about it: the attempt count is read now."""
|
|
828
|
+
context = prepared.context
|
|
829
|
+
info = context.info
|
|
830
|
+
return CallSite(
|
|
831
|
+
self._source, info.method, info.url, context.attempt, self._request_id_headers, context.get(RETRY_OUTCOME)
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _effective_policy(policy: TransportPolicy, *, off: bool) -> TransportPolicy:
|
|
836
|
+
"""Return the policy a client built now runs: *policy*, less retries and the breaker when transport policy is *off*.
|
|
837
|
+
|
|
838
|
+
The breaker's registry is the one a :func:`~headgate.testing.isolated_breakers` block substitutes for the default,
|
|
839
|
+
if any. The rate budget checks for transport policy being off itself, when it is built.
|
|
840
|
+
"""
|
|
841
|
+
if off:
|
|
842
|
+
return replace(policy, retry=NO_RETRY, breaker=None)
|
|
843
|
+
breaker = policy.breaker
|
|
844
|
+
if breaker is None or (registry := scoped_registry(breaker.registry)) is breaker.registry:
|
|
845
|
+
return policy
|
|
846
|
+
return replace(policy, breaker=replace(breaker, registry=registry))
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _query_pairs(params: Mapping[str, object]) -> list[tuple[str, _QueryValue]]:
|
|
850
|
+
"""Flatten *params* into the ``(key, value)`` pairs httpx2 encodes, exactly as httpx2 flattens a mapping."""
|
|
851
|
+
pairs: list[tuple[str, _QueryValue]] = []
|
|
852
|
+
for key, value in params.items():
|
|
853
|
+
items = value if isinstance(value, list | tuple) else (value,)
|
|
854
|
+
pairs.extend((key, _query_value(item)) for item in items)
|
|
855
|
+
return pairs
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _query_value(value: object) -> _QueryValue:
|
|
859
|
+
"""Return *value* if httpx2 encodes it natively, else its ``str()``, which is what httpx2 would send."""
|
|
860
|
+
return value if isinstance(value, str | int | float | bool | None) else str(value)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _stamp_accept(request: httpx2.Request, explicit: Mapping[str, str] | None, accept: str) -> None:
|
|
864
|
+
"""Send *accept* as *request*'s ``Accept``, over the client's default but never over an *explicit* one.
|
|
865
|
+
|
|
866
|
+
*explicit* is what the call and its view set in ``headers=``.
|
|
867
|
+
"""
|
|
868
|
+
if explicit is None or "accept" not in {name.lower() for name in explicit}:
|
|
869
|
+
request.headers["Accept"] = accept
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _charge_response(response: httpx2.Response, verdict: Verdict) -> None:
|
|
873
|
+
"""Mark *verdict* with what *response* says about its origin (see :meth:`AsyncBaseClient._sent`).
|
|
874
|
+
|
|
875
|
+
A response another request's attempt produced (a cache hit, a stored response, a shared download) is untested; a
|
|
876
|
+
5xx this request fetched itself is a failure; a 429 is neither, since the origin is up and asked us to slow down.
|
|
877
|
+
"""
|
|
878
|
+
if host_untested(response) or response.extensions.get("hishel_from_cache"):
|
|
879
|
+
verdict.mark_untested()
|
|
880
|
+
elif response.status_code >= httpx2.codes.INTERNAL_SERVER_ERROR:
|
|
881
|
+
verdict.mark_failed()
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _network_failure_charges_origin(exc: Exception) -> bool:
|
|
885
|
+
"""Return whether a network failure is the origin's, to charge its breaker with (see :meth:`AsyncBaseClient._sent`).
|
|
886
|
+
|
|
887
|
+
Not when it is of our own side's making or a redirect loop (:data:`_LOCAL_FAILURES`), happened on a hop an auth
|
|
888
|
+
flow made to mint a credential, or was shared by the cache from another request's attempt.
|
|
889
|
+
"""
|
|
890
|
+
return not (isinstance(exc, _LOCAL_FAILURES) or failed_on_auth_hop(exc) or host_untested(exc))
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _breaker_gate(breaker: Breaker, verdict: Verdict) -> Callable[[], bool]:
|
|
894
|
+
"""Return the call's :data:`~headgate._core.BREAKER_GATE`: whether its origin's breaker refuses now.
|
|
895
|
+
|
|
896
|
+
The half-open breaker's probe is never refused by its own probe slot, so the probe keeps its retries and its whole
|
|
897
|
+
retry sequence is the one recovery test.
|
|
898
|
+
"""
|
|
899
|
+
|
|
900
|
+
def refusing() -> bool:
|
|
901
|
+
return not verdict.probe and breaker.refusing
|
|
902
|
+
|
|
903
|
+
return refusing
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _refusal(check: RefusalCheck | None, resp: httpx2.Response) -> RefusedError | None:
|
|
907
|
+
"""Run the client's *check* on *resp*, treating a ``RefusedError`` it raises like one it returns."""
|
|
908
|
+
if check is None:
|
|
909
|
+
return None
|
|
910
|
+
try:
|
|
911
|
+
return check(resp)
|
|
912
|
+
except RefusedError as refused:
|
|
913
|
+
return refused
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _paired[T](read: Reader[T]) -> Reader[tuple[T, httpx2.Response]]:
|
|
917
|
+
"""Return a reader that returns what *read* reads together with the response, for a pager to read headers from."""
|
|
918
|
+
|
|
919
|
+
def _read(resp: httpx2.Response, /) -> tuple[T, httpx2.Response]:
|
|
920
|
+
return read(resp), resp
|
|
921
|
+
|
|
922
|
+
return _read
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _invalid_url(exc: httpx2.InvalidURL, site: CallSite) -> ClientError:
|
|
926
|
+
"""Build the ``ClientError`` for a URL httpx2 rejected, naming it and httpx2's reason, both bounded."""
|
|
927
|
+
return ClientError(f"invalid URL {site.url}: {shorten(str(exc))}", **site.details())
|