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/_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)