xtr-http-client-contracts 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_contracts/__init__.py +47 -0
- xtr_http_client_contracts/exception/__init__.py +40 -0
- xtr_http_client_contracts/exception/client_error.py +16 -0
- xtr_http_client_contracts/exception/decoding_error.py +32 -0
- xtr_http_client_contracts/exception/http_client_error.py +21 -0
- xtr_http_client_contracts/exception/http_error.py +51 -0
- xtr_http_client_contracts/exception/invalid_argument_error.py +27 -0
- xtr_http_client_contracts/exception/json_error.py +19 -0
- xtr_http_client_contracts/exception/redirection_error.py +21 -0
- xtr_http_client_contracts/exception/server_error.py +15 -0
- xtr_http_client_contracts/exception/transport_error.py +31 -0
- xtr_http_client_contracts/exception/transport_timeout_error.py +27 -0
- xtr_http_client_contracts/http_client_interface.py +124 -0
- xtr_http_client_contracts/http_response_interface.py +148 -0
- xtr_http_client_contracts/options_defaults.py +69 -0
- xtr_http_client_contracts/py.typed +0 -0
- xtr_http_client_contracts/request_options.py +106 -0
- xtr_http_client_contracts/testing/__init__.py +12 -0
- xtr_http_client_contracts/testing/_fixture_request.py +184 -0
- xtr_http_client_contracts/testing/_fixture_response.py +80 -0
- xtr_http_client_contracts/testing/_fixture_routes.py +321 -0
- xtr_http_client_contracts/testing/http_client_test_case.py +1035 -0
- xtr_http_client_contracts/testing/test_http_server.py +213 -0
- xtr_http_client_contracts-4.0.2.dist-info/METADATA +271 -0
- xtr_http_client_contracts-4.0.2.dist-info/RECORD +27 -0
- xtr_http_client_contracts-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_client_contracts-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
"""The conformance suite every client implementation runs against itself.
|
|
2
|
+
|
|
3
|
+
Subclass :class:`HttpClientTestCase`, return a client from ``create_client``, and the whole
|
|
4
|
+
suite runs against the fixture server. Nothing here is specific to one client: every assertion
|
|
5
|
+
is something the contract promises, so a client that passes is one a caller can hold through
|
|
6
|
+
the interface alone.
|
|
7
|
+
|
|
8
|
+
The suite is one class on purpose — it is the contract read as behaviour, and a reader checking
|
|
9
|
+
whether their client conforms reads it top to bottom. Splitting it by topic would only move the
|
|
10
|
+
reading around. (allow: SIZE_OK — one conformance suite, one file.)
|
|
11
|
+
|
|
12
|
+
What came from the reference suite, test by test. "ported" means the same calls and the same
|
|
13
|
+
assertions; "translated" says what had to change for an async client whose ``request()``
|
|
14
|
+
returns once the status line and the headers are in; "removed" names the feature the contract
|
|
15
|
+
dropped, which took the test with it.
|
|
16
|
+
|
|
17
|
+
================================== ==========================================================
|
|
18
|
+
``testGetRequest`` translated: eager request
|
|
19
|
+
``testHeadRequest`` translated: eager request, and the body is always kept
|
|
20
|
+
``testNonBufferedGetRequest`` removed: the body is always kept
|
|
21
|
+
``testBufferSink`` no counterpart: the sink is a writable stream resource,
|
|
22
|
+
which an async client has no equivalent of
|
|
23
|
+
``testConditionalBuffering`` removed: the body is always kept
|
|
24
|
+
``testReentrantBufferCallback`` removed: the body is always kept
|
|
25
|
+
``testThrowingBufferCallback`` removed: the body is always kept
|
|
26
|
+
``testUnsupportedOption`` translated: an unsupported option is a ``TransportError``
|
|
27
|
+
``testHttpVersion`` translated: ``http_version`` is the version as text
|
|
28
|
+
``testChunkedEncoding`` translated: the fixture's chunked body
|
|
29
|
+
``testClientError`` translated: eager request, without the streamed half
|
|
30
|
+
``testIgnoreErrors`` ported
|
|
31
|
+
``testDnsError`` translated: eager request
|
|
32
|
+
``testInlineAuth`` translated: the fixture's ``AUTH_USER`` / ``AUTH_PW``
|
|
33
|
+
``testBadRequestBody`` translated: eager request
|
|
34
|
+
``test304`` translated: the body is always kept
|
|
35
|
+
``testRedirects`` translated: parametrized, and the header filter names
|
|
36
|
+
``Content-Type`` because the fixture declares a length
|
|
37
|
+
``testInvalidRedirect`` translated: the status is raised by reading the content
|
|
38
|
+
``testRelativeRedirects`` ported
|
|
39
|
+
``testRedirect307`` ported
|
|
40
|
+
``testMaxRedirects`` translated: the status is raised by reading the content
|
|
41
|
+
``testStream`` removed: no chunk streaming
|
|
42
|
+
``testAddToStream`` removed: no chunk streaming
|
|
43
|
+
``testCompleteTypeError`` removed: no chunk streaming
|
|
44
|
+
``testOnProgress`` removed: no progress callback
|
|
45
|
+
``testPostJson`` ported
|
|
46
|
+
``testPostArray`` ported
|
|
47
|
+
``testPostResource`` translated: an ``AsyncIterable[bytes]`` body
|
|
48
|
+
``testPostCallback`` translated: an ``AsyncIterable[bytes]`` body
|
|
49
|
+
``testCancel`` ported
|
|
50
|
+
``testInfoOnCanceledResponse`` ported
|
|
51
|
+
``testCancelInStream`` removed: no chunk streaming
|
|
52
|
+
``testOnProgressCancel`` removed: no progress callback
|
|
53
|
+
``testOnProgressError`` removed: no progress callback
|
|
54
|
+
``testResolve`` translated: eager request
|
|
55
|
+
``testIdnResolve`` ported
|
|
56
|
+
``testIPv6Resolve`` translated: a second server, started by the test
|
|
57
|
+
``testNotATimeout`` ported
|
|
58
|
+
``testTimeoutOnAccess`` translated: eager request
|
|
59
|
+
``testTimeoutIsNotAFatalError`` ported
|
|
60
|
+
``testTimeoutOnStream`` removed: no chunk streaming
|
|
61
|
+
``testUncheckedTimeoutThrows`` removed: no chunk streaming
|
|
62
|
+
``testTimeoutWithActiveConcurrentStream`` translated: two servers, started by the test
|
|
63
|
+
``testTimeoutOnInitialize`` translated: eager request
|
|
64
|
+
``testTimeoutOnDestruct`` translated: eager request — the timeouts are reported
|
|
65
|
+
where the requests are made, not where they are released
|
|
66
|
+
``testDestruct`` translated: the ``async with`` exit frees the transfer
|
|
67
|
+
``testGetContentAfterDestruct`` removed: releasing a response raises no status
|
|
68
|
+
``testGetEncodedContentAfterDestruct`` removed: releasing a response raises no status
|
|
69
|
+
``testProxy`` translated: the ``proxy`` option alone
|
|
70
|
+
``testNoProxy`` translated: ``http_proxy`` and ``no_proxy``, patched into
|
|
71
|
+
the environment rather than written to it
|
|
72
|
+
``testAutoEncodingRequest`` ported
|
|
73
|
+
``testBaseUri`` ported
|
|
74
|
+
``testQuery`` ported
|
|
75
|
+
``testInformationalResponse`` ported
|
|
76
|
+
``testInformationalResponseStream`` removed: no chunk streaming
|
|
77
|
+
``testUserlandEncodingRequest`` ported
|
|
78
|
+
``testGzipBroken`` ported
|
|
79
|
+
``testMaxDuration`` ported
|
|
80
|
+
``testMaxConnectDurationInfo`` removed: the connect limit is not in the transfer info
|
|
81
|
+
``testMaxConnectDuration`` translated: eager request
|
|
82
|
+
``testWithOptions`` ported
|
|
83
|
+
``testBindToPort`` removed: no ``bindto`` option
|
|
84
|
+
``testBindToPortV6`` removed: no ``bindto`` option
|
|
85
|
+
================================== ==========================================================
|
|
86
|
+
|
|
87
|
+
63 reference methods: 43 here, 19 removed with the features they test, 1 with no counterpart.
|
|
88
|
+
|
|
89
|
+
Seven tests are the contract's own additions, with nothing behind them in the reference suite:
|
|
90
|
+
|
|
91
|
+
``test_body_stream``
|
|
92
|
+
Reads the body through the message's stream rather than in one go.
|
|
93
|
+
``test_send_request_prefers_the_request_headers``
|
|
94
|
+
A header the request carries wins; the options fill the names it lacks.
|
|
95
|
+
``test_send_request_resolves_a_relative_request_uri``
|
|
96
|
+
A relative request URI is resolved against ``base_uri``.
|
|
97
|
+
``test_send_request_merges_the_query_option``
|
|
98
|
+
``query`` is merged into the URI the request came with.
|
|
99
|
+
``test_send_request_applies_auth_bearer_only_without_one``
|
|
100
|
+
``auth_bearer`` fills an Authorization the request has none of, and replaces none.
|
|
101
|
+
``test_send_request_refuses_a_body_on_both``
|
|
102
|
+
A request with a body plus a ``body`` option is refused, word for word.
|
|
103
|
+
``test_request_is_send_request_of_the_same_request``
|
|
104
|
+
``request()`` answers as ``send_request`` of the same request does.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
from __future__ import annotations
|
|
108
|
+
|
|
109
|
+
import gzip
|
|
110
|
+
import json
|
|
111
|
+
import re
|
|
112
|
+
import time
|
|
113
|
+
from collections.abc import Mapping
|
|
114
|
+
from typing import TYPE_CHECKING, ClassVar, Protocol, TypeVar, cast
|
|
115
|
+
|
|
116
|
+
import anyio
|
|
117
|
+
import pytest
|
|
118
|
+
from xtr_http_message import Request
|
|
119
|
+
|
|
120
|
+
from xtr_http_client_contracts.exception import (
|
|
121
|
+
ClientError,
|
|
122
|
+
InvalidArgumentError,
|
|
123
|
+
RedirectionError,
|
|
124
|
+
TransportError,
|
|
125
|
+
TransportTimeoutError,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
from .test_http_server import TestHttpServer
|
|
129
|
+
|
|
130
|
+
if TYPE_CHECKING:
|
|
131
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
132
|
+
|
|
133
|
+
from xtr_http_client_contracts.http_client_interface import HttpClientInterface
|
|
134
|
+
from xtr_http_client_contracts.http_response_interface import HttpResponseInterface
|
|
135
|
+
from xtr_http_client_contracts.request_options import RequestOptions
|
|
136
|
+
|
|
137
|
+
__all__ = ["HttpClientTestCase"]
|
|
138
|
+
|
|
139
|
+
_T = TypeVar("_T")
|
|
140
|
+
|
|
141
|
+
_HOST = "localhost"
|
|
142
|
+
_IPV6_HOST = "::1"
|
|
143
|
+
|
|
144
|
+
# A name reserved for documentation, pinned to the fixture server by the `resolve` option.
|
|
145
|
+
_PINNED_HOST = "pinned.example"
|
|
146
|
+
|
|
147
|
+
# The hops of a redirect chain, as the reference suite reads them out of `response_headers`:
|
|
148
|
+
# the status lines, where each hop pointed, and what each hop answered with. The fixture's
|
|
149
|
+
# echo routes declare a length, so the filter names `Content-Type` rather than any `Cont`
|
|
150
|
+
# header.
|
|
151
|
+
_CHAIN_FIELDS = ("HTTP", "Location:", "Content-Type:")
|
|
152
|
+
|
|
153
|
+
# The echo entry no two connections agree on: the port the client's end of the connection
|
|
154
|
+
# was given, which the operating system picks afresh every time.
|
|
155
|
+
_CLIENT_PORT = "REMOTE_PORT"
|
|
156
|
+
|
|
157
|
+
# Matched word for word, because the contract quotes this refusal rather than describing it.
|
|
158
|
+
_BOTH_BODIES = (
|
|
159
|
+
'Define either the request body or the "body" or "json" option, setting both is not supported.'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _NamedNode(Protocol):
|
|
164
|
+
"""The collected test, as much of it as the skip list needs."""
|
|
165
|
+
|
|
166
|
+
name: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _RunningTest(Protocol):
|
|
170
|
+
"""What the runner tells a fixture about the test it is running for.
|
|
171
|
+
|
|
172
|
+
The runner leaves this untyped, and the suite will not loosen its own checking to read
|
|
173
|
+
one string: this says what is actually there and nothing else.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def node(self) -> _NamedNode: ...
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _decode_object(content: bytes) -> Mapping[str, object]:
|
|
181
|
+
"""``content`` read as the JSON object it is.
|
|
182
|
+
|
|
183
|
+
The standard library's decoder answers anything at all; saying here what the suite asks of
|
|
184
|
+
it keeps that looseness to one place.
|
|
185
|
+
"""
|
|
186
|
+
return cast("Mapping[str, object]", cast("object", json.loads(content)))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _without_client_port(echo: Mapping[str, object]) -> Mapping[str, object]:
|
|
190
|
+
"""``echo`` without the port the request came from.
|
|
191
|
+
|
|
192
|
+
Every connection is given a port of its own, so two echoes of the same request differ
|
|
193
|
+
there and nowhere else; dropping it is what leaves the rest comparable.
|
|
194
|
+
"""
|
|
195
|
+
return {name: value for name, value in echo.items() if name != _CLIENT_PORT}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _expect(value: object, kind: type[_T]) -> _T:
|
|
199
|
+
"""``value``, once it is a ``kind``; the transfer info comes out of the contract untyped."""
|
|
200
|
+
if not isinstance(value, kind):
|
|
201
|
+
raise TypeError(f"expected {kind.__name__}, got {value!r}")
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _info(response: HttpResponseInterface, key: str, kind: type[_T]) -> _T:
|
|
206
|
+
"""The ``key`` entry of ``response``'s info, narrowed to ``kind``."""
|
|
207
|
+
return _expect(response.get_info(key), kind)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _info_map(response: HttpResponseInterface) -> Mapping[str, object]:
|
|
211
|
+
"""Every entry of ``response``'s info at once."""
|
|
212
|
+
return cast("Mapping[str, object]", _expect(response.get_info(), Mapping))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _info_lines(response: HttpResponseInterface) -> list[str]:
|
|
216
|
+
"""The ``response_headers`` entry, as the lines it holds."""
|
|
217
|
+
lines = cast("Sequence[object]", response.get_info("response_headers"))
|
|
218
|
+
return [_expect(line, str) for line in lines]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _chain(response: HttpResponseInterface) -> list[str]:
|
|
222
|
+
"""Where every hop of a redirect chain went, and what it answered with."""
|
|
223
|
+
return [line for line in _info_lines(response) if line.startswith(_CHAIN_FIELDS)]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _header(response: HttpResponseInterface, name: str) -> Sequence[str]:
|
|
227
|
+
"""Every value under ``name``, matched without regard to case."""
|
|
228
|
+
wanted = name.lower()
|
|
229
|
+
headers = response.get_headers()
|
|
230
|
+
for key, values in headers.items():
|
|
231
|
+
if key.lower() == wanted:
|
|
232
|
+
return values
|
|
233
|
+
raise AssertionError(f"no {name} header among {sorted(headers)}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def _parts(*parts: bytes) -> AsyncIterator[bytes]:
|
|
237
|
+
"""``parts`` as a body produced piece by piece."""
|
|
238
|
+
for part in parts:
|
|
239
|
+
yield part
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _not_bytes() -> AsyncIterator[bytes]:
|
|
243
|
+
"""A body yielding something that is not bytes, which no client can send."""
|
|
244
|
+
yield cast("bytes", cast("object", []))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class HttpClientTestCase:
|
|
248
|
+
"""What every client that satisfies the contract does, run against the fixture server.
|
|
249
|
+
|
|
250
|
+
Subclass it and return the client under test::
|
|
251
|
+
|
|
252
|
+
class TestMyClient(HttpClientTestCase):
|
|
253
|
+
def create_client(self) -> HttpClientInterface:
|
|
254
|
+
return MyClient()
|
|
255
|
+
|
|
256
|
+
A client that cannot honour part of the contract yet names the tests it skips and why::
|
|
257
|
+
|
|
258
|
+
class TestMyClient(HttpClientTestCase):
|
|
259
|
+
skipped_tests: ClassVar[Mapping[str, str]] = {
|
|
260
|
+
"test_ipv6_resolve": "no IPv6 in this environment",
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
The suite starts the fixture server once for the whole session, on a free port, and
|
|
264
|
+
points every test at it through :attr:`base_uri` and :attr:`port`; the few tests that need
|
|
265
|
+
a second server start and stop one themselves. A subclass adding tests of its own reads
|
|
266
|
+
the same two attributes.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
__test__: ClassVar[bool] = False # the suite is a base class, not a test case of its own
|
|
270
|
+
|
|
271
|
+
pytestmark: ClassVar[pytest.MarkDecorator] = pytest.mark.anyio
|
|
272
|
+
|
|
273
|
+
skipped_tests: ClassVar[Mapping[str, str]] = {}
|
|
274
|
+
"""Test names to skip, mapped to why. A parametrized test is named without its parameter."""
|
|
275
|
+
|
|
276
|
+
port: int = 0
|
|
277
|
+
"""The port the session's fixture server listens on, set before every test."""
|
|
278
|
+
|
|
279
|
+
base_uri: str = ""
|
|
280
|
+
"""The origin of the session's fixture server, set before every test."""
|
|
281
|
+
|
|
282
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
283
|
+
@staticmethod
|
|
284
|
+
def fixture_server() -> Iterator[TestHttpServer]:
|
|
285
|
+
"""The server every test points at, listening for the whole session."""
|
|
286
|
+
with TestHttpServer() as server:
|
|
287
|
+
yield server
|
|
288
|
+
|
|
289
|
+
@pytest.fixture(autouse=True)
|
|
290
|
+
def _point_at_fixture_server(self, fixture_server: TestHttpServer) -> None:
|
|
291
|
+
"""Aim this test at wherever the session's fixture server was given a port."""
|
|
292
|
+
self.port = fixture_server.port
|
|
293
|
+
self.base_uri = f"http://{_HOST}:{self.port}"
|
|
294
|
+
|
|
295
|
+
@pytest.fixture(autouse=True)
|
|
296
|
+
def _skip_if_unsupported(self, request: pytest.FixtureRequest) -> None:
|
|
297
|
+
"""Skip a test this client has declared it does not satisfy."""
|
|
298
|
+
running = cast("_RunningTest", cast("object", request))
|
|
299
|
+
reason = self.skipped_tests.get(running.node.name.partition("[")[0])
|
|
300
|
+
if reason is not None:
|
|
301
|
+
pytest.skip(reason)
|
|
302
|
+
|
|
303
|
+
def create_client(self) -> HttpClientInterface:
|
|
304
|
+
"""Return the client the suite is to run against.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
A client, freshly made: a test may cancel transfers or exhaust a connection pool,
|
|
308
|
+
so no two tests share one.
|
|
309
|
+
"""
|
|
310
|
+
raise NotImplementedError("a subclass returns the client under test")
|
|
311
|
+
|
|
312
|
+
async def test_get_request(self) -> None:
|
|
313
|
+
"""A plain GET: the status and headers are in, and the body decodes to what was sent."""
|
|
314
|
+
client = self.create_client()
|
|
315
|
+
data = object()
|
|
316
|
+
response = await client.request(
|
|
317
|
+
"GET",
|
|
318
|
+
self.base_uri,
|
|
319
|
+
headers={"Foo": "baR"},
|
|
320
|
+
user_data=data,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
assert _info_lines(response)
|
|
324
|
+
assert _info_map(response)["user_data"] is data
|
|
325
|
+
assert response.get_status_code() == 200
|
|
326
|
+
|
|
327
|
+
info = _info_map(response)
|
|
328
|
+
assert info["error"] is None
|
|
329
|
+
assert info["redirect_count"] == 0
|
|
330
|
+
assert _info_lines(response)[0] == "HTTP/1.1 200 OK"
|
|
331
|
+
assert info["url"] == f"{self.base_uri}/"
|
|
332
|
+
|
|
333
|
+
assert _header(response, "content-type") == ["application/json"]
|
|
334
|
+
|
|
335
|
+
body = _decode_object(await response.get_content())
|
|
336
|
+
assert body == await response.to_array()
|
|
337
|
+
|
|
338
|
+
assert body["SERVER_PROTOCOL"] == "HTTP/1.1"
|
|
339
|
+
assert body["REQUEST_URI"] == "/"
|
|
340
|
+
assert body["REQUEST_METHOD"] == "GET"
|
|
341
|
+
assert body["HTTP_HOST"] == f"{_HOST}:{self.port}"
|
|
342
|
+
assert body["HTTP_FOO"] == "baR"
|
|
343
|
+
|
|
344
|
+
broken = await client.request("GET", f"{self.base_uri}/length-broken")
|
|
345
|
+
|
|
346
|
+
with pytest.raises(TransportError):
|
|
347
|
+
_ = await broken.get_content()
|
|
348
|
+
|
|
349
|
+
async def test_body_stream(self) -> None:
|
|
350
|
+
"""The body is also the message's stream, which reads the same bytes.
|
|
351
|
+
|
|
352
|
+
Nothing in the reference suite reads a response that way — it has no streamed body
|
|
353
|
+
on the message — so this is the contract's own addition.
|
|
354
|
+
"""
|
|
355
|
+
client = self.create_client()
|
|
356
|
+
response = await client.request("GET", self.base_uri, headers={"Foo": "baR"})
|
|
357
|
+
|
|
358
|
+
content = await response.get_content()
|
|
359
|
+
body = response.get_body()
|
|
360
|
+
|
|
361
|
+
assert await body.get_contents() == content
|
|
362
|
+
await body.rewind()
|
|
363
|
+
assert await body.read(len(content)) == content
|
|
364
|
+
|
|
365
|
+
async def test_head_request(self) -> None:
|
|
366
|
+
"""A HEAD answers headers describing a body it does not send."""
|
|
367
|
+
client = self.create_client()
|
|
368
|
+
response = await client.request("HEAD", f"{self.base_uri}/head", headers={"Foo": "baR"})
|
|
369
|
+
|
|
370
|
+
assert _info_lines(response)
|
|
371
|
+
assert response.get_status_code() == 200
|
|
372
|
+
assert _info_lines(response)[0] == "HTTP/1.1 200 OK"
|
|
373
|
+
|
|
374
|
+
assert _header(response, "content-type") == ["application/json"]
|
|
375
|
+
assert int(_header(response, "content-length")[0]) > 0
|
|
376
|
+
|
|
377
|
+
assert await response.get_content() == b""
|
|
378
|
+
|
|
379
|
+
async def test_unsupported_option(self) -> None:
|
|
380
|
+
"""An option the client has never heard of is refused, never quietly obeyed."""
|
|
381
|
+
client = self.create_client()
|
|
382
|
+
unknown = cast("RequestOptions", cast("object", {"capture_peer_cert": 1.0}))
|
|
383
|
+
|
|
384
|
+
with pytest.raises(TransportError):
|
|
385
|
+
_ = await client.request("GET", self.base_uri, **unknown)
|
|
386
|
+
|
|
387
|
+
async def test_http_version(self) -> None:
|
|
388
|
+
"""Asking for HTTP/1.0 sends HTTP/1.0, which the echo answers with."""
|
|
389
|
+
client = self.create_client()
|
|
390
|
+
response = await client.request("GET", self.base_uri, http_version="1.0")
|
|
391
|
+
|
|
392
|
+
assert response.get_status_code() == 200
|
|
393
|
+
assert _info_lines(response)[0] == "HTTP/1.0 200 OK"
|
|
394
|
+
|
|
395
|
+
body = await response.to_array()
|
|
396
|
+
|
|
397
|
+
assert body["SERVER_PROTOCOL"] == "HTTP/1.0"
|
|
398
|
+
assert body["REQUEST_METHOD"] == "GET"
|
|
399
|
+
assert body["REQUEST_URI"] == "/"
|
|
400
|
+
|
|
401
|
+
async def test_chunked_encoding(self) -> None:
|
|
402
|
+
"""A chunked body is reassembled; framing that stops mid-chunk fails the transfer."""
|
|
403
|
+
client = self.create_client()
|
|
404
|
+
response = await client.request("GET", f"{self.base_uri}/chunked")
|
|
405
|
+
|
|
406
|
+
assert _header(response, "transfer-encoding") == ["chunked"]
|
|
407
|
+
assert await response.get_content() == b"chunked data works!"
|
|
408
|
+
|
|
409
|
+
response = await client.request("GET", f"{self.base_uri}/chunked-broken")
|
|
410
|
+
|
|
411
|
+
with pytest.raises(TransportError):
|
|
412
|
+
_ = await response.get_content()
|
|
413
|
+
|
|
414
|
+
async def test_client_error(self) -> None:
|
|
415
|
+
"""A 404 is an answer: told about when asked for, readable when not."""
|
|
416
|
+
client = self.create_client()
|
|
417
|
+
response = await client.request("GET", f"{self.base_uri}/404")
|
|
418
|
+
|
|
419
|
+
assert _info(response, "http_code", int) == 404
|
|
420
|
+
|
|
421
|
+
with pytest.raises(ClientError):
|
|
422
|
+
_ = await response.get_content()
|
|
423
|
+
|
|
424
|
+
assert response.get_status_code() == 404
|
|
425
|
+
assert _header(response, "content-type") == ["application/json"]
|
|
426
|
+
assert await response.get_content(throw=False)
|
|
427
|
+
|
|
428
|
+
async def test_ignore_errors(self) -> None:
|
|
429
|
+
"""Reading the status of a 404 is not being told about it."""
|
|
430
|
+
client = self.create_client()
|
|
431
|
+
response = await client.request("GET", f"{self.base_uri}/404")
|
|
432
|
+
|
|
433
|
+
assert response.get_status_code() == 404
|
|
434
|
+
|
|
435
|
+
async def test_dns_error(self) -> None:
|
|
436
|
+
"""A host that will not resolve fails the request, and fails it again on a retry."""
|
|
437
|
+
client = self.create_client()
|
|
438
|
+
|
|
439
|
+
with pytest.raises(TransportError):
|
|
440
|
+
_ = await client.request("GET", f"{self.base_uri}/301/bad-tld")
|
|
441
|
+
|
|
442
|
+
with pytest.raises(TransportError):
|
|
443
|
+
_ = await client.request("GET", f"{self.base_uri}/301/bad-tld")
|
|
444
|
+
|
|
445
|
+
async def test_inline_auth(self) -> None:
|
|
446
|
+
"""Credentials in the URL are sent as basic credentials, decoded as given."""
|
|
447
|
+
client = self.create_client()
|
|
448
|
+
response = await client.request("GET", f"http://foo:bar%3Dbar@{_HOST}:{self.port}")
|
|
449
|
+
|
|
450
|
+
body = await response.to_array()
|
|
451
|
+
|
|
452
|
+
assert body["AUTH_USER"] == "foo"
|
|
453
|
+
assert body["AUTH_PW"] == "bar=bar"
|
|
454
|
+
|
|
455
|
+
async def test_bad_request_body(self) -> None:
|
|
456
|
+
"""A body producing something that is not bytes fails the request."""
|
|
457
|
+
client = self.create_client()
|
|
458
|
+
|
|
459
|
+
with pytest.raises(TransportError):
|
|
460
|
+
_ = await client.request("POST", f"{self.base_uri}/", body=_not_bytes())
|
|
461
|
+
|
|
462
|
+
async def test_304(self) -> None:
|
|
463
|
+
"""A 304 carries no body, whatever length it declares."""
|
|
464
|
+
client = self.create_client()
|
|
465
|
+
response = await client.request(
|
|
466
|
+
"GET", f"{self.base_uri}/304", headers={"If-Match": '"abc"'}
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
assert response.get_status_code() == 304
|
|
470
|
+
assert await response.get_content(throw=False) == b""
|
|
471
|
+
|
|
472
|
+
@pytest.mark.parametrize("headers", [{}, {"Content-Length": "7"}])
|
|
473
|
+
async def test_redirects(self, headers: Mapping[str, str]) -> None:
|
|
474
|
+
"""A chain is followed as GET, and credentials come back when it returns home."""
|
|
475
|
+
client = self.create_client()
|
|
476
|
+
response = await client.request(
|
|
477
|
+
"POST",
|
|
478
|
+
f"{self.base_uri}/301",
|
|
479
|
+
auth_basic="foo:bar",
|
|
480
|
+
headers=headers,
|
|
481
|
+
body=_parts(b"foo=bar"),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
body = await response.to_array()
|
|
485
|
+
assert body["REQUEST_METHOD"] == "GET"
|
|
486
|
+
assert body["HTTP_AUTHORIZATION"] == "Basic Zm9vOmJhcg=="
|
|
487
|
+
assert _info(response, "url", str) == f"{self.base_uri}/"
|
|
488
|
+
|
|
489
|
+
assert _info(response, "redirect_count", int) == 2
|
|
490
|
+
assert response.get_info("redirect_url") is None
|
|
491
|
+
|
|
492
|
+
assert _chain(response) == [
|
|
493
|
+
"HTTP/1.1 301 Moved Permanently",
|
|
494
|
+
f"Location: http://127.0.0.1:{self.port}/302",
|
|
495
|
+
"Content-Type: application/json",
|
|
496
|
+
"HTTP/1.1 302 Found",
|
|
497
|
+
f"Location: {self.base_uri}/",
|
|
498
|
+
"Content-Type: application/json",
|
|
499
|
+
"HTTP/1.1 200 OK",
|
|
500
|
+
"Content-Type: application/json",
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
async def test_invalid_redirect(self) -> None:
|
|
504
|
+
"""A Location nothing can be made of is left as the 3xx it came on."""
|
|
505
|
+
client = self.create_client()
|
|
506
|
+
response = await client.request("GET", f"{self.base_uri}/301/invalid")
|
|
507
|
+
|
|
508
|
+
assert response.get_status_code() == 301
|
|
509
|
+
assert _header(response, "location") == ["//?foo=bar"]
|
|
510
|
+
assert _info(response, "redirect_count", int) == 0
|
|
511
|
+
assert response.get_info("redirect_url") is None
|
|
512
|
+
|
|
513
|
+
with pytest.raises(RedirectionError):
|
|
514
|
+
_ = await response.get_content()
|
|
515
|
+
|
|
516
|
+
async def test_relative_redirects(self) -> None:
|
|
517
|
+
"""A relative Location is resolved; following none leaves it to be read."""
|
|
518
|
+
client = self.create_client()
|
|
519
|
+
response = await client.request("GET", f"{self.base_uri}/302/relative")
|
|
520
|
+
|
|
521
|
+
body = await response.to_array()
|
|
522
|
+
|
|
523
|
+
assert body["REQUEST_URI"] == "/"
|
|
524
|
+
assert response.get_info("redirect_url") is None
|
|
525
|
+
|
|
526
|
+
response = await client.request("GET", f"{self.base_uri}/302/relative", max_redirects=0)
|
|
527
|
+
|
|
528
|
+
assert response.get_status_code() == 302
|
|
529
|
+
assert _info(response, "redirect_url", str) == f"{self.base_uri}/"
|
|
530
|
+
|
|
531
|
+
async def test_redirect_307(self) -> None:
|
|
532
|
+
"""A 307 keeps the method and the body, once it is followed."""
|
|
533
|
+
client = self.create_client()
|
|
534
|
+
|
|
535
|
+
response = await client.request(
|
|
536
|
+
"POST",
|
|
537
|
+
f"{self.base_uri}/307",
|
|
538
|
+
body=_parts(b"foo=bar"),
|
|
539
|
+
max_redirects=0,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
assert response.get_status_code() == 307
|
|
543
|
+
|
|
544
|
+
response = await client.request("POST", f"{self.base_uri}/307", body="foo=bar")
|
|
545
|
+
|
|
546
|
+
assert await response.to_array() == {"foo": "bar", "REQUEST_METHOD": "POST"}
|
|
547
|
+
|
|
548
|
+
async def test_max_redirects(self) -> None:
|
|
549
|
+
"""One hop too few ends on the 3xx, saying where it would have gone."""
|
|
550
|
+
client = self.create_client()
|
|
551
|
+
response = await client.request(
|
|
552
|
+
"GET",
|
|
553
|
+
f"{self.base_uri}/301",
|
|
554
|
+
max_redirects=1,
|
|
555
|
+
auth_basic="foo:bar",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
with pytest.raises(RedirectionError):
|
|
559
|
+
_ = await response.get_content()
|
|
560
|
+
|
|
561
|
+
assert response.get_status_code() == 302
|
|
562
|
+
assert _info(response, "redirect_count", int) == 1
|
|
563
|
+
assert _info(response, "redirect_url", str) == f"{self.base_uri}/"
|
|
564
|
+
|
|
565
|
+
assert _chain(response) == [
|
|
566
|
+
"HTTP/1.1 301 Moved Permanently",
|
|
567
|
+
f"Location: http://127.0.0.1:{self.port}/302",
|
|
568
|
+
"Content-Type: application/json",
|
|
569
|
+
"HTTP/1.1 302 Found",
|
|
570
|
+
f"Location: {self.base_uri}/",
|
|
571
|
+
"Content-Type: application/json",
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
async def test_post_json(self) -> None:
|
|
575
|
+
"""A JSON body is sent as JSON, and says so in its content type."""
|
|
576
|
+
client = self.create_client()
|
|
577
|
+
|
|
578
|
+
response = await client.request("POST", f"{self.base_uri}/post", json={"foo": "bar"})
|
|
579
|
+
|
|
580
|
+
body = dict(await response.to_array())
|
|
581
|
+
|
|
582
|
+
assert "json" in _expect(body.pop("content-type"), str)
|
|
583
|
+
assert body == {"foo": "bar", "REQUEST_METHOD": "POST"}
|
|
584
|
+
|
|
585
|
+
async def test_post_array(self) -> None:
|
|
586
|
+
"""A mapping body is sent as a form, which the echo answers field by field."""
|
|
587
|
+
client = self.create_client()
|
|
588
|
+
|
|
589
|
+
response = await client.request("POST", f"{self.base_uri}/post", body={"foo": "bar"})
|
|
590
|
+
|
|
591
|
+
assert await response.to_array() == {"foo": "bar", "REQUEST_METHOD": "POST"}
|
|
592
|
+
|
|
593
|
+
async def test_post_resource(self) -> None:
|
|
594
|
+
"""A body read from somewhere else is sent as it is produced."""
|
|
595
|
+
client = self.create_client()
|
|
596
|
+
|
|
597
|
+
response = await client.request(
|
|
598
|
+
"POST",
|
|
599
|
+
f"{self.base_uri}/post",
|
|
600
|
+
body=_parts(b"foo=0123456789"),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
body = await response.to_array()
|
|
604
|
+
|
|
605
|
+
assert body == {"foo": "0123456789", "REQUEST_METHOD": "POST"}
|
|
606
|
+
|
|
607
|
+
async def test_post_callback(self) -> None:
|
|
608
|
+
"""A body produced in pieces, empty ones included, arrives as one form."""
|
|
609
|
+
client = self.create_client()
|
|
610
|
+
|
|
611
|
+
response = await client.request(
|
|
612
|
+
"POST",
|
|
613
|
+
f"{self.base_uri}/post",
|
|
614
|
+
body=_parts(b"foo", b"", b"=", b"0123456789"),
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
assert await response.to_array() == {"foo": "0123456789", "REQUEST_METHOD": "POST"}
|
|
618
|
+
|
|
619
|
+
async def test_cancel(self) -> None:
|
|
620
|
+
"""A cancelled transfer has nothing left to read."""
|
|
621
|
+
client = self.create_client()
|
|
622
|
+
response = await client.request("GET", f"{self.base_uri}/timeout-header")
|
|
623
|
+
|
|
624
|
+
await response.cancel()
|
|
625
|
+
|
|
626
|
+
with pytest.raises(TransportError):
|
|
627
|
+
_ = await response.get_content()
|
|
628
|
+
|
|
629
|
+
async def test_info_on_canceled_response(self) -> None:
|
|
630
|
+
"""Cancelling is something the transfer info records."""
|
|
631
|
+
client = self.create_client()
|
|
632
|
+
|
|
633
|
+
response = await client.request("GET", f"{self.base_uri}/timeout-header")
|
|
634
|
+
|
|
635
|
+
assert _info(response, "canceled", bool) is False
|
|
636
|
+
await response.cancel()
|
|
637
|
+
assert _info(response, "canceled", bool) is True
|
|
638
|
+
|
|
639
|
+
async def test_resolve(self) -> None:
|
|
640
|
+
"""A host pinned to an address is reached at it, and is not once the pin is gone."""
|
|
641
|
+
client = self.create_client()
|
|
642
|
+
response = await client.request(
|
|
643
|
+
"GET",
|
|
644
|
+
f"http://{_PINNED_HOST}:{self.port}/",
|
|
645
|
+
resolve={_PINNED_HOST: "127.0.0.1"},
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
assert response.get_status_code() == 200
|
|
649
|
+
assert (
|
|
650
|
+
await client.request("GET", f"http://{_PINNED_HOST}:{self.port}/")
|
|
651
|
+
).get_status_code() == 200
|
|
652
|
+
|
|
653
|
+
with pytest.raises(TransportError):
|
|
654
|
+
_ = await client.request("GET", f"http://{_PINNED_HOST}:{self.port}/", timeout=1)
|
|
655
|
+
|
|
656
|
+
async def test_idn_resolve(self) -> None:
|
|
657
|
+
"""A very long label and a non-ASCII name both resolve to what they are pinned to."""
|
|
658
|
+
client = self.create_client()
|
|
659
|
+
|
|
660
|
+
long_host = "0" + "-" * 61 + "0.com"
|
|
661
|
+
response = await client.request(
|
|
662
|
+
"GET",
|
|
663
|
+
f"http://{long_host}:{self.port}/",
|
|
664
|
+
resolve={long_host: "127.0.0.1"},
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
assert response.get_status_code() == 200
|
|
668
|
+
|
|
669
|
+
response = await client.request(
|
|
670
|
+
"GET",
|
|
671
|
+
f"http://Bücher.example:{self.port}/",
|
|
672
|
+
resolve={"xn--bcher-kva.example": "127.0.0.1"},
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
assert response.get_status_code() == 200
|
|
676
|
+
|
|
677
|
+
async def test_ipv6_resolve(self) -> None:
|
|
678
|
+
"""A host pinned to an IPv6 address is reached over IPv6."""
|
|
679
|
+
server = TestHttpServer(host=_IPV6_HOST)
|
|
680
|
+
server.start()
|
|
681
|
+
try:
|
|
682
|
+
client = self.create_client()
|
|
683
|
+
response = await client.request(
|
|
684
|
+
"GET",
|
|
685
|
+
f"http://{_PINNED_HOST}:{server.port}/",
|
|
686
|
+
resolve={_PINNED_HOST: _IPV6_HOST},
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
assert response.get_status_code() == 200
|
|
690
|
+
finally:
|
|
691
|
+
server.stop()
|
|
692
|
+
|
|
693
|
+
async def test_not_a_timeout(self) -> None:
|
|
694
|
+
"""The timeout is how long a transfer may go quiet for, not how long it may take."""
|
|
695
|
+
client = self.create_client()
|
|
696
|
+
response = await client.request("GET", f"{self.base_uri}/timeout-header", timeout=0.9)
|
|
697
|
+
await anyio.sleep(1)
|
|
698
|
+
assert response.get_status_code() == 200
|
|
699
|
+
|
|
700
|
+
async def test_timeout_on_access(self) -> None:
|
|
701
|
+
"""A head that does not arrive in time fails the request."""
|
|
702
|
+
client = self.create_client()
|
|
703
|
+
|
|
704
|
+
with pytest.raises(TransportError):
|
|
705
|
+
_ = await client.request("GET", f"{self.base_uri}/timeout-header", timeout=0.1)
|
|
706
|
+
|
|
707
|
+
async def test_timeout_is_not_a_fatal_error(self) -> None:
|
|
708
|
+
"""A transfer that timed out mid-body carries on being read."""
|
|
709
|
+
await anyio.sleep(0.3) # let the previous test release the server
|
|
710
|
+
client = self.create_client()
|
|
711
|
+
response = await client.request("GET", f"{self.base_uri}/timeout-body", timeout=0.25)
|
|
712
|
+
|
|
713
|
+
with pytest.raises(TransportTimeoutError):
|
|
714
|
+
_ = await response.get_content()
|
|
715
|
+
|
|
716
|
+
for _attempt in range(10):
|
|
717
|
+
try:
|
|
718
|
+
content = await response.get_content()
|
|
719
|
+
except TransportTimeoutError:
|
|
720
|
+
continue
|
|
721
|
+
assert content == b"<1><2>"
|
|
722
|
+
break
|
|
723
|
+
else:
|
|
724
|
+
pytest.fail("the body never arrived")
|
|
725
|
+
|
|
726
|
+
async def test_timeout_with_active_concurrent_stream(self) -> None:
|
|
727
|
+
"""A transfer that keeps arriving does not hold a timeout back."""
|
|
728
|
+
first = TestHttpServer()
|
|
729
|
+
second = TestHttpServer()
|
|
730
|
+
first.start()
|
|
731
|
+
second.start()
|
|
732
|
+
try:
|
|
733
|
+
client = self.create_client()
|
|
734
|
+
streaming = await client.request("GET", f"http://{_HOST}:{first.port}/max-duration")
|
|
735
|
+
blocking = await client.request(
|
|
736
|
+
"GET",
|
|
737
|
+
f"http://{_HOST}:{second.port}/timeout-body",
|
|
738
|
+
timeout=0.25,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
assert streaming.get_status_code() == 200
|
|
742
|
+
assert blocking.get_status_code() == 200
|
|
743
|
+
|
|
744
|
+
with pytest.raises(TransportError):
|
|
745
|
+
_ = await blocking.get_content()
|
|
746
|
+
finally:
|
|
747
|
+
first.stop()
|
|
748
|
+
second.stop()
|
|
749
|
+
|
|
750
|
+
async def test_timeout_on_initialize(self) -> None:
|
|
751
|
+
"""Four heads that never arrive time out alongside each other, not one after another."""
|
|
752
|
+
first = TestHttpServer()
|
|
753
|
+
second = TestHttpServer()
|
|
754
|
+
first.start()
|
|
755
|
+
second.start()
|
|
756
|
+
try:
|
|
757
|
+
client = self.create_client()
|
|
758
|
+
start = time.monotonic()
|
|
759
|
+
|
|
760
|
+
async with anyio.create_task_group() as requests:
|
|
761
|
+
for port in (first.port, second.port, first.port, second.port):
|
|
762
|
+
_ = requests.start_soon(self._expect_timeout, client, port)
|
|
763
|
+
|
|
764
|
+
assert time.monotonic() - start < 1.0
|
|
765
|
+
finally:
|
|
766
|
+
first.stop()
|
|
767
|
+
second.stop()
|
|
768
|
+
|
|
769
|
+
async def test_timeout_on_destruct(self) -> None:
|
|
770
|
+
"""The same four, each reported where its request was made."""
|
|
771
|
+
first = TestHttpServer()
|
|
772
|
+
second = TestHttpServer()
|
|
773
|
+
first.start()
|
|
774
|
+
second.start()
|
|
775
|
+
try:
|
|
776
|
+
client = self.create_client()
|
|
777
|
+
start = time.monotonic()
|
|
778
|
+
|
|
779
|
+
for port in (first.port, second.port, first.port, second.port):
|
|
780
|
+
with pytest.raises(TransportError):
|
|
781
|
+
_ = await client.request(
|
|
782
|
+
"GET",
|
|
783
|
+
f"http://{_HOST}:{port}/timeout-header",
|
|
784
|
+
timeout=0.25,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
assert time.monotonic() - start < 2.0
|
|
788
|
+
finally:
|
|
789
|
+
first.stop()
|
|
790
|
+
second.stop()
|
|
791
|
+
|
|
792
|
+
async def test_destruct(self) -> None:
|
|
793
|
+
"""Leaving the block frees a transfer rather than waiting for the rest of it."""
|
|
794
|
+
client = self.create_client()
|
|
795
|
+
|
|
796
|
+
start = time.monotonic()
|
|
797
|
+
async with await client.request("GET", f"{self.base_uri}/timeout-long"):
|
|
798
|
+
pass
|
|
799
|
+
duration = time.monotonic() - start
|
|
800
|
+
|
|
801
|
+
assert duration > 1
|
|
802
|
+
assert duration < 4
|
|
803
|
+
|
|
804
|
+
async def test_proxy(self) -> None:
|
|
805
|
+
"""The ``proxy`` option is asked for the absolute URL, with its own credentials."""
|
|
806
|
+
client = self.create_client()
|
|
807
|
+
response = await client.request("GET", f"{self.base_uri}/", proxy=self.base_uri)
|
|
808
|
+
|
|
809
|
+
body = await response.to_array()
|
|
810
|
+
assert body["HTTP_HOST"] == f"{_HOST}:{self.port}"
|
|
811
|
+
assert self._is_proxied(body["REQUEST_URI"])
|
|
812
|
+
|
|
813
|
+
response = await client.request(
|
|
814
|
+
"GET",
|
|
815
|
+
f"{self.base_uri}/",
|
|
816
|
+
proxy=f"http://foo:b%3Dar@{_HOST}:{self.port}",
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
body = await response.to_array()
|
|
820
|
+
assert body["HTTP_PROXY_AUTHORIZATION"] == "Basic Zm9vOmI9YXI="
|
|
821
|
+
|
|
822
|
+
response = await client.request("GET", f"{self.base_uri}/301/proxy", proxy=self.base_uri)
|
|
823
|
+
|
|
824
|
+
body = await response.to_array()
|
|
825
|
+
assert body["HTTP_HOST"] == f"{_HOST}:{self.port}"
|
|
826
|
+
assert self._is_proxied(body["REQUEST_URI"])
|
|
827
|
+
|
|
828
|
+
async def test_no_proxy(self) -> None:
|
|
829
|
+
"""``http_proxy`` is obeyed with no option at all, and ``no_proxy`` exempts a host."""
|
|
830
|
+
client = self.create_client()
|
|
831
|
+
|
|
832
|
+
with pytest.MonkeyPatch.context() as environment:
|
|
833
|
+
environment.setenv("http_proxy", self.base_uri)
|
|
834
|
+
|
|
835
|
+
body = await (await client.request("GET", f"{self.base_uri}/")).to_array()
|
|
836
|
+
assert self._is_proxied(body["REQUEST_URI"])
|
|
837
|
+
|
|
838
|
+
environment.setenv("no_proxy", _HOST)
|
|
839
|
+
|
|
840
|
+
body = await (await client.request("GET", f"{self.base_uri}/")).to_array()
|
|
841
|
+
assert body["REQUEST_URI"] == "/"
|
|
842
|
+
|
|
843
|
+
async def test_auto_encoding_request(self) -> None:
|
|
844
|
+
"""A client asks for a compressed body by itself, and hands it over decoded."""
|
|
845
|
+
client = self.create_client()
|
|
846
|
+
response = await client.request("GET", self.base_uri)
|
|
847
|
+
|
|
848
|
+
assert response.get_status_code() == 200
|
|
849
|
+
|
|
850
|
+
assert _header(response, "vary") == ["Accept-Encoding"]
|
|
851
|
+
assert "gzip" in _header(response, "content-encoding")[0]
|
|
852
|
+
|
|
853
|
+
body = await response.to_array()
|
|
854
|
+
|
|
855
|
+
assert "gzip" in _expect(body["HTTP_ACCEPT_ENCODING"], str)
|
|
856
|
+
|
|
857
|
+
async def test_base_uri(self) -> None:
|
|
858
|
+
"""A relative URL is resolved against the base, traversal included."""
|
|
859
|
+
client = self.create_client()
|
|
860
|
+
response = await client.request("GET", "../404", base_uri=f"{self.base_uri}/abc/")
|
|
861
|
+
|
|
862
|
+
assert response.get_status_code() == 404
|
|
863
|
+
assert _header(response, "content-type") == ["application/json"]
|
|
864
|
+
|
|
865
|
+
async def test_query(self) -> None:
|
|
866
|
+
"""Query parameters are merged into the ones the URL already carried."""
|
|
867
|
+
client = self.create_client()
|
|
868
|
+
response = await client.request("GET", f"{self.base_uri}/?a=a", query={"b": "b"})
|
|
869
|
+
|
|
870
|
+
body = await response.to_array()
|
|
871
|
+
assert body["REQUEST_METHOD"] == "GET"
|
|
872
|
+
assert body["REQUEST_URI"] == "/?a=a&b=b"
|
|
873
|
+
|
|
874
|
+
async def test_informational_response(self) -> None:
|
|
875
|
+
"""A 1xx ahead of the real status is read past, not answered with."""
|
|
876
|
+
client = self.create_client()
|
|
877
|
+
response = await client.request("GET", f"{self.base_uri}/103")
|
|
878
|
+
|
|
879
|
+
assert await response.get_content() == b"Here the body"
|
|
880
|
+
assert response.get_status_code() == 200
|
|
881
|
+
|
|
882
|
+
async def test_userland_encoding_request(self) -> None:
|
|
883
|
+
"""Asking for an encoding outright hands the body over as it arrived."""
|
|
884
|
+
client = self.create_client()
|
|
885
|
+
response = await client.request("GET", self.base_uri, headers={"Accept-Encoding": "gzip"})
|
|
886
|
+
|
|
887
|
+
assert _header(response, "vary") == ["Accept-Encoding"]
|
|
888
|
+
assert "gzip" in _header(response, "content-encoding")[0]
|
|
889
|
+
|
|
890
|
+
content = await response.get_content()
|
|
891
|
+
assert content[:1] == b"\x1f"
|
|
892
|
+
|
|
893
|
+
body = _decode_object(gzip.decompress(content))
|
|
894
|
+
assert body["HTTP_ACCEPT_ENCODING"] == "gzip"
|
|
895
|
+
|
|
896
|
+
async def test_gzip_broken(self) -> None:
|
|
897
|
+
"""A body compressed only in name fails the transfer."""
|
|
898
|
+
client = self.create_client()
|
|
899
|
+
response = await client.request("GET", f"{self.base_uri}/gzip-broken")
|
|
900
|
+
|
|
901
|
+
with pytest.raises(TransportError):
|
|
902
|
+
_ = await response.get_content()
|
|
903
|
+
|
|
904
|
+
async def test_max_duration(self) -> None:
|
|
905
|
+
"""A transfer outlasting its whole-request limit ends there, however much arrives."""
|
|
906
|
+
client = self.create_client()
|
|
907
|
+
response = await client.request("GET", f"{self.base_uri}/max-duration", max_duration=0.1)
|
|
908
|
+
|
|
909
|
+
start = time.monotonic()
|
|
910
|
+
|
|
911
|
+
with pytest.raises(TransportError):
|
|
912
|
+
_ = await response.get_content()
|
|
913
|
+
|
|
914
|
+
assert time.monotonic() - start < 10
|
|
915
|
+
|
|
916
|
+
async def test_max_connect_duration(self) -> None:
|
|
917
|
+
"""Connecting is given its own limit, well short of the idle one."""
|
|
918
|
+
client = self.create_client()
|
|
919
|
+
|
|
920
|
+
start = time.monotonic()
|
|
921
|
+
|
|
922
|
+
with pytest.raises(TransportError):
|
|
923
|
+
_ = await client.request(
|
|
924
|
+
"GET",
|
|
925
|
+
"http://10.255.255.1/",
|
|
926
|
+
max_connect_duration=0.1,
|
|
927
|
+
timeout=10,
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
duration = time.monotonic() - start
|
|
931
|
+
|
|
932
|
+
assert duration < 2, "should give up long before the 10s idle timeout"
|
|
933
|
+
assert duration > 0.05, "should take at least some time before giving up"
|
|
934
|
+
|
|
935
|
+
async def test_with_options(self) -> None:
|
|
936
|
+
"""Options become the defaults of a new client, and leave this one as it was."""
|
|
937
|
+
client = self.create_client()
|
|
938
|
+
scoped = client.with_options(base_uri=f"{self.base_uri}/")
|
|
939
|
+
|
|
940
|
+
assert scoped is not client
|
|
941
|
+
assert type(scoped) is type(client)
|
|
942
|
+
|
|
943
|
+
response = await scoped.request("GET", "/")
|
|
944
|
+
assert response.get_status_code() == 200
|
|
945
|
+
|
|
946
|
+
async def test_send_request_prefers_the_request_headers(self) -> None:
|
|
947
|
+
"""A header the request carries is sent; one it lacks is filled from the options."""
|
|
948
|
+
client = self.create_client()
|
|
949
|
+
request = Request("GET", f"{self.base_uri}/", headers={"Foo": "from-request"})
|
|
950
|
+
|
|
951
|
+
response = await client.send_request(
|
|
952
|
+
request,
|
|
953
|
+
headers={"Foo": "from-options", "Bar": "from-options"},
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
body = await response.to_array()
|
|
957
|
+
assert body["HTTP_FOO"] == "from-request"
|
|
958
|
+
assert body["HTTP_BAR"] == "from-options"
|
|
959
|
+
|
|
960
|
+
async def test_send_request_resolves_a_relative_request_uri(self) -> None:
|
|
961
|
+
"""A request whose URI is relative is resolved against ``base_uri``."""
|
|
962
|
+
client = self.create_client()
|
|
963
|
+
|
|
964
|
+
response = await client.send_request(
|
|
965
|
+
Request("GET", "../404"),
|
|
966
|
+
base_uri=f"{self.base_uri}/abc/",
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
assert response.get_status_code() == 404
|
|
970
|
+
|
|
971
|
+
async def test_send_request_merges_the_query_option(self) -> None:
|
|
972
|
+
"""``query`` is merged into the query the request's URI already carried."""
|
|
973
|
+
client = self.create_client()
|
|
974
|
+
|
|
975
|
+
response = await client.send_request(
|
|
976
|
+
Request("GET", f"{self.base_uri}/?a=a"),
|
|
977
|
+
query={"b": "b"},
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
body = await response.to_array()
|
|
981
|
+
assert body["REQUEST_URI"] == "/?a=a&b=b"
|
|
982
|
+
|
|
983
|
+
async def test_send_request_applies_auth_bearer_only_without_one(self) -> None:
|
|
984
|
+
"""``auth_bearer`` fills an Authorization the request has none of, and replaces none."""
|
|
985
|
+
client = self.create_client()
|
|
986
|
+
|
|
987
|
+
response = await client.send_request(
|
|
988
|
+
Request("GET", f"{self.base_uri}/"),
|
|
989
|
+
auth_bearer="from-the-option",
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
assert (await response.to_array())["HTTP_AUTHORIZATION"] == "Bearer from-the-option"
|
|
993
|
+
|
|
994
|
+
response = await client.send_request(
|
|
995
|
+
Request(
|
|
996
|
+
"GET",
|
|
997
|
+
f"{self.base_uri}/",
|
|
998
|
+
headers={"Authorization": "Bearer from-the-request"},
|
|
999
|
+
),
|
|
1000
|
+
auth_bearer="from-the-option",
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
assert (await response.to_array())["HTTP_AUTHORIZATION"] == "Bearer from-the-request"
|
|
1004
|
+
|
|
1005
|
+
async def test_send_request_refuses_a_body_on_both(self) -> None:
|
|
1006
|
+
"""A request carrying a body plus a ``body`` option is a request refused, not merged."""
|
|
1007
|
+
client = self.create_client()
|
|
1008
|
+
request = Request("POST", f"{self.base_uri}/post", body=b"foo=bar")
|
|
1009
|
+
|
|
1010
|
+
with pytest.raises(InvalidArgumentError, match=re.escape(_BOTH_BODIES)):
|
|
1011
|
+
_ = await client.send_request(request, body={"foo": "baz"})
|
|
1012
|
+
|
|
1013
|
+
async def test_request_is_send_request_of_the_same_request(self) -> None:
|
|
1014
|
+
"""``request(method, url)`` answers as ``send_request`` of that method and URL does."""
|
|
1015
|
+
client = self.create_client()
|
|
1016
|
+
|
|
1017
|
+
direct = await (await client.request("GET", f"{self.base_uri}/")).to_array()
|
|
1018
|
+
sent = await (await client.send_request(Request("GET", f"{self.base_uri}/"))).to_array()
|
|
1019
|
+
|
|
1020
|
+
assert _without_client_port(sent) == _without_client_port(direct)
|
|
1021
|
+
|
|
1022
|
+
def _is_proxied(self, request_uri: object) -> bool:
|
|
1023
|
+
"""Whether the server was asked for its own root in absolute form, as a proxy is."""
|
|
1024
|
+
proxied = rf"http://(localhost|127\.0\.0\.1):{self.port}/"
|
|
1025
|
+
return re.fullmatch(proxied, _expect(request_uri, str)) is not None
|
|
1026
|
+
|
|
1027
|
+
@staticmethod
|
|
1028
|
+
async def _expect_timeout(client: HttpClientInterface, port: int) -> None:
|
|
1029
|
+
"""Make a request whose head never arrives, and expect it to say so."""
|
|
1030
|
+
with pytest.raises(TransportError):
|
|
1031
|
+
_ = await client.request(
|
|
1032
|
+
"GET",
|
|
1033
|
+
f"http://{_HOST}:{port}/timeout-header",
|
|
1034
|
+
timeout=0.25,
|
|
1035
|
+
)
|