feat(client): add durable HTTP task state
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user