xtr-http-client 4.0.2__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 (51) hide show
  1. xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
  2. xtr_http_client/__init__.py +47 -0
  3. xtr_http_client/bundle/__init__.py +17 -0
  4. xtr_http_client/bundle/_clients.py +217 -0
  5. xtr_http_client/bundle/client_options_config.py +79 -0
  6. xtr_http_client/bundle/http_client_bundle.py +85 -0
  7. xtr_http_client/bundle/http_client_config.py +75 -0
  8. xtr_http_client/bundle/retry_failed_config.py +77 -0
  9. xtr_http_client/bundle/scoped_client_config.py +99 -0
  10. xtr_http_client/exception/__init__.py +40 -0
  11. xtr_http_client/exception/_http_error_message.py +129 -0
  12. xtr_http_client/http_client.py +57 -0
  13. xtr_http_client/http_client_mixin.py +176 -0
  14. xtr_http_client/httpx_http_client.py +327 -0
  15. xtr_http_client/internal/__init__.py +13 -0
  16. xtr_http_client/internal/debug_type.py +14 -0
  17. xtr_http_client/internal/httpx_body.py +88 -0
  18. xtr_http_client/internal/httpx_clients.py +203 -0
  19. xtr_http_client/internal/httpx_errors.py +63 -0
  20. xtr_http_client/internal/httpx_redirect.py +63 -0
  21. xtr_http_client/internal/httpx_request.py +272 -0
  22. xtr_http_client/internal/mock_head.py +144 -0
  23. xtr_http_client/internal/mock_info.py +86 -0
  24. xtr_http_client/internal/option_merge.py +108 -0
  25. xtr_http_client/internal/response_body.py +270 -0
  26. xtr_http_client/internal/retry_count_response.py +148 -0
  27. xtr_http_client/internal/retry_delay.py +45 -0
  28. xtr_http_client/internal/retry_options.py +98 -0
  29. xtr_http_client/mock_http_client.py +253 -0
  30. xtr_http_client/no_private_network_http_client.py +342 -0
  31. xtr_http_client/py.typed +0 -0
  32. xtr_http_client/recipe/__init__.py +15 -0
  33. xtr_http_client/recipe/manifest.toml +23 -0
  34. xtr_http_client/response/__init__.py +10 -0
  35. xtr_http_client/response/http_response.py +412 -0
  36. xtr_http_client/response/httpx_response.py +173 -0
  37. xtr_http_client/response/json_mock_response.py +119 -0
  38. xtr_http_client/response/mock_response.py +375 -0
  39. xtr_http_client/retry/__init__.py +25 -0
  40. xtr_http_client/retry/generic_retry_strategy.py +215 -0
  41. xtr_http_client/retry/retry_context.py +73 -0
  42. xtr_http_client/retry/retry_strategy_interface.py +74 -0
  43. xtr_http_client/retryable_http_client.py +346 -0
  44. xtr_http_client/scoping_http_client.py +303 -0
  45. xtr_http_client/throttling_http_client.py +143 -0
  46. xtr_http_client/traceable_http_client.py +406 -0
  47. xtr_http_client-4.0.2.dist-info/METADATA +505 -0
  48. xtr_http_client-4.0.2.dist-info/RECORD +51 -0
  49. xtr_http_client-4.0.2.dist-info/WHEEL +4 -0
  50. xtr_http_client-4.0.2.dist-info/entry_points.txt +6 -0
  51. xtr_http_client-4.0.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,85 @@
