headgate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. headgate/__init__.py +131 -0
  2. headgate/_attempts.py +50 -0
  3. headgate/_auth.py +504 -0
  4. headgate/_body.py +115 -0
  5. headgate/_client.py +927 -0
  6. headgate/_codec.py +24 -0
  7. headgate/_core/__init__.py +88 -0
  8. headgate/_core/_context.py +228 -0
  9. headgate/_core/_events.py +248 -0
  10. headgate/_core/_origin.py +39 -0
  11. headgate/_core/_retry_after.py +76 -0
  12. headgate/_core/_seams.py +50 -0
  13. headgate/_core/_switch.py +40 -0
  14. headgate/_core/_time.py +102 -0
  15. headgate/_curl.py +287 -0
  16. headgate/_deprecation.py +101 -0
  17. headgate/_failures.py +439 -0
  18. headgate/_middleware.py +90 -0
  19. headgate/_options.py +358 -0
  20. headgate/_redirects.py +86 -0
  21. headgate/_request_body.py +81 -0
  22. headgate/_responses.py +290 -0
  23. headgate/_stack.py +185 -0
  24. headgate/_url.py +258 -0
  25. headgate/_user_agent.py +30 -0
  26. headgate/_view.py +105 -0
  27. headgate/breaker/__init__.py +24 -0
  28. headgate/breaker/_breaker.py +302 -0
  29. headgate/breaker/_policy.py +37 -0
  30. headgate/breaker/_registry.py +168 -0
  31. headgate/breaker/_scope.py +43 -0
  32. headgate/cache/__init__.py +35 -0
  33. headgate/cache/_conditional.py +89 -0
  34. headgate/cache/_credentials.py +143 -0
  35. headgate/cache/_policy.py +131 -0
  36. headgate/cache/_transport.py +568 -0
  37. headgate/curl.py +8 -0
  38. headgate/errors.py +329 -0
  39. headgate/events.py +45 -0
  40. headgate/health.py +73 -0
  41. headgate/msgspec.py +125 -0
  42. headgate/otel.py +236 -0
  43. headgate/pagination/__init__.py +25 -0
  44. headgate/pagination/_link.py +141 -0
  45. headgate/pagination/_pager.py +197 -0
  46. headgate/pagination/_paginated.py +82 -0
  47. headgate/policy.py +43 -0
  48. headgate/py.typed +0 -0
  49. headgate/pydantic.py +131 -0
  50. headgate/ratelimit/__init__.py +23 -0
  51. headgate/ratelimit/_budget.py +275 -0
  52. headgate/ratelimit/_budgets.py +62 -0
  53. headgate/ratelimit/_feedback.py +133 -0
  54. headgate/ratelimit/_fields.py +187 -0
  55. headgate/ratelimit/_inflight.py +92 -0
  56. headgate/ratelimit/_rate.py +103 -0
  57. headgate/ratelimit/_transport.py +106 -0
  58. headgate/retry/__init__.py +40 -0
  59. headgate/retry/_budget.py +73 -0
  60. headgate/retry/_classify.py +135 -0
  61. headgate/retry/_policy.py +254 -0
  62. headgate/retry/_tracking.py +121 -0
  63. headgate/retry/_transport.py +448 -0
  64. headgate/security/__init__.py +36 -0
  65. headgate/security/_addresses.py +120 -0
  66. headgate/security/_credentials.py +124 -0
  67. headgate/security/_downgrade.py +42 -0
  68. headgate/security/_transport.py +119 -0
  69. headgate/streaming/__init__.py +12 -0
  70. headgate/streaming/_stream.py +164 -0
  71. headgate/testing/__init__.py +58 -0
  72. headgate/testing/_breakers.py +33 -0
  73. headgate/testing/_recording.py +120 -0
  74. headgate/testing/_time.py +84 -0
  75. headgate/testing/_transport.py +115 -0
  76. headgate/testing/faults.py +275 -0
  77. headgate-0.2.0.dist-info/METADATA +373 -0
  78. headgate-0.2.0.dist-info/RECORD +80 -0
  79. headgate-0.2.0.dist-info/WHEEL +4 -0
  80. headgate-0.2.0.dist-info/licenses/LICENSE +21 -0
headgate/_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())