xtr-http-client-contracts 4.0.2__tar.gz

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 (28) hide show
  1. xtr_http_client_contracts-4.0.2/LICENSE +22 -0
  2. xtr_http_client_contracts-4.0.2/PKG-INFO +271 -0
  3. xtr_http_client_contracts-4.0.2/README.md +244 -0
  4. xtr_http_client_contracts-4.0.2/pyproject.toml +173 -0
  5. xtr_http_client_contracts-4.0.2/pyproject.toml.orig +187 -0
  6. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/__init__.py +47 -0
  7. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/__init__.py +40 -0
  8. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/client_error.py +16 -0
  9. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/decoding_error.py +32 -0
  10. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/http_client_error.py +21 -0
  11. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/http_error.py +51 -0
  12. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/invalid_argument_error.py +27 -0
  13. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/json_error.py +19 -0
  14. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/redirection_error.py +21 -0
  15. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/server_error.py +15 -0
  16. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/transport_error.py +31 -0
  17. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/exception/transport_timeout_error.py +27 -0
  18. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/http_client_interface.py +124 -0
  19. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/http_response_interface.py +148 -0
  20. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/options_defaults.py +69 -0
  21. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/py.typed +0 -0
  22. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/request_options.py +106 -0
  23. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/testing/__init__.py +12 -0
  24. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/testing/_fixture_request.py +184 -0
  25. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/testing/_fixture_response.py +80 -0
  26. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/testing/_fixture_routes.py +321 -0
  27. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/testing/http_client_test_case.py +1035 -0
  28. xtr_http_client_contracts-4.0.2/src/xtr_http_client_contracts/testing/test_http_server.py +213 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2018-present Fabien Potencier
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-http-client-contracts
3
+ Version: 4.0.2
4
+ Summary: The async HTTP client contract alone: a request call, a response read on demand, typed options and the errors they raise.
5
+ Keywords: http,http-client,contracts,interface,protocol,asyncio
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: typing-extensions>=4.4
20
+ Requires-Dist: xtr-http-message-contracts>=4.0,<5
21
+ Requires-Dist: anyio>=4.0 ; extra == 'testing'
22
+ Requires-Dist: pytest>=8 ; extra == 'testing'
23
+ Requires-Dist: xtr-http-message>=4.0,<5 ; extra == 'testing'
24
+ Requires-Python: >=3.11
25
+ Provides-Extra: testing
26
+ Description-Content-Type: text/markdown
27
+
28
+ <div align="center">
29
+
30
+ # xtr-http-client-contracts
31
+
32
+ **The async HTTP client contract, and nothing else — so a library that calls out installs no backend.**
33
+
34
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
35
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
36
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
37
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
38
+
39
+ </div>
40
+
41
+ ---
42
+
43
+ ## Why?
44
+
45
+ A library that sends an HTTP request should not choose what sends it. It takes the client
46
+ interface, asks for a method and a URL, and leaves connection pooling, redirects and TLS to
47
+ whatever the application wired behind it.
48
+
49
+ That needs the *seam*, not a client. This package is that seam: the client interface, the
50
+ response interface, the typed request options, the errors they raise, and a conformance suite
51
+ any implementation runs against itself.
52
+
53
+ ## Install
54
+
55
+ ```sh
56
+ uv add xtr-http-client-contracts
57
+ uv add --dev "xtr-http-client-contracts[testing]" # + what the conformance suite runs on
58
+ ```
59
+
60
+ Requires Python 3.11+. The only runtime dependency beyond `typing-extensions` is
61
+ [xtr-http-message-contracts](../xtr-http-message-contracts): a client response is an HTTP
62
+ response before it is anything else.
63
+
64
+ ## What a caller depends on
65
+
66
+ ```python
67
+ from xtr_http_client_contracts import HttpClientInterface
68
+
69
+
70
+ async def paid_orders(client: HttpClientInterface) -> dict[str, object]:
71
+ response = await client.request("GET", "https://api.example.com/orders", query={"paid": "1"})
72
+ async with response:
73
+ return await response.to_array()
74
+ ```
75
+
76
+ `HttpClientInterface` is a `@runtime_checkable` `Protocol` with three members:
77
+
78
+ | Member | Answers |
79
+ |---|---|
80
+ | `await request(method, url, **options)` | an `HttpResponseInterface`, once the status line and the headers are in |
81
+ | `await send_request(request, **options)` | the same, for a [message request](../xtr-http-message-contracts) that already knows its own method, URI, headers and body |
82
+ | `with_options(**options)` | another client of the same type, whose defaults are these options over this one's |
83
+
84
+ Neither call raises because of the status: a 404 or a 503 is an answer and comes back as a
85
+ response. What they do raise is `TransportError` when nothing answered,
86
+ `TransportTimeoutError` when nothing answered in time, and `InvalidArgumentError` for a
87
+ method, a URL or an option that is refused before anything is sent.
88
+
89
+ `send_request` says what wins when a request and the options both have an opinion: the request
90
+ is authoritative. A relative URI is resolved against `base_uri`, `query` is merged into the
91
+ URI, a header from `headers` is sent only under a name the request does not carry,
92
+ `auth_basic` and `auth_bearer` apply only without an `Authorization` of the request's own, and
93
+ `body` or `json` alongside a request that already carries a body is refused, word for word:
94
+
95
+ ```
96
+ Define either the request body or the "body" or "json" option, setting both is not supported.
97
+ ```
98
+
99
+ Several requests run at once by being made from tasks of one task group; nothing in the
100
+ contract is per client state a caller has to serialize.
101
+
102
+ ## The response
103
+
104
+ `HttpResponseInterface` extends the message `ResponseInterface`, so the status code, the reason
105
+ phrase, the protocol version, the headers, `get_body()` and the `with_*` copies are the message
106
+ contract's — code typed against the plain response interface takes one of these as it is, and
107
+ `isinstance` is what reveals the extras:
108
+
109
+ ```python
110
+ from xtr_http_client_contracts import HttpResponseInterface
111
+ from xtr_http_message_contracts import ResponseInterface
112
+
113
+
114
+ def log(response: ResponseInterface) -> None:
115
+ print(response.get_status_code())
116
+ if isinstance(response, HttpResponseInterface):
117
+ print(response.get_info("total_time"))
118
+ ```
119
+
120
+ What a client adds on top of a message response:
121
+
122
+ | Member | Is |
123
+ |---|---|
124
+ | `await get_content(throw=True)` | every byte of the body, waiting for the rest of it to arrive |
125
+ | `await to_array(throw=True)` | that body decoded as a JSON object |
126
+ | `get_info(key=None)` | what the transfer knows about itself, never waiting and never raising |
127
+ | `await cancel()` | the transfer abandoned, and what it held let go of |
128
+ | `async with response:` | a block that owns the transfer and closes the body on the way out |
129
+
130
+ `throw` is the one switch: left on, an unsuccessful status is raised by reading the body —
131
+ `RedirectionError`, `ClientError`, `ServerError` — rather than returned. `throw=False` hands
132
+ the body over whatever the status says, which is how a 4xx carrying an explanation is read.
133
+
134
+ `get_info()` always answers these entries: `canceled`, `error`, `http_code` (`0` before a
135
+ status arrived), `http_method`, `redirect_count`, `redirect_url`, `response_headers` (the raw
136
+ status and header lines of every hop), `start_time`, `total_time`, `url` and `user_data`.
137
+ `primary_ip` and `retry_count` are there when they are known. Because it neither waits nor
138
+ raises, it is readable while the transfer is still going on.
139
+
140
+ ## Request options
141
+
142
+ `RequestOptions` is a `total=False` `TypedDict`, so every option is checked where it is
143
+ written and an option left out is one nobody asked about — the client fills it in from its own
144
+ defaults, which start at `OPTIONS_DEFAULTS`.
145
+
146
+ ```python
147
+ from xtr_http_client_contracts import OPTIONS_DEFAULTS, RequestOptions
148
+
149
+ defaults: RequestOptions = {**OPTIONS_DEFAULTS, "timeout": 5, "max_redirects": 0}
150
+ ```
151
+
152
+ | Option | Says |
153
+ |---|---|
154
+ | `auth_basic`, `auth_bearer` | the credentials to send; both at once is refused |
155
+ | `query` | parameters merged into the URL, winning over one of the same name already there |
156
+ | `headers` | the headers to send, one value or several per name |
157
+ | `body`, `json` | what to send — bytes, text, a mapping as a form, an async iterable produced as it goes; or a value encoded as JSON. Both at once is refused |
158
+ | `user_data` | anything to find again under the `user_data` entry of `get_info()`; never read |
159
+ | `max_redirects` | how many hops to follow; zero or less answers with the 3xx |
160
+ | `http_version` | `"1.0"`, `"1.1"`, `"2"` or `"2.0"`; `None` leaves it to the connection |
161
+ | `base_uri` | what a relative URL resolves against. A sequence of them is for a client that retries, which moves along it |
162
+ | `timeout` | how long the transfer may go quiet for. `None` is 60 seconds, not "no limit" |
163
+ | `max_duration`, `max_connect_duration` | limits on the whole request and on connecting. Zero *is* "no limit" |
164
+ | `resolve` | host to address, instead of asking a resolver — what pins a request to an address already checked |
165
+ | `proxy` | the proxy to go through, as a URL |
166
+ | `verify_peer`, `verify_host`, `cafile`, `capath`, `local_cert`, `local_pk`, `passphrase` | how the peer is checked, and what is presented to it |
167
+ | `max_retries` | how many further attempts a client that retries may make |
168
+ | `extra` | options a client may ignore where it has no use for them — `use_persistent_connections`, `trace_content` |
169
+
170
+ A name the contract does not list is refused rather than quietly obeyed, with
171
+ `InvalidArgumentError`:
172
+
173
+ ```
174
+ Unsupported option "timout" passed to "HttpxHttpClient", did you mean "timeout"?
175
+ ```
176
+
177
+ That refusal is the point of naming them: an option spelled wrong cannot pass for one that was
178
+ obeyed. `extra` is the escape hatch for what one client understands and another has no use
179
+ for, so entries in it may be ignored.
180
+
181
+ ## Errors
182
+
183
+ Everything derives from `HttpClientError`, and the shape of the tree is the question a caller
184
+ asks: was there an answer at all, and was it the one wanted?
185
+
186
+ | Error | Raised when |
187
+ |---|---|
188
+ | `HttpClientError` | the root; nothing raises it directly |
189
+ | `TransportError` | no answer came — a refused connection, a broken transfer, a cancelled response |
190
+ | `TransportTimeoutError` | no answer came in time; a `TransportError` |
191
+ | `HttpError` | there is an answer and it is not a success |
192
+ | `RedirectionError` | a 3xx read with `throw`, once `max_redirects` was reached |
193
+ | `ClientError` | a 4xx read with `throw` |
194
+ | `ServerError` | a 5xx read with `throw` |
195
+ | `DecodingError` | the body is not what it was read as |
196
+ | `JsonError` | the body is not the JSON object it was read as; a `DecodingError` |
197
+ | `InvalidArgumentError` | the request is one the client refuses to make, before anything is sent |
198
+
199
+ The message of an `HttpError` names the code and the URL — `HTTP 404 returned for "<url>".` —
200
+ or quotes the status line the server sent, and takes the `title` and `detail` of the body when
201
+ that body is a JSON error document.
202
+
203
+ ## The conformance suite
204
+
205
+ `xtr_http_client_contracts.testing` is how an implementation proves it is one. It ships in
206
+ `src` rather than in `tests` because it is run by other packages; install it with the
207
+ `testing` extra, which brings an async backend, the runner the suite is written for, and a
208
+ message request implementation.
209
+
210
+ Subclass `HttpClientTestCase` and return the client under test:
211
+
212
+ ```python
213
+ # tests/integration/test_my_http_client.py
214
+ from xtr_http_client_contracts import HttpClientInterface
215
+ from xtr_http_client_contracts.testing import HttpClientTestCase
216
+
217
+ from my_package import MyHttpClient
218
+
219
+
220
+ class TestMyHttpClient(HttpClientTestCase):
221
+ def create_client(self) -> HttpClientInterface:
222
+ return MyHttpClient()
223
+ ```
224
+
225
+ That is the whole of it: the suite starts `TestHttpServer` once for the session on a free port,
226
+ points every test at it through `self.base_uri` and `self.port`, and runs the contract as
227
+ behaviour — the status that is an answer, the body that is
228
+ kept and can be read twice, the redirect chain that drops credentials when it leaves the host
229
+ and gets them back when it returns, the timeouts, the proxy settings read from the
230
+ environment, the `resolve` pin, the options `send_request` fills in, and the refusals quoted
231
+ above. A client that passes is one a caller can hold through the interface alone.
232
+
233
+ `create_client` is called per test, freshly: a test may cancel a transfer or exhaust a pool, so
234
+ no two tests share a client. A client that cannot honour part of the contract yet says which
235
+ tests it skips and why, rather than quietly not running them:
236
+
237
+ ```python
238
+ from collections.abc import Mapping
239
+ from typing import ClassVar
240
+
241
+
242
+ class TestMyHttpClient(HttpClientTestCase):
243
+ skipped_tests: ClassVar[Mapping[str, str]] = {
244
+ "test_ipv6_resolve": "no IPv6 in this environment",
245
+ }
246
+ ```
247
+
248
+ A parametrized test is named there without its parameter. `TestHttpServer(host=..., port=...)`
249
+ is usable on its own — as a context manager, or with `start()` and `stop()` — for a test that
250
+ needs a second server of its own. Leave `port` out and it listens on whichever free port the
251
+ operating system gives it, which `server.port` and `server.base_uri` report once it has started;
252
+ two suites running at once never collide.
253
+
254
+ ## Development
255
+
256
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
257
+ `packages/xtr-http-client-contracts`; run the commands below from there. The
258
+ `python-xtr-http-client-contracts` repository is a read-only copy, so send issues and pull
259
+ requests to the monorepo.
260
+
261
+ ```sh
262
+ uv sync --all-extras
263
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
264
+ ```
265
+
266
+ [xtr-http-client](../xtr-http-client) is the implementation, and runs this suite against each
267
+ of its clients.
268
+
269
+ ## License
270
+
271
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,244 @@
1
+ <div align="center">
2
+
3
+ # xtr-http-client-contracts
4
+
5
+ **The async HTTP client contract, and nothing else — so a library that calls out installs no backend.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
9
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
10
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Why?
17
+
18
+ A library that sends an HTTP request should not choose what sends it. It takes the client
19
+ interface, asks for a method and a URL, and leaves connection pooling, redirects and TLS to
20
+ whatever the application wired behind it.
21
+
22
+ That needs the *seam*, not a client. This package is that seam: the client interface, the
23
+ response interface, the typed request options, the errors they raise, and a conformance suite
24
+ any implementation runs against itself.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ uv add xtr-http-client-contracts
30
+ uv add --dev "xtr-http-client-contracts[testing]" # + what the conformance suite runs on
31
+ ```
32
+
33
+ Requires Python 3.11+. The only runtime dependency beyond `typing-extensions` is
34
+ [xtr-http-message-contracts](../xtr-http-message-contracts): a client response is an HTTP
35
+ response before it is anything else.
36
+
37
+ ## What a caller depends on
38
+
39
+ ```python
40
+ from xtr_http_client_contracts import HttpClientInterface
41
+
42
+
43
+ async def paid_orders(client: HttpClientInterface) -> dict[str, object]:
44
+ response = await client.request("GET", "https://api.example.com/orders", query={"paid": "1"})
45
+ async with response:
46
+ return await response.to_array()
47
+ ```
48
+
49
+ `HttpClientInterface` is a `@runtime_checkable` `Protocol` with three members:
50
+
51
+ | Member | Answers |
52
+ |---|---|
53
+ | `await request(method, url, **options)` | an `HttpResponseInterface`, once the status line and the headers are in |
54
+ | `await send_request(request, **options)` | the same, for a [message request](../xtr-http-message-contracts) that already knows its own method, URI, headers and body |
55
+ | `with_options(**options)` | another client of the same type, whose defaults are these options over this one's |
56
+
57
+ Neither call raises because of the status: a 404 or a 503 is an answer and comes back as a
58
+ response. What they do raise is `TransportError` when nothing answered,
59
+ `TransportTimeoutError` when nothing answered in time, and `InvalidArgumentError` for a
60
+ method, a URL or an option that is refused before anything is sent.
61
+
62
+ `send_request` says what wins when a request and the options both have an opinion: the request
63
+ is authoritative. A relative URI is resolved against `base_uri`, `query` is merged into the
64
+ URI, a header from `headers` is sent only under a name the request does not carry,
65
+ `auth_basic` and `auth_bearer` apply only without an `Authorization` of the request's own, and
66
+ `body` or `json` alongside a request that already carries a body is refused, word for word:
67
+
68
+ ```
69
+ Define either the request body or the "body" or "json" option, setting both is not supported.
70
+ ```
71
+
72
+ Several requests run at once by being made from tasks of one task group; nothing in the
73
+ contract is per client state a caller has to serialize.
74
+
75
+ ## The response
76
+
77
+ `HttpResponseInterface` extends the message `ResponseInterface`, so the status code, the reason
78
+ phrase, the protocol version, the headers, `get_body()` and the `with_*` copies are the message
79
+ contract's — code typed against the plain response interface takes one of these as it is, and
80
+ `isinstance` is what reveals the extras:
81
+
82
+ ```python
83
+ from xtr_http_client_contracts import HttpResponseInterface
84
+ from xtr_http_message_contracts import ResponseInterface
85
+
86
+
87
+ def log(response: ResponseInterface) -> None:
88
+ print(response.get_status_code())
89
+ if isinstance(response, HttpResponseInterface):
90
+ print(response.get_info("total_time"))
91
+ ```
92
+
93
+ What a client adds on top of a message response:
94
+
95
+ | Member | Is |
96
+ |---|---|
97
+ | `await get_content(throw=True)` | every byte of the body, waiting for the rest of it to arrive |
98
+ | `await to_array(throw=True)` | that body decoded as a JSON object |
99
+ | `get_info(key=None)` | what the transfer knows about itself, never waiting and never raising |
100
+ | `await cancel()` | the transfer abandoned, and what it held let go of |
101
+ | `async with response:` | a block that owns the transfer and closes the body on the way out |
102
+
103
+ `throw` is the one switch: left on, an unsuccessful status is raised by reading the body —
104
+ `RedirectionError`, `ClientError`, `ServerError` — rather than returned. `throw=False` hands
105
+ the body over whatever the status says, which is how a 4xx carrying an explanation is read.
106
+
107
+ `get_info()` always answers these entries: `canceled`, `error`, `http_code` (`0` before a
108
+ status arrived), `http_method`, `redirect_count`, `redirect_url`, `response_headers` (the raw
109
+ status and header lines of every hop), `start_time`, `total_time`, `url` and `user_data`.
110
+ `primary_ip` and `retry_count` are there when they are known. Because it neither waits nor
111
+ raises, it is readable while the transfer is still going on.
112
+
113
+ ## Request options
114
+
115
+ `RequestOptions` is a `total=False` `TypedDict`, so every option is checked where it is
116
+ written and an option left out is one nobody asked about — the client fills it in from its own
117
+ defaults, which start at `OPTIONS_DEFAULTS`.
118
+
119
+ ```python
120
+ from xtr_http_client_contracts import OPTIONS_DEFAULTS, RequestOptions
121
+
122
+ defaults: RequestOptions = {**OPTIONS_DEFAULTS, "timeout": 5, "max_redirects": 0}
123
+ ```
124
+
125
+ | Option | Says |
126
+ |---|---|
127
+ | `auth_basic`, `auth_bearer` | the credentials to send; both at once is refused |
128
+ | `query` | parameters merged into the URL, winning over one of the same name already there |
129
+ | `headers` | the headers to send, one value or several per name |
130
+ | `body`, `json` | what to send — bytes, text, a mapping as a form, an async iterable produced as it goes; or a value encoded as JSON. Both at once is refused |
131
+ | `user_data` | anything to find again under the `user_data` entry of `get_info()`; never read |
132
+ | `max_redirects` | how many hops to follow; zero or less answers with the 3xx |
133
+ | `http_version` | `"1.0"`, `"1.1"`, `"2"` or `"2.0"`; `None` leaves it to the connection |
134
+ | `base_uri` | what a relative URL resolves against. A sequence of them is for a client that retries, which moves along it |
135
+ | `timeout` | how long the transfer may go quiet for. `None` is 60 seconds, not "no limit" |
136
+ | `max_duration`, `max_connect_duration` | limits on the whole request and on connecting. Zero *is* "no limit" |
137
+ | `resolve` | host to address, instead of asking a resolver — what pins a request to an address already checked |
138
+ | `proxy` | the proxy to go through, as a URL |
139
+ | `verify_peer`, `verify_host`, `cafile`, `capath`, `local_cert`, `local_pk`, `passphrase` | how the peer is checked, and what is presented to it |
140
+ | `max_retries` | how many further attempts a client that retries may make |
141
+ | `extra` | options a client may ignore where it has no use for them — `use_persistent_connections`, `trace_content` |
142
+
143
+ A name the contract does not list is refused rather than quietly obeyed, with
144
+ `InvalidArgumentError`:
145
+
146
+ ```
147
+ Unsupported option "timout" passed to "HttpxHttpClient", did you mean "timeout"?
148
+ ```
149
+
150
+ That refusal is the point of naming them: an option spelled wrong cannot pass for one that was
151
+ obeyed. `extra` is the escape hatch for what one client understands and another has no use
152
+ for, so entries in it may be ignored.
153
+
154
+ ## Errors
155
+
156
+ Everything derives from `HttpClientError`, and the shape of the tree is the question a caller
157
+ asks: was there an answer at all, and was it the one wanted?
158
+
159
+ | Error | Raised when |
160
+ |---|---|
161
+ | `HttpClientError` | the root; nothing raises it directly |
162
+ | `TransportError` | no answer came — a refused connection, a broken transfer, a cancelled response |
163
+ | `TransportTimeoutError` | no answer came in time; a `TransportError` |
164
+ | `HttpError` | there is an answer and it is not a success |
165
+ | `RedirectionError` | a 3xx read with `throw`, once `max_redirects` was reached |
166
+ | `ClientError` | a 4xx read with `throw` |
167
+ | `ServerError` | a 5xx read with `throw` |
168
+ | `DecodingError` | the body is not what it was read as |
169
+ | `JsonError` | the body is not the JSON object it was read as; a `DecodingError` |
170
+ | `InvalidArgumentError` | the request is one the client refuses to make, before anything is sent |
171
+
172
+ The message of an `HttpError` names the code and the URL — `HTTP 404 returned for "<url>".` —
173
+ or quotes the status line the server sent, and takes the `title` and `detail` of the body when
174
+ that body is a JSON error document.
175
+
176
+ ## The conformance suite
177
+
178
+ `xtr_http_client_contracts.testing` is how an implementation proves it is one. It ships in
179
+ `src` rather than in `tests` because it is run by other packages; install it with the
180
+ `testing` extra, which brings an async backend, the runner the suite is written for, and a
181
+ message request implementation.
182
+
183
+ Subclass `HttpClientTestCase` and return the client under test:
184
+
185
+ ```python
186
+ # tests/integration/test_my_http_client.py
187
+ from xtr_http_client_contracts import HttpClientInterface
188
+ from xtr_http_client_contracts.testing import HttpClientTestCase
189
+
190
+ from my_package import MyHttpClient
191
+
192
+
193
+ class TestMyHttpClient(HttpClientTestCase):
194
+ def create_client(self) -> HttpClientInterface:
195
+ return MyHttpClient()
196
+ ```
197
+
198
+ That is the whole of it: the suite starts `TestHttpServer` once for the session on a free port,
199
+ points every test at it through `self.base_uri` and `self.port`, and runs the contract as
200
+ behaviour — the status that is an answer, the body that is
201
+ kept and can be read twice, the redirect chain that drops credentials when it leaves the host
202
+ and gets them back when it returns, the timeouts, the proxy settings read from the
203
+ environment, the `resolve` pin, the options `send_request` fills in, and the refusals quoted
204
+ above. A client that passes is one a caller can hold through the interface alone.
205
+
206
+ `create_client` is called per test, freshly: a test may cancel a transfer or exhaust a pool, so
207
+ no two tests share a client. A client that cannot honour part of the contract yet says which
208
+ tests it skips and why, rather than quietly not running them:
209
+
210
+ ```python
211
+ from collections.abc import Mapping
212
+ from typing import ClassVar
213
+
214
+
215
+ class TestMyHttpClient(HttpClientTestCase):
216
+ skipped_tests: ClassVar[Mapping[str, str]] = {
217
+ "test_ipv6_resolve": "no IPv6 in this environment",
218
+ }
219
+ ```
220
+
221
+ A parametrized test is named there without its parameter. `TestHttpServer(host=..., port=...)`
222
+ is usable on its own — as a context manager, or with `start()` and `stop()` — for a test that
223
+ needs a second server of its own. Leave `port` out and it listens on whichever free port the
224
+ operating system gives it, which `server.port` and `server.base_uri` report once it has started;
225
+ two suites running at once never collide.
226
+
227
+ ## Development
228
+
229
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
230
+ `packages/xtr-http-client-contracts`; run the commands below from there. The
231
+ `python-xtr-http-client-contracts` repository is a read-only copy, so send issues and pull
232
+ requests to the monorepo.
233
+
234
+ ```sh
235
+ uv sync --all-extras
236
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
237
+ ```
238
+
239
+ [xtr-http-client](../xtr-http-client) is the implementation, and runs this suite against each
240
+ of its clients.
241
+
242
+ ## License
243
+
244
+ MIT — see [LICENSE](LICENSE).