1
+ """The xtr-http-client bundle: the application's HTTP client, and one more per service.
2
+
3
+ An application listing :class:`HttpClientBundle` gets an
4
+ :class:`~xtr_http_client_contracts.HttpClientInterface` — the default client — and,
5
+ for every scoped client its :class:`HttpClientConfig` names, another one qualified
6
+ by that name. Each is a transport the bundle owns, wrapped in the layers its
7
+ configuration turns on, from the inside out: the pace of an xtr-rate-limiter
8
+ limiter, the options of one service, another try at what failed, and a record of
9
+ every request.
10
+
11
+ Every client shares one transport — :class:`~xtr_http_client.HttpxHttpClient`, or
12
+ :class:`~xtr_http_client.MockHttpClient` for a client answering from a script — so
13
+ they share its connections. Nothing is opened until a request is made, and the
14
+ connections are closed with the container.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import final
20
+
21
+ from typing_extensions import override
22
+ from xtr_dependency_injection import (
23
+ Bundle,
24
+ ContainerBuilder,
25
+ ServiceConfigurator,
26
+ as_bundle,
27
+ bundle_active,
28
+ required_bundle,
29
+ )
30
+
31
+ from xtr_http_client.exception import InvalidArgumentError
32
+
33
+ from ._clients import register_client, register_transports
34
+ from .http_client_config import HttpClientConfig
35
+
36
+ __all__ = ["HttpClientBundle"]
37
+
38
+
39
+ @final
40
+ @required_bundle("xtr_rate_limiter.bundle:RateLimiterBundle", ignore_on_invalid=True)
41
+ @as_bundle("http_client", config=HttpClientConfig)
42
+ class HttpClientBundle(Bundle[HttpClientConfig]):
43
+ """Turns an :class:`HttpClientConfig` into the default client and a client per service."""
44
+
45
+ @override
46
+ def load_extension(
47
+ self,
48
+ config: HttpClientConfig,
49
+ services: ServiceConfigurator,
50
+ builder: ContainerBuilder,
51
+ ) -> None:
52
+ """Register every client, and the transports they send through.
53
+
54
+ Raises:
55
+ InvalidArgumentError: When a client is paced by a rate limiter while
56
+ the rate limiter bundle is not active.
57
+ """
58
+ pacing = bundle_active(builder, "rate_limiter")
59
+ defaults = config.default_options
60
+ _check_pace("The default HTTP client", defaults.rate_limiter, pacing)
61
+ register_client(services, None, defaults, mocked=config.mock_response_factory)
62
+
63
+ mocked = [config.mock_response_factory]
64
+ for name, scoped in config.scoped_clients.items():
65
+ client = scoped.inheriting(defaults)
66
+ mock = scoped.mock_response_factory
67
+ mocked.append(config.mock_response_factory if mock is None else mock)
68
+ _check_pace(f'The "{name}" HTTP client', client.rate_limiter, pacing)
69
+ register_client(services, name, client, mocked=mocked[-1])
70
+
71
+ register_transports(services, mocked)
72
+
73
+
74
+ def _check_pace(owner: str, rate_limiter: str | None, pacing: bool) -> None:
75
+ """Refuse a rate limiter nothing can provide.
76
+
77
+ Raises:
78
+ InvalidArgumentError: When ``rate_limiter`` is named while the rate
79
+ limiter bundle is not active.
80
+ """
81
+ if rate_limiter is not None and not pacing:
82
+ raise InvalidArgumentError(
83
+ f'{owner} is paced by the "{rate_limiter}" rate limiter, but the rate limiter '
84
+ f'bundle is not active: install "xtr-http-client[throttling]".'
85
+ )
@@ -0,0 +1,75 @@
1
+ """Configuration for :class:`~xtr_http_client.bundle.http_client_bundle.HttpClientBundle`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import final
8
+
9
+ from xtr_http_client.exception import InvalidArgumentError
10
+
11
+ from .client_options_config import ClientOptionsConfig
12
+ from .scoped_client_config import ScopedClientConfig
13
+
14
+ __all__ = ["HttpClientConfig"]
15
+
16
+
17
+ def _no_scoped_clients() -> dict[str, ScopedClientConfig]:
18
+ """The empty set of services a zero-config application starts with."""
19
+ return {}
20
+
21
+
22
+ @final
23
+ @dataclass(frozen=True, slots=True)
24
+ class HttpClientConfig:
25
+ """The application's default client, and one more per service it calls.
26
+
27
+ Buildable with no arguments: the zero-config path is one client sending
28
+ requests as they are given, over connections opened when the first
29
+ request is made.
30
+
31
+ ```python
32
+ HttpClientConfig(
33
+ default_options=ClientOptionsConfig(
34
+ timeout=10, retry_failed=RetryFailedConfig(enabled=True)
35
+ ),
36
+ scoped_clients={
37
+ "github": ScopedClientConfig(
38
+ base_uri="https://api.github.com/", auth_bearer=env("GITHUB_TOKEN")
39
+ ),
40
+ },
41
+ )
42
+ ```
43
+
44
+ Attributes:
45
+ max_host_connections: How many connections one pool may hold at once.
46
+ default_options: What every request starts from, and the layers the
47
+ default client — and every scoped one leaving them unset — is
48
+ wrapped in.
49
+ scoped_clients: A client per service, by the name it is qualified by.
50
+ mock_response_factory: Answer every request from a
51
+ :class:`~xtr_http_client.MockHttpClient` — an empty 200 until a test
52
+ scripts it — instead of the network.
53
+ tracing: Wrap every client in a
54
+ :class:`~xtr_http_client.TraceableHttpClient`, which writes down
55
+ each request made through it.
56
+ """
57
+
58
+ max_host_connections: int = 6
59
+ default_options: ClientOptionsConfig = field(default_factory=ClientOptionsConfig)
60
+ scoped_clients: Mapping[str, ScopedClientConfig] = field(default_factory=_no_scoped_clients)
61
+ mock_response_factory: bool = False
62
+ tracing: bool = False
63
+
64
+ def __post_init__(self) -> None:
65
+ """Refuse a scoped client with no name, or one that cannot take what it inherits.
66
+
67
+ Raises:
68
+ InvalidArgumentError: When the configuration cannot be used.
69
+ """
70
+ for name, scoped in self.scoped_clients.items():
71
+ if name == "":
72
+ raise InvalidArgumentError("A scoped HTTP client needs a name, got an empty one.")
73
+ # Validated as it is built: retries falling back to other base URIs
74
+ # are only usable by a client with a base URI of its own.
75
+ _ = scoped.inheriting(self.default_options)
@@ -0,0 +1,77 @@
1
+ """Whether a configured client makes a failed request again, and how."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Final, final
8
+
9
+ from xtr_http_client.exception import InvalidArgumentError
10
+
11
+ __all__ = ["RetryFailedConfig"]
12
+
13
+ _DELAY_MS: Final = 1000
14
+ _MULTIPLIER: Final = 2.0
15
+ _MAX_DELAY_MS: Final = 0
16
+ _JITTER: Final = 0.1
17
+
18
+
19
+ @final
20
+ @dataclass(frozen=True, slots=True)
21
+ class RetryFailedConfig:
22
+ """The retrying layer of a configured client: off unless ``enabled``.
23
+
24
+ What is retried, and how long is waited in between, is decided either by a
25
+ :class:`~xtr_http_client.retry.GenericRetryStrategy` built from the numbers
26
+ here, or by the strategy service ``retry_strategy`` names — one or the
27
+ other, since numbers next to a service that ignores them would be settings
28
+ that silently never apply.
29
+
30
+ Attributes:
31
+ enabled: Whether a failed request is made again.
32
+ max_retries: How many further attempts a request may have.
33
+ delay: How many milliseconds to wait before the first retry, and what
34
+ the growing delay starts from.
35
+ multiplier: What the delay is multiplied by per attempt already made.
36
+ max_delay: The longest wait, in milliseconds; ``0`` sets no ceiling.
37
+ jitter: How much of each delay is randomness, from ``0`` to ``1``.
38
+ http_codes: The status codes retried, each mapped to the methods it is
39
+ retried for — ``None`` for every method. ``None`` keeps the
40
+ strategy's own list.
41
+ base_uris: The base URIs each further attempt moves along, after the
42
+ client's own ``base_uri``.
43
+ retry_strategy: The qualifier of a
44
+ :class:`~xtr_http_client.retry.RetryStrategyInterface` service
45
+ deciding instead of the numbers above.
46
+ """
47
+
48
+ enabled: bool = False
49
+ max_retries: int = 3
50
+ delay: int = _DELAY_MS
51
+ multiplier: float = _MULTIPLIER
52
+ max_delay: int = _MAX_DELAY_MS
53
+ jitter: float = _JITTER
54
+ http_codes: Mapping[int, Sequence[str] | None] | None = None
55
+ base_uris: Sequence[str] = ()
56
+ retry_strategy: str | None = None
57
+
58
+ def __post_init__(self) -> None:
59
+ """Refuse fallback URIs nothing retries towards, and numbers a strategy service ignores.
60
+
61
+ Raises:
62
+ InvalidArgumentError: When the configuration cannot be used.
63
+ """
64
+ if self.base_uris and not self.enabled:
65
+ raise InvalidArgumentError('The "base_uris" option requires retries to be enabled.')
66
+ tuned = (self.http_codes, self.delay, self.multiplier, self.max_delay, self.jitter) != (
67
+ None,
68
+ _DELAY_MS,
69
+ _MULTIPLIER,
70
+ _MAX_DELAY_MS,
71
+ _JITTER,
72
+ )
73
+ if self.retry_strategy is not None and tuned:
74
+ raise InvalidArgumentError(
75
+ 'The "retry_strategy" option cannot be used along with the "http_codes", '
76
+ '"delay", "multiplier", "max_delay" or "jitter" options.'
77
+ )
@@ -0,0 +1,99 @@
1
+ """A client of its own for one service: where it is, and what reaching it takes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field, replace
7
+ from typing import ClassVar, final
8
+
9
+ from xtr_http_client.exception import InvalidArgumentError
10
+
11
+ from .client_options_config import ClientOptionsConfig
12
+ from .retry_failed_config import RetryFailedConfig
13
+
14
+ __all__ = ["ScopedClientConfig"]
15
+
16
+
17
+ @final
18
+ @dataclass(frozen=True, slots=True)
19
+ class ScopedClientConfig(ClientOptionsConfig):
20
+ r"""A client whose options apply to the URLs of one service.
21
+
22
+ A request whose URL ``scope`` matches from its start — or which falls under
23
+ ``base_uri``, when there is no scope — starts from these options, and a
24
+ relative URL resolves against ``base_uri``. Left as they are,
25
+ ``retry_failed`` and ``rate_limiter`` are the default options' own.
26
+
27
+ ```python
28
+ ScopedClientConfig(
29
+ base_uri="https://api.github.com/",
30
+ headers={"Accept": "application/vnd.github+json"},
31
+ auth_bearer=env("GITHUB_TOKEN"),
32
+ )
33
+ ```
34
+
35
+ Attributes:
36
+ scope: A regular expression a URL must match from its start for these
37
+ options to apply.
38
+ base_uri: What a relative URL resolves against, and — with no
39
+ ``scope`` — what a URL must start with to be in scope.
40
+ auth_basic: Credentials for basic authentication; kept out of the
41
+ ``repr``.
42
+ auth_bearer: A token to send as a bearer credential; kept out of the
43
+ ``repr``.
44
+ query: Parameters merged into the query of every URL resolved against
45
+ ``base_uri``.
46
+ mock_response_factory: ``True`` answers this client's requests from
47
+ the mock transport, ``False`` sends them for real, whatever the
48
+ bundle's own setting is; ``None`` follows it.
49
+ """
50
+
51
+ _NOT_OPTIONS: ClassVar[frozenset[str]] = frozenset(
52
+ {"retry_failed", "rate_limiter", "scope", "mock_response_factory"}
53
+ )
54
+
55
+ scope: str | None = None
56
+ base_uri: str | None = None
57
+ auth_basic: str | tuple[str] | tuple[str, str] | None = field(default=None, repr=False)
58
+ auth_bearer: str | None = field(default=None, repr=False)
59
+ query: Mapping[str, str | int | float | Sequence[str | int | float]] | None = None
60
+ mock_response_factory: bool | None = None
61
+
62
+ def __post_init__(self) -> None:
63
+ """Refuse a client that says nothing about which URLs are its own.
64
+
65
+ Raises:
66
+ InvalidArgumentError: When neither a scope nor a base URI is given,
67
+ or ``query`` or fallback base URIs come without a base URI.
68
+ """
69
+ if self.scope is None and self.base_uri is None:
70
+ raise InvalidArgumentError('Either "scope" or "base_uri" should be defined.')
71
+ if self.query and self.base_uri is None:
72
+ raise InvalidArgumentError('"query" applies to "base_uri" but no base URI is defined.')
73
+ if self.retry_failed.base_uris and self.base_uri is None:
74
+ raise InvalidArgumentError(
75
+ '"retry_failed.base_uris" lists the URIs to fall back to, so a "base_uri" '
76
+ "must be defined as well."
77
+ )
78
+
79
+ def inheriting(self, defaults: ClientOptionsConfig) -> ScopedClientConfig:
80
+ """Return this client with the retries and the limiter it left to ``defaults``.
81
+
82
+ Inherited whole, not field by field: a client that configures its own
83
+ retries says everything about them.
84
+
85
+ Raises:
86
+ InvalidArgumentError: When what it inherits cannot be used with it —
87
+ fallback base URIs, for a client with no base URI.
88
+ """
89
+ return replace(
90
+ self,
91
+ retry_failed=(
92
+ self.retry_failed
93
+ if self.retry_failed != RetryFailedConfig()
94
+ else defaults.retry_failed
95
+ ),
96
+ rate_limiter=self.rate_limiter
97
+ if self.rate_limiter is not None
98
+ else defaults.rate_limiter,
99
+ )
@@ -0,0 +1,40 @@
1
+ """Everything calling out through this package can raise.
2
+
3
+ The hierarchy is the contract's, re-exported here so one import answers "what
4
+ can this client raise" without reaching into two packages: :class:`HttpClientError`
5
+ at the root, :class:`TransportError` for "there is no answer" (with
6
+ :class:`TransportTimeoutError` for running out of time), :class:`HttpError` for
7
+ "there is one and it is not a success" (:class:`RedirectionError`,
8
+ :class:`ClientError`, :class:`ServerError`), :class:`DecodingError` for an
9
+ answer that is not what it was read as (with :class:`JsonError` for a body that
10
+ is not the JSON it was read as), and :class:`InvalidArgumentError` for a request
11
+ this client refuses to make.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from xtr_http_client_contracts import (
17
+ ClientError,
18
+ DecodingError,
19
+ HttpClientError,
20
+ HttpError,
21
+ InvalidArgumentError,
22
+ JsonError,
23
+ RedirectionError,
24
+ ServerError,
25
+ TransportError,
26
+ TransportTimeoutError,
27
+ )
28
+
29
+ __all__ = [
30
+ "ClientError",
31
+ "DecodingError",
32
+ "HttpClientError",
33
+ "HttpError",
34
+ "InvalidArgumentError",
35
+ "JsonError",
36
+ "RedirectionError",
37
+ "ServerError",
38
+ "TransportError",
39
+ "TransportTimeoutError",
40
+ ]
@@ -0,0 +1,129 @@
1
+ """What an unsuccessful status says it means, read off the response."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Sequence
8
+ from typing import TYPE_CHECKING, Final, cast
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ from xtr_http_client_contracts import HttpResponseInterface
14
+
15
+ __all__ = ["build_message"]
16
+
17
+ _MAX_ERROR_BODY: Final = 32768
18
+ """How much of an error document is worth reading to name the error.
19
+
20
+ An error message is a line or two; a body larger than this is a payload that
21
+ happens to have failed, and reading it to the end to quote 40 characters of it
22
+ would mean holding it all. Past the limit the status line stands on its own.
23
+ """
24
+
25
+ _HYDRA: Final = ("hydra:title", "hydra:description")
26
+ _PROBLEM: Final = ("title", "detail")
27
+ _JSON_WORD: Final = re.compile(r"\bjson\b", re.IGNORECASE)
28
+
29
+
30
+ async def build_message(response: HttpResponseInterface) -> str:
31
+ """Say what the status means for the request that got it.
32
+
33
+ The code and the URL are always enough — ``HTTP 404 returned for "..."``.
34
+ Two things make it better, and both come off the response itself: the
35
+ status line the server sent, which carries its own wording of the code,
36
+ and an error document, which carries the server's wording of the problem.
37
+
38
+ The document is only looked for when the head says the body is JSON, and
39
+ only the head of it is parsed, so naming an error never means holding a
40
+ payload. The two shapes read are the ones APIs actually send: ``title`` and
41
+ ``detail``, or the same pair under a ``hydra:`` prefix. The media type is
42
+ not checked beyond "it says JSON" because these shapes travel under many of
43
+ them.
44
+
45
+ Args:
46
+ response: The response the status came on. Its body is read with
47
+ ``throw=False``, so reading it cannot raise over the very status
48
+ this message is about.
49
+
50
+ Returns:
51
+ The message, ready to be the ``reason`` of an
52
+ :class:`HttpError <xtr_http_client_contracts.exception.http_error.HttpError>`.
53
+ """
54
+ url = response.get_info("url")
55
+ message = f'HTTP {response.get_info("http_code")} returned for "{url}".'
56
+ status_line, is_json = _read_head(response.get_info("response_headers"))
57
+
58
+ if status_line is not None:
59
+ message = f'{status_line} returned for "{url}".'
60
+ if not is_json:
61
+ return message
62
+
63
+ content = (await response.get_content(throw=False))[:_MAX_ERROR_BODY]
64
+ try:
65
+ document = cast("object", json.loads(content))
66
+ except ValueError:
67
+ return message
68
+
69
+ return _described(document) or message
70
+
71
+
72
+ def _read_head(response_headers: object) -> tuple[str | None, bool]:
73
+ """Find the last status line, and whether its response said JSON.
74
+
75
+ Read backwards, because a chain of redirects leaves every hop's head in
76
+ there and it is the last one the status belongs to. A content type is
77
+ noted wherever it appears, since it may be written before the status line
78
+ it belongs to.
79
+ """
80
+ if not isinstance(response_headers, Sequence) or isinstance(response_headers, str):
81
+ return None, False
82
+
83
+ status_line: str | None = None
84
+ is_json = False
85
+ for line in reversed([entry for entry in response_headers if isinstance(entry, str)]):
86
+ if line.startswith("HTTP/"):
87
+ if status_line is not None:
88
+ break
89
+ status_line = line
90
+ if line.lower().startswith("content-type:"):
91
+ is_json = is_json or _JSON_WORD.search(line) is not None
92
+ if status_line is not None:
93
+ break
94
+
95
+ return status_line, is_json
96
+
97
+
98
+ def _described(document: object) -> str | None:
99
+ """Join the two fields an error document names the problem with."""
100
+ if not document or not isinstance(document, dict):
101
+ return None
102
+
103
+ # A decoded JSON object is keyed by strings; the cast says so once rather
104
+ # than at every lookup below.
105
+ fields = cast("Mapping[str, object]", document)
106
+ for title, detail in (_HYDRA, _PROBLEM):
107
+ head, tail = fields.get(title), fields.get(detail)
108
+ if head is None and tail is None:
109
+ continue
110
+ if not _is_scalar(head) or not _is_scalar(tail):
111
+ return None
112
+ separator = "\n\n" if head is not None and tail is not None else ""
113
+ return f"{_as_text(head)}{separator}{_as_text(tail)}"
114
+
115
+ return None
116
+
117
+
118
+ def _is_scalar(value: object) -> bool:
119
+ """Tell whether a decoded JSON value can stand in a message as it is."""
120
+ return value is None or isinstance(value, str | int | float | bool)
121
+
122
+
123
+ def _as_text(value: object) -> str:
124
+ """Write a scalar out the way the document meant it to be read."""
125
+ if value is None:
126
+ return ""
127
+ if isinstance(value, bool):
128
+ return "1" if value else ""
129
+ return str(value)
@@ -0,0 +1,57 @@
1
+ """Where a client comes from when the caller has no opinion about which one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import Unpack
8
+
9
+ from .httpx_http_client import HttpxHttpClient
10
+
11
+ if TYPE_CHECKING:
12
+ from xtr_http_client_contracts import HttpClientInterface, RequestOptions
13
+
14
+ __all__ = ["HttpClient"]
15
+
16
+
17
+ @final
18
+ class HttpClient:
19
+ """One call that hands back a client, so nothing has to name an implementation.
20
+
21
+ Code that calls out depends on the interface. Code that *builds* a client
22
+ has to pick something, and picking is a decision worth making in one
23
+ place: this is that place.
24
+
25
+ ```python
26
+ from xtr_http_client import HttpClient
27
+
28
+ client = HttpClient.create(base_uri="https://example.com/", timeout=5)
29
+ ```
30
+
31
+ It is not a client itself and holds nothing; there is nothing to keep an
32
+ instance of.
33
+ """
34
+
35
+ __slots__ = ()
36
+
37
+ @staticmethod
38
+ def create(
39
+ max_host_connections: int = 6,
40
+ **default_options: Unpack[RequestOptions],
41
+ ) -> HttpClientInterface:
42
+ """Return a client whose requests start from ``default_options``.
43
+
44
+ Args:
45
+ max_host_connections: How many connections one pool may hold at
46
+ once.
47
+ **default_options: What every request the client makes starts
48
+ from, over the contract's own defaults.
49
+
50
+ Returns:
51
+ A client, typed as the interface: what is behind it is this
52
+ package's decision, not the caller's.
53
+
54
+ Raises:
55
+ InvalidArgumentError: When an option is not one the client knows.
56
+ """
57
+ return HttpxHttpClient(default_options, max_host_connections)