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/_codec.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Codec-neutral request-body preparation shared by every flavor's ``to_builtins``."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__all__ = ["drop_none"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def drop_none(obj: object) -> object:
|
|
12
|
+
"""Return *obj* with every ``None``-valued key removed, walking nested dicts, lists and tuples.
|
|
13
|
+
|
|
14
|
+
Every other value survives, so ``False``, ``0`` and ``""`` stay (unlike msgspec's ``omit_defaults``; like
|
|
15
|
+
pydantic's ``exclude_none``). Insertion order is preserved. Tuples are walked because ``msgspec.to_builtins`` keeps
|
|
16
|
+
a ``tuple`` field as a tuple, and the structs inside one have ``None`` fields like any other.
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(obj, dict):
|
|
19
|
+
return {k: drop_none(v) for k, v in obj.items() if v is not None}
|
|
20
|
+
if isinstance(obj, list):
|
|
21
|
+
return [drop_none(v) for v in obj]
|
|
22
|
+
if isinstance(obj, tuple):
|
|
23
|
+
return tuple(drop_none(v) for v in obj)
|
|
24
|
+
return obj
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The shared core every layer may import: time, origins, the per-call context, events and the policy switch.
|
|
5
|
+
|
|
6
|
+
It imports only the standard library and ``httpx2``, so the policy subpackages (:mod:`headgate.retry`,
|
|
7
|
+
:mod:`headgate.breaker`, :mod:`headgate.ratelimit`) can depend on it and on nothing else of headgate's. Coordination
|
|
8
|
+
between those subpackages goes through what is defined here and is wired together in :mod:`headgate._stack`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from headgate._core._context import CallContext, SendProgress, Slot
|
|
12
|
+
from headgate._core._events import (
|
|
13
|
+
OBSERVER_FAILURES,
|
|
14
|
+
SILENT,
|
|
15
|
+
AttemptFinished,
|
|
16
|
+
BreakerChanged,
|
|
17
|
+
BreakerState,
|
|
18
|
+
CacheOutcome,
|
|
19
|
+
CacheResult,
|
|
20
|
+
CallEvent,
|
|
21
|
+
CallFinished,
|
|
22
|
+
CallInfo,
|
|
23
|
+
CallStarted,
|
|
24
|
+
Deprecated,
|
|
25
|
+
Emitter,
|
|
26
|
+
Event,
|
|
27
|
+
Observer,
|
|
28
|
+
RateLimited,
|
|
29
|
+
RetryScheduled,
|
|
30
|
+
)
|
|
31
|
+
from headgate._core._origin import origin_of, origin_text, same_origin
|
|
32
|
+
from headgate._core._retry_after import RETRY_AFTER_STATUSES, parse_retry_after, retry_after_of
|
|
33
|
+
from headgate._core._seams import BREAKER_GATE, CredentialCarrier, CredentialStamper
|
|
34
|
+
from headgate._core._switch import policy_disabled, policy_is_disabled
|
|
35
|
+
from headgate._core._time import (
|
|
36
|
+
SYSTEM_TIMING,
|
|
37
|
+
Clock,
|
|
38
|
+
RandomSource,
|
|
39
|
+
Sleep,
|
|
40
|
+
Timing,
|
|
41
|
+
asyncio_sleep,
|
|
42
|
+
current_timing,
|
|
43
|
+
system_random,
|
|
44
|
+
timing_override,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"BREAKER_GATE",
|
|
49
|
+
"OBSERVER_FAILURES",
|
|
50
|
+
"RETRY_AFTER_STATUSES",
|
|
51
|
+
"SILENT",
|
|
52
|
+
"SYSTEM_TIMING",
|
|
53
|
+
"AttemptFinished",
|
|
54
|
+
"BreakerChanged",
|
|
55
|
+
"BreakerState",
|
|
56
|
+
"CacheOutcome",
|
|
57
|
+
"CacheResult",
|
|
58
|
+
"CallContext",
|
|
59
|
+
"CallEvent",
|
|
60
|
+
"CallFinished",
|
|
61
|
+
"CallInfo",
|
|
62
|
+
"CallStarted",
|
|
63
|
+
"Clock",
|
|
64
|
+
"CredentialCarrier",
|
|
65
|
+
"CredentialStamper",
|
|
66
|
+
"Deprecated",
|
|
67
|
+
"Emitter",
|
|
68
|
+
"Event",
|
|
69
|
+
"Observer",
|
|
70
|
+
"RandomSource",
|
|
71
|
+
"RateLimited",
|
|
72
|
+
"RetryScheduled",
|
|
73
|
+
"SendProgress",
|
|
74
|
+
"Sleep",
|
|
75
|
+
"Slot",
|
|
76
|
+
"Timing",
|
|
77
|
+
"asyncio_sleep",
|
|
78
|
+
"current_timing",
|
|
79
|
+
"origin_of",
|
|
80
|
+
"origin_text",
|
|
81
|
+
"parse_retry_after",
|
|
82
|
+
"policy_disabled",
|
|
83
|
+
"policy_is_disabled",
|
|
84
|
+
"retry_after_of",
|
|
85
|
+
"same_origin",
|
|
86
|
+
"system_random",
|
|
87
|
+
"timing_override",
|
|
88
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""The per-call context: what every layer of the stack knows about the logical call a request belongs to.
|
|
5
|
+
|
|
6
|
+
``AsyncBaseClient`` builds one :class:`CallContext` per call and stamps it on the request in a single extension.
|
|
7
|
+
httpx2 hands a request's extensions to every redirect hop, and the cache layer restores them below hishel, so every
|
|
8
|
+
layer from the auth flow down to the network transport finds the same object with :meth:`CallContext.find`. Layers
|
|
9
|
+
read and write it through its methods, never through extension keys of their own:
|
|
10
|
+
|
|
11
|
+
* what the call is: :attr:`~CallContext.info` (method, sanitized URL, route template, call id);
|
|
12
|
+
* when it must end: :attr:`~CallContext.deadline`, absolute on the client's clock, :meth:`~CallContext.remaining`,
|
|
13
|
+
and :meth:`~CallContext.fit_timeouts`, which cuts a request's timeouts to it;
|
|
14
|
+
* its sends: :meth:`~CallContext.begin_attempt`, :attr:`~CallContext.attempt` and the current attempt's
|
|
15
|
+
:attr:`~CallContext.progress` (whether a connection was obtained and how many request bytes were written);
|
|
16
|
+
* what it carries: :attr:`~CallContext.idempotency_key` and whether its redirects are followed;
|
|
17
|
+
* what happened: :meth:`~CallContext.add_note` lines, which the client adds to the error it raises, and
|
|
18
|
+
:meth:`~CallContext.emit` for events;
|
|
19
|
+
* per-layer data: a layer that needs a value of its own on the call declares a typed :class:`Slot` in its own module and
|
|
20
|
+
reads and writes it with :meth:`~CallContext.get` and :meth:`~CallContext.put`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import TYPE_CHECKING, Final, cast, override
|
|
28
|
+
|
|
29
|
+
from headgate._core._events import SILENT, CallInfo
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import httpx2
|
|
33
|
+
|
|
34
|
+
from headgate._core._events import Emitter, Event
|
|
35
|
+
from headgate._core._time import Clock
|
|
36
|
+
|
|
37
|
+
__all__ = ["CallContext", "SendProgress", "Slot"]
|
|
38
|
+
|
|
39
|
+
#: The one request extension headgate keeps its per-call state in.
|
|
40
|
+
_EXTENSION: Final = "headgate.call"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class SendProgress:
|
|
45
|
+
"""What one attempt established about sending: whether a connection was obtained, and request bytes written.
|
|
46
|
+
|
|
47
|
+
These are facts, recorded by whichever layer can observe them, so the retry layer can tell a request that never
|
|
48
|
+
left from one that may have been processed.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
connected: bool = False
|
|
52
|
+
bytes_sent: int = 0
|
|
53
|
+
|
|
54
|
+
def mark_connected(self) -> None:
|
|
55
|
+
"""Record that the attempt obtained a connection."""
|
|
56
|
+
self.connected = True
|
|
57
|
+
|
|
58
|
+
def add_sent(self, count: int) -> None:
|
|
59
|
+
"""Record that *count* more request bytes were written."""
|
|
60
|
+
self.bytes_sent += count
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Slot[T]:
|
|
64
|
+
"""A typed key for a value one layer keeps on a :class:`CallContext`; compared by identity."""
|
|
65
|
+
|
|
66
|
+
__slots__ = ("name",)
|
|
67
|
+
|
|
68
|
+
def __init__(self, name: str) -> None:
|
|
69
|
+
"""Name the slot, for its ``repr`` only."""
|
|
70
|
+
self.name = name
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
"""Return ``Slot('name')``."""
|
|
75
|
+
return f"Slot({self.name!r})"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CallContext:
|
|
79
|
+
"""One logical call's state, shared by every layer that handles one of its requests. See the module docstring."""
|
|
80
|
+
|
|
81
|
+
__slots__ = (
|
|
82
|
+
"_attempt",
|
|
83
|
+
"_clock",
|
|
84
|
+
"_deadline",
|
|
85
|
+
"_emitter",
|
|
86
|
+
"_notes",
|
|
87
|
+
"_progress",
|
|
88
|
+
"_slots",
|
|
89
|
+
"follow_redirects",
|
|
90
|
+
"idempotency_key",
|
|
91
|
+
"info",
|
|
92
|
+
"started_at",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
info: CallInfo,
|
|
98
|
+
*,
|
|
99
|
+
clock: Clock = time.monotonic,
|
|
100
|
+
deadline_after: float | None = None,
|
|
101
|
+
emitter: Emitter = SILENT,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Start the call *info* now on *clock*, due *deadline_after* seconds from now (``None``: no deadline)."""
|
|
104
|
+
self.info = info
|
|
105
|
+
self._clock = clock
|
|
106
|
+
self.started_at = clock()
|
|
107
|
+
self._deadline = None if deadline_after is None else self.started_at + deadline_after
|
|
108
|
+
self._emitter = emitter
|
|
109
|
+
self._attempt = 0
|
|
110
|
+
self._progress = SendProgress()
|
|
111
|
+
self._notes: list[str] = []
|
|
112
|
+
self._slots: dict[object, object] = {}
|
|
113
|
+
#: The ``Idempotency-Key`` (or configured header) value every attempt of the call carries, if any.
|
|
114
|
+
self.idempotency_key: str | None = None
|
|
115
|
+
#: Whether httpx2 follows this call's redirects, read by the hook that drops a followed redirect's body.
|
|
116
|
+
self.follow_redirects = True
|
|
117
|
+
|
|
118
|
+
# Finding the context of a request.
|
|
119
|
+
|
|
120
|
+
def attach(self, request: httpx2.Request) -> None:
|
|
121
|
+
"""Stamp this context on *request*."""
|
|
122
|
+
request.extensions[_EXTENSION] = self
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def find(request: httpx2.Request) -> CallContext | None:
|
|
126
|
+
"""Return the context stamped on *request*, or ``None`` for a request no client call built."""
|
|
127
|
+
found = request.extensions.get(_EXTENSION)
|
|
128
|
+
return found if isinstance(found, CallContext) else None
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def of(request: httpx2.Request) -> CallContext:
|
|
132
|
+
"""Return the context stamped on *request*, stamping a standalone one first when it has none.
|
|
133
|
+
|
|
134
|
+
A request a layer is handed directly (a test driving one transport, a hop an auth flow built) has no client
|
|
135
|
+
call around it; its standalone context names the request's origin and path only, and has no deadline.
|
|
136
|
+
"""
|
|
137
|
+
found = CallContext.find(request)
|
|
138
|
+
if found is not None:
|
|
139
|
+
return found
|
|
140
|
+
bare = request.url.copy_with(query=None, fragment=None, userinfo=b"")
|
|
141
|
+
context = CallContext(CallInfo(method=request.method, url=str(bare)))
|
|
142
|
+
context.attach(request)
|
|
143
|
+
return context
|
|
144
|
+
|
|
145
|
+
# Time.
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def clock(self) -> Clock:
|
|
149
|
+
"""The clock the deadline and every duration of this call are measured on."""
|
|
150
|
+
return self._clock
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def deadline(self) -> float | None:
|
|
154
|
+
"""The clock reading by which the call must end, or ``None`` when it has no deadline."""
|
|
155
|
+
return self._deadline
|
|
156
|
+
|
|
157
|
+
def remaining(self) -> float | None:
|
|
158
|
+
"""Return the seconds left before the deadline (negative once past it), or ``None`` without a deadline."""
|
|
159
|
+
return None if self._deadline is None else self._deadline - self._clock()
|
|
160
|
+
|
|
161
|
+
def elapsed(self) -> float:
|
|
162
|
+
"""Return the seconds since the call started."""
|
|
163
|
+
return self._clock() - self.started_at
|
|
164
|
+
|
|
165
|
+
def fit_timeouts(self, request: httpx2.Request, *, floor: float = 0.0) -> None:
|
|
166
|
+
"""Cut *request*'s httpx2 timeouts to the time left before the deadline, never below *floor* seconds.
|
|
167
|
+
|
|
168
|
+
Each phase keeps its own limit when that is shorter; a phase with no limit gets the time left. A call without a
|
|
169
|
+
deadline, or a request without a ``timeout`` extension, is left as it is. Every layer that waits before a send
|
|
170
|
+
calls it, so the send that follows cannot outlast the call.
|
|
171
|
+
"""
|
|
172
|
+
remaining = self.remaining()
|
|
173
|
+
timeout = request.extensions.get("timeout")
|
|
174
|
+
if remaining is None or not isinstance(timeout, dict):
|
|
175
|
+
return
|
|
176
|
+
left = max(remaining, floor)
|
|
177
|
+
request.extensions["timeout"] = {
|
|
178
|
+
phase: left if limit is None else min(limit, left) for phase, limit in timeout.items()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Attempts.
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def attempt(self) -> int:
|
|
185
|
+
"""How many sends the call has begun: 0 before the first, then 1, 2, ..."""
|
|
186
|
+
return self._attempt
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def progress(self) -> SendProgress:
|
|
190
|
+
"""The current attempt's send progress, fresh for each attempt."""
|
|
191
|
+
return self._progress
|
|
192
|
+
|
|
193
|
+
def begin_attempt(self) -> int:
|
|
194
|
+
"""Record that a send begins, reset the send progress, and return the attempt's number (from 1)."""
|
|
195
|
+
self._attempt += 1
|
|
196
|
+
self._progress = SendProgress()
|
|
197
|
+
return self._attempt
|
|
198
|
+
|
|
199
|
+
# Notes and events.
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def notes(self) -> tuple[str, ...]:
|
|
203
|
+
"""The lines layers added about the call, in order."""
|
|
204
|
+
return tuple(self._notes)
|
|
205
|
+
|
|
206
|
+
def add_note(self, note: str) -> None:
|
|
207
|
+
"""Add *note*, a line with no credential in it, for the error the call may raise."""
|
|
208
|
+
self._notes.append(note)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def observed(self) -> bool:
|
|
212
|
+
"""Whether the client has observers, so a layer can skip building an event nobody receives."""
|
|
213
|
+
return self._emitter.active
|
|
214
|
+
|
|
215
|
+
def emit(self, event: Event) -> None:
|
|
216
|
+
"""Deliver *event* to the client's observers."""
|
|
217
|
+
self._emitter.emit(event)
|
|
218
|
+
|
|
219
|
+
# Per-layer data.
|
|
220
|
+
|
|
221
|
+
def get[T](self, slot: Slot[T]) -> T | None:
|
|
222
|
+
"""Return the value put in *slot*, or ``None``."""
|
|
223
|
+
# A slot is only ever filled through put(), with a value of its own type.
|
|
224
|
+
return cast("T | None", self._slots.get(slot))
|
|
225
|
+
|
|
226
|
+
def put[T](self, slot: Slot[T], value: T) -> None:
|
|
227
|
+
"""Put *value* in *slot*."""
|
|
228
|
+
self._slots[slot] = value
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Typed events: what a client reports about its calls, instead of logging.
|
|
5
|
+
|
|
6
|
+
Every event is a frozen value carrying sanitized data only: a URL with its credentials redacted, a method, the route
|
|
7
|
+
template, statuses, exception type names and durations on the client's clock. No header, body or credential is ever
|
|
8
|
+
put in one. :mod:`headgate.events` is the public face of this module.
|
|
9
|
+
|
|
10
|
+
An :class:`Observer` installed through ``HttpOptions(observers=...)`` receives them. Observers run inline, in the order
|
|
11
|
+
they were given, so one should hand slow work to a queue. An exception an observer raises never reaches the call: the
|
|
12
|
+
:class:`Emitter` swallows it and counts it in :data:`OBSERVER_FAILURES`, which ``headgate.health.observer_failures``
|
|
13
|
+
reads.
|
|
14
|
+
|
|
15
|
+
The event set is defined here, below the policy subpackages, so that every layer can emit the events it owns through
|
|
16
|
+
the call's :class:`~headgate._core.CallContext`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import itertools
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import StrEnum
|
|
26
|
+
from typing import TYPE_CHECKING, Protocol
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"OBSERVER_FAILURES",
|
|
33
|
+
"SILENT",
|
|
34
|
+
"AttemptFinished",
|
|
35
|
+
"BreakerChanged",
|
|
36
|
+
"BreakerState",
|
|
37
|
+
"CacheOutcome",
|
|
38
|
+
"CacheResult",
|
|
39
|
+
"CallEvent",
|
|
40
|
+
"CallFinished",
|
|
41
|
+
"CallInfo",
|
|
42
|
+
"CallStarted",
|
|
43
|
+
"Deprecated",
|
|
44
|
+
"Emitter",
|
|
45
|
+
"Event",
|
|
46
|
+
"Observer",
|
|
47
|
+
"RateLimited",
|
|
48
|
+
"RetryScheduled",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
#: Process-unique call ids, so an observer can group the events of one call among many concurrent ones.
|
|
52
|
+
_CALL_IDS = itertools.count(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
56
|
+
class CallInfo:
|
|
57
|
+
"""What identifies one logical call in every event about it.
|
|
58
|
+
|
|
59
|
+
``url`` is the URL the call was given, sanitized and bounded; ``route`` is the t-string template the URL was
|
|
60
|
+
rendered from (``"series/{series_id}"``), or ``None`` for a ``str`` URL. ``full_url`` is the absolute URL of the
|
|
61
|
+
request as first built (before any redirect), sanitized: what an observer needs to name the server, as an
|
|
62
|
+
OpenTelemetry span does. It is ``None`` when the client has no observers.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
method: str
|
|
66
|
+
url: str
|
|
67
|
+
route: str | None = None
|
|
68
|
+
full_url: str | None = None
|
|
69
|
+
call_id: int = field(default_factory=lambda: next(_CALL_IDS))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BreakerState(StrEnum):
|
|
73
|
+
"""Circuit-breaker states; the value is what a breaker reports."""
|
|
74
|
+
|
|
75
|
+
CLOSED = "closed"
|
|
76
|
+
OPEN = "open"
|
|
77
|
+
HALF_OPEN = "half_open"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CacheOutcome(StrEnum):
|
|
81
|
+
"""What the HTTP cache did for one request.
|
|
82
|
+
|
|
83
|
+
* ``HIT``: a stored response answered it;
|
|
84
|
+
* ``MISS``: it went to the network (and its response may have been stored);
|
|
85
|
+
* ``SHARED``: it waited on an identical request's download and got a copy of its response, or its failure;
|
|
86
|
+
* ``DEGRADED``: the storage failed, so it went to the network uncached.
|
|
87
|
+
|
|
88
|
+
The cache is TTL-only and never revalidates an entry with the origin, so there is no revalidated outcome.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
HIT = "hit"
|
|
92
|
+
MISS = "miss"
|
|
93
|
+
SHARED = "shared"
|
|
94
|
+
DEGRADED = "degraded"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
98
|
+
class CallEvent:
|
|
99
|
+
"""Base of every event about one call: :attr:`call` names it."""
|
|
100
|
+
|
|
101
|
+
call: CallInfo
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
105
|
+
class CallStarted(CallEvent):
|
|
106
|
+
"""A call's request was built and is about to be sent."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
110
|
+
class AttemptFinished(CallEvent):
|
|
111
|
+
"""One send of the call finished: a response head arrived (``status_code``) or it raised (``error``, a type name).
|
|
112
|
+
|
|
113
|
+
``attempt`` counts from 1 across the call's sends, retries and followed redirects included; ``elapsed`` is the
|
|
114
|
+
send's duration in seconds on the client's clock.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
attempt: int
|
|
118
|
+
status_code: int | None = None
|
|
119
|
+
error: str | None = None
|
|
120
|
+
elapsed: float
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
124
|
+
class RetryScheduled(CallEvent):
|
|
125
|
+
"""The retry layer will send the call again after ``delay`` seconds, because of ``reason``."""
|
|
126
|
+
|
|
127
|
+
attempt: int
|
|
128
|
+
delay: float
|
|
129
|
+
reason: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
133
|
+
class RateLimited(CallEvent):
|
|
134
|
+
"""The call's rate budget held it back, or its response paused the budget, for ``delay`` seconds.
|
|
135
|
+
|
|
136
|
+
``reason`` says which: ``"paced"`` or ``"throttled"``, the call waited for its turn at the configured rate or at
|
|
137
|
+
the rate adaptive throttling lowered it to; ``"paused"``, it waited out a pause the server asked for; and
|
|
138
|
+
``"pushback"``, its own response asked for that pause, which every call sharing the budget then waits out.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
delay: float
|
|
142
|
+
reason: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
146
|
+
class CacheResult(CallEvent):
|
|
147
|
+
"""What the HTTP cache did for the call."""
|
|
148
|
+
|
|
149
|
+
outcome: CacheOutcome
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
153
|
+
class Deprecated(CallEvent):
|
|
154
|
+
"""The call's response announced that its endpoint is deprecated (RFC 9745) or will be sunset (RFC 8594).
|
|
155
|
+
|
|
156
|
+
* ``deprecated``: the response carried a valid ``Deprecation`` header;
|
|
157
|
+
* ``deprecated_at``: the date it names (in the past: deprecated since then; in the future: deprecated from then),
|
|
158
|
+
or ``None`` for the older ``Deprecation: true`` and for a response with only a ``Sunset`` header;
|
|
159
|
+
* ``sunset_at``: the ``Sunset`` date, after which the endpoint may stop answering, or ``None``;
|
|
160
|
+
* ``link`` / ``sunset_link``: the ``Link`` targets with ``rel="deprecation"`` / ``rel="sunset"`` (where the
|
|
161
|
+
provider documents the change), resolved against the request URL and with credentials redacted.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
deprecated: bool = False
|
|
165
|
+
deprecated_at: datetime | None = None
|
|
166
|
+
sunset_at: datetime | None = None
|
|
167
|
+
link: str | None = None
|
|
168
|
+
sunset_link: str | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
172
|
+
class CallFinished(CallEvent):
|
|
173
|
+
"""A call ended: with the final ``status_code``, or raising ``error`` (the ``ClientError`` type name).
|
|
174
|
+
|
|
175
|
+
``attempts`` is how many sends it made; ``elapsed`` its whole duration in seconds on the client's clock.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
status_code: int | None = None
|
|
179
|
+
error: str | None = None
|
|
180
|
+
attempts: int
|
|
181
|
+
elapsed: float
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
185
|
+
class BreakerChanged:
|
|
186
|
+
"""An origin's circuit breaker moved from ``previous`` to ``state``; ``call`` is the call that moved it, if any."""
|
|
187
|
+
|
|
188
|
+
origin: str
|
|
189
|
+
previous: BreakerState
|
|
190
|
+
state: BreakerState
|
|
191
|
+
failures: int
|
|
192
|
+
call: CallInfo | None = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
type Event = (
|
|
196
|
+
CallStarted
|
|
197
|
+
| AttemptFinished
|
|
198
|
+
| RetryScheduled
|
|
199
|
+
| RateLimited
|
|
200
|
+
| BreakerChanged
|
|
201
|
+
| CacheResult
|
|
202
|
+
| Deprecated
|
|
203
|
+
| CallFinished
|
|
204
|
+
)
|
|
205
|
+
"""Every event a client emits."""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Observer(Protocol):
|
|
209
|
+
"""Receives a client's events. Installed with ``HttpOptions(observers=(...))``."""
|
|
210
|
+
|
|
211
|
+
def notify(self, event: Event) -> None:
|
|
212
|
+
"""Handle *event*; any exception raised here is counted and swallowed, never raised into the call."""
|
|
213
|
+
...
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
#: How many times each observer class raised from ``notify``, since process start.
|
|
217
|
+
OBSERVER_FAILURES: Counter[str] = Counter()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Emitter:
|
|
221
|
+
"""Deliver events to a client's observers, isolating the call from any observer's failure."""
|
|
222
|
+
|
|
223
|
+
__slots__ = ("_observers",)
|
|
224
|
+
|
|
225
|
+
def __init__(self, observers: tuple[Observer, ...] = ()) -> None:
|
|
226
|
+
"""Deliver to *observers*, in order."""
|
|
227
|
+
self._observers = observers
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def active(self) -> bool:
|
|
231
|
+
"""Whether anyone is listening, so a hot path can skip building an event nobody receives."""
|
|
232
|
+
return bool(self._observers)
|
|
233
|
+
|
|
234
|
+
def emit(self, event: Event) -> None:
|
|
235
|
+
"""Hand *event* to every observer; one that raises is counted in :data:`OBSERVER_FAILURES` and skipped."""
|
|
236
|
+
for observer in self._observers:
|
|
237
|
+
delivered = False
|
|
238
|
+
# An observer is the caller's code, outside headgate's error contract: whatever it raises must not
|
|
239
|
+
# change the call's outcome, and headgate never logs, so the count is the whole record.
|
|
240
|
+
with contextlib.suppress(Exception):
|
|
241
|
+
observer.notify(event)
|
|
242
|
+
delivered = True
|
|
243
|
+
if not delivered:
|
|
244
|
+
OBSERVER_FAILURES[type(observer).__qualname__] += 1
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
#: The emitter of a client with no observers.
|
|
248
|
+
SILENT = Emitter()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Copyright (c) 2026 Khaled Elsayed
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Origins (RFC 6454 / RFC 9110 section 4.3.1): the scheme, host and effective port a URL addresses.
|
|
5
|
+
|
|
6
|
+
``httpx2.Origin`` does the normalisation (lower-case scheme and host, the default port filled in, IPv6 in canonical
|
|
7
|
+
form); these helpers name the operations every layer that keys or compares by origin needs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
import httpx2
|
|
15
|
+
|
|
16
|
+
__all__ = ["origin_of", "origin_text", "same_origin"]
|
|
17
|
+
|
|
18
|
+
#: The port each scheme implies, left out of :func:`origin_text`.
|
|
19
|
+
_DEFAULT_PORTS: Final = {"http": 80, "https": 443, "ws": 80, "wss": 443}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def origin_of(url: httpx2.URL | str) -> httpx2.Origin:
|
|
23
|
+
"""Return *url*'s origin.
|
|
24
|
+
|
|
25
|
+
:raises ValueError: for a relative URL, which has none.
|
|
26
|
+
"""
|
|
27
|
+
return httpx2.Origin(url)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def origin_text(origin: httpx2.Origin) -> str:
|
|
31
|
+
"""Return *origin* as ``scheme://host[:port]``, leaving out the scheme's default port and bracketing IPv6."""
|
|
32
|
+
host = f"[{origin.host}]" if ":" in origin.host else origin.host
|
|
33
|
+
port = "" if origin.port in {None, _DEFAULT_PORTS.get(origin.scheme)} else f":{origin.port}"
|
|
34
|
+
return f"{origin.scheme}://{host}{port}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def same_origin(a: httpx2.URL | str, b: httpx2.URL | str) -> bool:
|
|
38
|
+
"""Return whether two absolute URLs address the same origin."""
|
|
39
|
+
return origin_of(a) == origin_of(b)
|