185 lines
7.4 KiB
Python
185 lines
7.4 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import os
|
||
|
|
import http.client
|
||
|
|
import unittest
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
|
||
|
|
from cmbuyer_client.remote.http_transport import HttpTransport
|
||
|
|
|
||
|
|
|
||
|
|
class FakeResponse:
|
||
|
|
status = 200
|
||
|
|
|
||
|
|
def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None:
|
||
|
|
self.body = body
|
||
|
|
self.headers = headers or [("Content-Type", "application/json")]
|
||
|
|
|
||
|
|
def getheader(self, name: str) -> str | None:
|
||
|
|
return str(len(self.body)) if name == "Content-Length" else None
|
||
|
|
|
||
|
|
def getheaders(self) -> list[tuple[str, str]]:
|
||
|
|
return self.headers
|
||
|
|
|
||
|
|
def read(self, maximum: int) -> bytes:
|
||
|
|
return self.body[:maximum]
|
||
|
|
|
||
|
|
|
||
|
|
class FakeConnection:
|
||
|
|
def __init__(self, host: str, port: int, timeout: int) -> None:
|
||
|
|
self.created = (host, port, timeout)
|
||
|
|
self.calls = 0
|
||
|
|
self.closed = False
|
||
|
|
self.sent_headers: dict[str, str] = {}
|
||
|
|
self.response = FakeResponse()
|
||
|
|
|
||
|
|
def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None:
|
||
|
|
self.calls += 1
|
||
|
|
self.sent_headers = headers
|
||
|
|
|
||
|
|
def getresponse(self) -> FakeResponse:
|
||
|
|
return self.response
|
||
|
|
|
||
|
|
def close(self) -> None:
|
||
|
|
self.closed = True
|
||
|
|
|
||
|
|
|
||
|
|
class HttpTransportTests(unittest.TestCase):
|
||
|
|
def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None:
|
||
|
|
made: list[FakeConnection] = []
|
||
|
|
|
||
|
|
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||
|
|
connection = FakeConnection(*args, **kwargs)
|
||
|
|
made.append(connection)
|
||
|
|
return connection
|
||
|
|
|
||
|
|
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}):
|
||
|
|
result = HttpTransport(connection_factory=factory).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10
|
||
|
|
)
|
||
|
|
self.assertEqual(result.status, 200)
|
||
|
|
self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10))
|
||
|
|
self.assertEqual(made[0].calls, 1)
|
||
|
|
self.assertTrue(made[0].closed)
|
||
|
|
|
||
|
|
for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"):
|
||
|
|
with self.subTest(url=url), self.assertRaises(ProtocolRemoteError):
|
||
|
|
HttpTransport(url)
|
||
|
|
|
||
|
|
def test_network_failure_is_ambiguous_without_retry(self) -> None:
|
||
|
|
class Broken(FakeConnection):
|
||
|
|
def getresponse(self) -> FakeResponse:
|
||
|
|
raise OSError("offline")
|
||
|
|
|
||
|
|
made: list[Broken] = []
|
||
|
|
|
||
|
|
def factory(*args: object, **kwargs: object) -> Broken:
|
||
|
|
connection = Broken(*args, **kwargs)
|
||
|
|
made.append(connection)
|
||
|
|
return connection
|
||
|
|
|
||
|
|
with self.assertRaises(AmbiguousRemoteError):
|
||
|
|
HttpTransport(connection_factory=factory).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10
|
||
|
|
)
|
||
|
|
self.assertEqual(made[0].calls, 1)
|
||
|
|
|
||
|
|
def test_generator_headers_and_content_length_framing(self) -> None:
|
||
|
|
made: list[FakeConnection] = []
|
||
|
|
|
||
|
|
def factory(*args: object, **kwargs: object) -> FakeConnection:
|
||
|
|
connection = FakeConnection(*args, **kwargs)
|
||
|
|
made.append(connection)
|
||
|
|
return connection
|
||
|
|
|
||
|
|
headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json")))
|
||
|
|
HttpTransport(connection_factory=factory).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8
|
||
|
|
)
|
||
|
|
self.assertEqual(made[0].sent_headers["Content-Type"], "application/json")
|
||
|
|
self.assertEqual(made[0].sent_headers["Accept"], "application/json")
|
||
|
|
|
||
|
|
legal = FakeConnection("127.0.0.1", 8080, 10)
|
||
|
|
legal.response = FakeResponse(b"{}", [("Content-Length", "2")])
|
||
|
|
accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||
|
|
)
|
||
|
|
self.assertEqual(accepted.body, b"{}")
|
||
|
|
chunked = FakeConnection("127.0.0.1", 8080, 10)
|
||
|
|
chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")])
|
||
|
|
accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||
|
|
)
|
||
|
|
self.assertEqual(accepted_chunked.body, b"{}")
|
||
|
|
|
||
|
|
cases = (
|
||
|
|
([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'),
|
||
|
|
([('Transfer-Encoding', 'gzip')], b'{}'),
|
||
|
|
([('Transfer-Encoding', ' chunked ')], b'{}'),
|
||
|
|
([('Transfer-Encoding', 'chunked,gzip')], b'{}'),
|
||
|
|
([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'),
|
||
|
|
([("Content-Length", "2"), ("Content-Length", "2")], b"{}"),
|
||
|
|
([("Content-Length", "+2")], b"{}"),
|
||
|
|
([("Content-Length", "-0")], b""),
|
||
|
|
([("Content-Length", "2x")], b"{}"),
|
||
|
|
([("Content-Length", "3")], b"{}"),
|
||
|
|
([("Content-Length", "1")], b"{}"),
|
||
|
|
([("Content-Length", "999")], b"{}"),
|
||
|
|
([], b"0123456789"),
|
||
|
|
)
|
||
|
|
for response_headers, body in cases:
|
||
|
|
with self.subTest(headers=response_headers, body=body):
|
||
|
|
connection = FakeConnection("127.0.0.1", 8080, 10)
|
||
|
|
connection.response = FakeResponse(body, response_headers)
|
||
|
|
with self.assertRaises(AmbiguousRemoteError):
|
||
|
|
HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None:
|
||
|
|
token = ("a" * 64).encode()
|
||
|
|
|
||
|
|
class Incomplete(FakeResponse):
|
||
|
|
def read(self, maximum: int) -> bytes:
|
||
|
|
raise http.client.IncompleteRead(token, 1)
|
||
|
|
|
||
|
|
class Connection(FakeConnection):
|
||
|
|
def getresponse(self) -> FakeResponse:
|
||
|
|
return Incomplete()
|
||
|
|
|
||
|
|
def close(self) -> None:
|
||
|
|
self.closed = True
|
||
|
|
raise OSError("close failed")
|
||
|
|
|
||
|
|
with self.assertRaises(AmbiguousRemoteError) as captured:
|
||
|
|
HttpTransport(connection_factory=Connection).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128
|
||
|
|
)
|
||
|
|
self.assertNotIn(token.decode(), _exception_graph(captured.exception))
|
||
|
|
|
||
|
|
class Timeout(FakeConnection):
|
||
|
|
def getresponse(self) -> FakeResponse:
|
||
|
|
raise TimeoutError("timed out")
|
||
|
|
|
||
|
|
with self.assertRaises(AmbiguousRemoteError):
|
||
|
|
HttpTransport(connection_factory=Timeout).request(
|
||
|
|
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _exception_graph(error: BaseException) -> str:
|
||
|
|
seen: set[int] = set()
|
||
|
|
values: list[str] = []
|
||
|
|
pending: list[object] = [error]
|
||
|
|
while pending:
|
||
|
|
value = pending.pop()
|
||
|
|
if id(value) in seen:
|
||
|
|
continue
|
||
|
|
seen.add(id(value))
|
||
|
|
values.append(repr(value))
|
||
|
|
if isinstance(value, BaseException):
|
||
|
|
pending.extend(item for item in (value.__cause__, value.__context__) if item is not None)
|
||
|
|
pending.extend(value.__dict__.values())
|
||
|
|
return "\n".join(values)
|