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.
- xtr_http_client/.agents/skills/xtr-http-client/SKILL.md +289 -0
- xtr_http_client/__init__.py +47 -0
- xtr_http_client/bundle/__init__.py +17 -0
- xtr_http_client/bundle/_clients.py +217 -0
- xtr_http_client/bundle/client_options_config.py +79 -0
- xtr_http_client/bundle/http_client_bundle.py +85 -0
- xtr_http_client/bundle/http_client_config.py +75 -0
- xtr_http_client/bundle/retry_failed_config.py +77 -0
- xtr_http_client/bundle/scoped_client_config.py +99 -0
- xtr_http_client/exception/__init__.py +40 -0
- xtr_http_client/exception/_http_error_message.py +129 -0
- xtr_http_client/http_client.py +57 -0
- xtr_http_client/http_client_mixin.py +176 -0
- xtr_http_client/httpx_http_client.py +327 -0
- xtr_http_client/internal/__init__.py +13 -0
- xtr_http_client/internal/debug_type.py +14 -0
- xtr_http_client/internal/httpx_body.py +88 -0
- xtr_http_client/internal/httpx_clients.py +203 -0
- xtr_http_client/internal/httpx_errors.py +63 -0
- xtr_http_client/internal/httpx_redirect.py +63 -0
- xtr_http_client/internal/httpx_request.py +272 -0
- xtr_http_client/internal/mock_head.py +144 -0
- xtr_http_client/internal/mock_info.py +86 -0
- xtr_http_client/internal/option_merge.py +108 -0
- xtr_http_client/internal/response_body.py +270 -0
- xtr_http_client/internal/retry_count_response.py +148 -0
- xtr_http_client/internal/retry_delay.py +45 -0
- xtr_http_client/internal/retry_options.py +98 -0
- xtr_http_client/mock_http_client.py +253 -0
- xtr_http_client/no_private_network_http_client.py +342 -0
- xtr_http_client/py.typed +0 -0
- xtr_http_client/recipe/__init__.py +15 -0
- xtr_http_client/recipe/manifest.toml +23 -0
- xtr_http_client/response/__init__.py +10 -0
- xtr_http_client/response/http_response.py +412 -0
- xtr_http_client/response/httpx_response.py +173 -0
- xtr_http_client/response/json_mock_response.py +119 -0
- xtr_http_client/response/mock_response.py +375 -0
- xtr_http_client/retry/__init__.py +25 -0
- xtr_http_client/retry/generic_retry_strategy.py +215 -0
- xtr_http_client/retry/retry_context.py +73 -0
- xtr_http_client/retry/retry_strategy_interface.py +74 -0
- xtr_http_client/retryable_http_client.py +346 -0
- xtr_http_client/scoping_http_client.py +303 -0
- xtr_http_client/throttling_http_client.py +143 -0
- xtr_http_client/traceable_http_client.py +406 -0
- xtr_http_client-4.0.2.dist-info/METADATA +505 -0
- xtr_http_client-4.0.2.dist-info/RECORD +51 -0
- xtr_http_client-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_client-4.0.2.dist-info/entry_points.txt +6 -0
- 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)
|