_client_sync.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. from __future__ import annotations
  2. import functools
  3. from typing import TYPE_CHECKING, Any, Protocol, TypeVar
  4. from urllib.parse import urlencode
  5. from pyqwest import FullResponse, SyncClient, SyncResponse
  6. from pyqwest import Headers as HTTPHeaders
  7. from connectrpc._protocol_grpc import GRPCClientProtocol, GRPCWebClientProtocol
  8. from . import _client_shared
  9. from ._codec import proto_binary_codec
  10. from ._compression import IdentityCompression, _gzip, resolve_compressions
  11. from ._interceptor_sync import (
  12. BidiStreamInterceptorSync,
  13. ClientStreamInterceptorSync,
  14. InterceptorSync,
  15. ServerStreamInterceptorSync,
  16. UnaryInterceptorSync,
  17. resolve_interceptors,
  18. )
  19. from ._protocol import ConnectWireError
  20. from ._protocol_connect import ConnectClientProtocol, ConnectEnvelopeWriter
  21. from ._response_metadata import handle_response_headers
  22. from .code import Code
  23. from .errors import ConnectError
  24. from .protocol import ProtocolType
  25. if TYPE_CHECKING:
  26. import sys
  27. from collections.abc import Iterable, Iterator, Mapping
  28. from types import TracebackType
  29. from ._envelope import EnvelopeReader
  30. from .codec import Codec
  31. from .compression import Compression
  32. from .method import MethodInfo
  33. from .request import Headers, RequestContext
  34. if sys.version_info >= (3, 11):
  35. from typing import Self
  36. else:
  37. from typing_extensions import Self
  38. else:
  39. Self = "Self"
  40. REQ = TypeVar("REQ")
  41. RES = TypeVar("RES")
  42. class _ExecuteUnary(Protocol[REQ, RES]):
  43. def __call__(self, request: REQ, ctx: RequestContext[REQ, RES]) -> RES: ...
  44. class _ExecuteClientStream(Protocol[REQ, RES]):
  45. def __call__(
  46. self, request: Iterator[REQ], ctx: RequestContext[REQ, RES]
  47. ) -> RES: ...
  48. class _ExecuteServerStream(Protocol[REQ, RES]):
  49. def __call__(
  50. self, request: REQ, ctx: RequestContext[REQ, RES]
  51. ) -> Iterator[RES]: ...
  52. class _ExecuteBidiStream(Protocol[REQ, RES]):
  53. def __call__(
  54. self, request: Iterator[REQ], ctx: RequestContext[REQ, RES]
  55. ) -> Iterator[RES]: ...
  56. class ConnectClientSync:
  57. """A synchronous client for the Connect protocol."""
  58. _execute_unary: _ExecuteUnary
  59. _execute_client_stream: _ExecuteClientStream
  60. _execute_server_stream: _ExecuteServerStream
  61. _execute_bidi_stream: _ExecuteBidiStream
  62. def __init__(
  63. self,
  64. address: str,
  65. *,
  66. codec: Codec | None = None,
  67. protocol: ProtocolType = ProtocolType.CONNECT,
  68. accept_compression: Iterable[Compression] | None = None,
  69. send_compression: Compression | None = _gzip,
  70. timeout_ms: int | None = None,
  71. read_max_bytes: int | None = None,
  72. interceptors: Iterable[InterceptorSync] = (),
  73. http_client: SyncClient | None = None,
  74. ) -> None:
  75. """Creates a new synchronous Connect client.
  76. When providing an HTTP client, for example to configure TLS settings,
  77. it is the caller's responsibility to close it.
  78. Examples:
  79. ```python
  80. from pyqwest import SyncClient
  81. from my_service import MyServiceClientSync
  82. with (
  83. SyncClient() as http_client,
  84. MyServiceClientSync("http://localhost:8000", http_client=http_client) as client,
  85. ):
  86. # Use the client!
  87. ```
  88. Args:
  89. address: The address of the server to connect to, including scheme.
  90. codec: The [Codec][] to use for requests. If unset, defaults to binary protobuf.
  91. For JSON encoding, use [proto_json_codec][connectrpc.codec.proto_json_codec].
  92. protocol: The [ProtocolType][] to use for requests.
  93. accept_compression: Compression algorithms to accept from the server. If unset,
  94. defaults to gzip. If set to empty, disables response compression.
  95. send_compression: Compression algorithm to use for sending requests. If unset,
  96. defaults to gzip. If set to None, disables request compression.
  97. timeout_ms: The timeout for requests in milliseconds.
  98. read_max_bytes: The maximum number of bytes to read from the response.
  99. interceptors: A list of interceptors to apply to requests.
  100. http_client: A pyqwest SyncClient to use for requests.
  101. """
  102. self._address = address
  103. self._codec = codec or proto_binary_codec()
  104. self._timeout_ms = timeout_ms
  105. self._read_max_bytes = read_max_bytes
  106. self._response_compressions = resolve_compressions(accept_compression)
  107. self._accept_compression_header = ",".join(self._response_compressions.keys())
  108. self._send_compression = send_compression or IdentityCompression()
  109. if http_client:
  110. self._http_client = http_client
  111. else:
  112. # Use shared default transport if not specified
  113. self._http_client = SyncClient()
  114. self._closed = False
  115. match protocol:
  116. case ProtocolType.CONNECT:
  117. self._protocol = ConnectClientProtocol()
  118. case ProtocolType.GRPC:
  119. self._protocol = GRPCClientProtocol()
  120. case ProtocolType.GRPC_WEB:
  121. self._protocol = GRPCWebClientProtocol()
  122. interceptors = resolve_interceptors(interceptors)
  123. execute_unary = self._send_request_unary
  124. for interceptor in (
  125. i for i in reversed(interceptors) if isinstance(i, UnaryInterceptorSync)
  126. ):
  127. execute_unary = functools.partial(
  128. interceptor.intercept_unary_sync, execute_unary
  129. )
  130. self._execute_unary = execute_unary
  131. execute_client_stream = self._send_request_client_stream
  132. for interceptor in (
  133. i
  134. for i in reversed(interceptors)
  135. if isinstance(i, ClientStreamInterceptorSync)
  136. ):
  137. execute_client_stream = functools.partial(
  138. interceptor.intercept_client_stream_sync, execute_client_stream
  139. )
  140. self._execute_client_stream = execute_client_stream
  141. execute_server_stream: _ExecuteServerStream = self._send_request_server_stream
  142. for interceptor in (
  143. i
  144. for i in reversed(interceptors)
  145. if isinstance(i, ServerStreamInterceptorSync)
  146. ):
  147. execute_server_stream = functools.partial(
  148. interceptor.intercept_server_stream_sync, execute_server_stream
  149. )
  150. self._execute_server_stream = execute_server_stream
  151. execute_bidi_stream = self._send_request_bidi_stream
  152. for interceptor in (
  153. i
  154. for i in reversed(interceptors)
  155. if isinstance(i, BidiStreamInterceptorSync)
  156. ):
  157. execute_bidi_stream = functools.partial(
  158. interceptor.intercept_bidi_stream_sync, execute_bidi_stream
  159. )
  160. self._execute_bidi_stream = execute_bidi_stream
  161. def close(self) -> None:
  162. """Close the client. After closing, the client cannot be used to make requests."""
  163. if not self._closed:
  164. self._closed = True
  165. def __enter__(self) -> Self:
  166. return self
  167. def __exit__(
  168. self,
  169. _exc_type: type[BaseException] | None,
  170. _exc_value: BaseException | None,
  171. _traceback: TracebackType | None,
  172. ) -> None:
  173. self.close()
  174. def execute_unary(
  175. self,
  176. *,
  177. request: REQ,
  178. method: MethodInfo[REQ, RES],
  179. headers: Headers | Mapping[str, str] | None = None,
  180. timeout_ms: int | None = None,
  181. use_get: bool = False,
  182. ) -> RES:
  183. ctx = self._protocol.create_request_context(
  184. method=method,
  185. url=self._address,
  186. http_method="GET" if use_get else "POST",
  187. user_headers=headers,
  188. timeout_ms=timeout_ms or self._timeout_ms,
  189. codec=self._codec,
  190. stream=False,
  191. accept_compression=self._accept_compression_header,
  192. send_compression=self._send_compression,
  193. )
  194. return self._execute_unary(request, ctx)
  195. def execute_client_stream(
  196. self,
  197. *,
  198. request: Iterator[REQ],
  199. method: MethodInfo[REQ, RES],
  200. headers: Headers | Mapping[str, str] | None = None,
  201. timeout_ms: int | None = None,
  202. ) -> RES:
  203. ctx = self._protocol.create_request_context(
  204. method=method,
  205. url=self._address,
  206. http_method="POST",
  207. user_headers=headers,
  208. timeout_ms=timeout_ms or self._timeout_ms,
  209. codec=self._codec,
  210. stream=True,
  211. accept_compression=self._accept_compression_header,
  212. send_compression=self._send_compression,
  213. )
  214. return self._execute_client_stream(request, ctx)
  215. def execute_server_stream(
  216. self,
  217. *,
  218. request: REQ,
  219. method: MethodInfo[REQ, RES],
  220. headers: Headers | Mapping[str, str] | None = None,
  221. timeout_ms: int | None = None,
  222. ) -> Iterator[RES]:
  223. ctx = self._protocol.create_request_context(
  224. method=method,
  225. url=self._address,
  226. http_method="POST",
  227. user_headers=headers,
  228. timeout_ms=timeout_ms or self._timeout_ms,
  229. codec=self._codec,
  230. stream=True,
  231. accept_compression=self._accept_compression_header,
  232. send_compression=self._send_compression,
  233. )
  234. return self._execute_server_stream(request, ctx)
  235. def execute_bidi_stream(
  236. self,
  237. *,
  238. request: Iterator[REQ],
  239. method: MethodInfo[REQ, RES],
  240. headers: Headers | Mapping[str, str] | None = None,
  241. timeout_ms: int | None = None,
  242. ) -> Iterator[RES]:
  243. ctx = self._protocol.create_request_context(
  244. method=method,
  245. url=self._address,
  246. http_method="POST",
  247. user_headers=headers,
  248. timeout_ms=timeout_ms or self._timeout_ms,
  249. codec=self._codec,
  250. stream=True,
  251. accept_compression=self._accept_compression_header,
  252. send_compression=self._send_compression,
  253. )
  254. return self._execute_bidi_stream(request, ctx)
  255. def _send_request_unary(self, request: REQ, ctx: RequestContext[REQ, RES]) -> RES:
  256. if isinstance(self._protocol, GRPCClientProtocol):
  257. return _consume_single_response(
  258. self._send_request_bidi_stream(iter([request]), ctx)
  259. )
  260. request_headers = HTTPHeaders(ctx.request_headers.allitems())
  261. url = f"{self._address}/{ctx.method.service_name}/{ctx.method.name}"
  262. if (timeout_ms := ctx.timeout_ms) is not None:
  263. timeout_s = timeout_ms / 1000.0
  264. else:
  265. timeout_s = None
  266. try:
  267. request_data = self._codec.encode(request)
  268. if self._send_compression:
  269. request_data = self._send_compression.compress(request_data)
  270. if ctx.http_method == "GET":
  271. params = _client_shared.prepare_get_params(
  272. self._codec, request_data, request_headers
  273. )
  274. params_str = urlencode(params)
  275. url = f"{url}?{params_str}"
  276. request_headers.pop("content-type", None)
  277. resp = self._http_client.get(
  278. url=url, headers=request_headers, timeout=timeout_s
  279. )
  280. else:
  281. resp = self._http_client.post(
  282. url=url,
  283. headers=request_headers,
  284. content=request_data,
  285. timeout=timeout_s,
  286. )
  287. self._protocol.validate_response(
  288. self._codec.name(), resp.status, resp.headers.get("content-type", "")
  289. )
  290. # Decompression itself is handled by pyqwest, but we validate it
  291. # by resolving it.
  292. self._protocol.handle_response_compression(
  293. resp.headers, self._response_compressions, stream=False
  294. )
  295. handle_response_headers(resp.headers)
  296. if resp.status == 200:
  297. if (
  298. self._read_max_bytes is not None
  299. and len(resp.content) > self._read_max_bytes
  300. ):
  301. raise ConnectError(
  302. Code.RESOURCE_EXHAUSTED,
  303. f"message is larger than configured max {self._read_max_bytes}",
  304. )
  305. return self._codec.decode(resp.content, ctx.method.output)
  306. raise ConnectWireError.from_response(resp).to_exception()
  307. except TimeoutError as e:
  308. raise ConnectError(Code.DEADLINE_EXCEEDED, "Request timed out") from e
  309. except ConnectError:
  310. raise
  311. except Exception as e:
  312. raise ConnectError(Code.UNAVAILABLE, str(e)) from e
  313. def _send_request_client_stream(
  314. self, request: Iterator[REQ], ctx: RequestContext[REQ, RES]
  315. ) -> RES:
  316. return _consume_single_response(self._send_request_bidi_stream(request, ctx))
  317. def _send_request_server_stream(
  318. self, request: REQ, ctx: RequestContext[REQ, RES]
  319. ) -> Iterator[RES]:
  320. return self._send_request_bidi_stream(iter([request]), ctx)
  321. def _send_request_bidi_stream(
  322. self, request: Iterator[REQ], ctx: RequestContext[REQ, RES]
  323. ) -> Iterator[RES]:
  324. request_headers = HTTPHeaders(ctx.request_headers.allitems())
  325. url = f"{self._address}/{ctx.method.service_name}/{ctx.method.name}"
  326. if (timeout_ms := ctx.timeout_ms) is not None:
  327. timeout_s = timeout_ms / 1000.0
  328. else:
  329. timeout_s = None
  330. stream_error: Exception | None = None
  331. reader: EnvelopeReader | None = None
  332. resp: SyncResponse | None = None
  333. try:
  334. request_data = _streaming_request_content(
  335. request, self._codec, self._send_compression
  336. )
  337. with self._http_client.stream(
  338. method="POST",
  339. url=url,
  340. headers=request_headers,
  341. content=request_data,
  342. timeout=timeout_s,
  343. ) as resp:
  344. handle_response_headers(resp.headers)
  345. if resp.status == 200:
  346. self._protocol.validate_stream_response(
  347. self._codec.name(), resp.headers.get("content-type", "")
  348. )
  349. compression = self._protocol.handle_response_compression(
  350. resp.headers, self._response_compressions, stream=True
  351. )
  352. reader = self._protocol.create_envelope_reader(
  353. ctx.method.output,
  354. self._codec,
  355. compression,
  356. self._read_max_bytes,
  357. )
  358. try:
  359. for chunk in resp.content:
  360. yield from reader.feed(chunk)
  361. except ConnectError as e:
  362. stream_error = e
  363. raise
  364. # For sync, we rely on the HTTP client to handle timeout, but
  365. # currently the one we use for gRPC does not propagate RST_STREAM
  366. # correctly which is used for server timeouts. We go ahead and check
  367. # the timeout ourselves too.
  368. # https://github.com/hyperium/hyper/issues/3681#issuecomment-3734084436
  369. if (t := ctx.timeout_ms) is not None and t <= 0:
  370. raise TimeoutError
  371. reader.handle_response_complete(resp)
  372. else:
  373. content = bytearray()
  374. for chunk in resp.content:
  375. content.extend(chunk)
  376. fres = FullResponse(
  377. status=resp.status,
  378. headers=resp.headers,
  379. content=bytes(content),
  380. trailers=resp.trailers,
  381. )
  382. raise ConnectWireError.from_response(fres).to_exception()
  383. except TimeoutError as e:
  384. raise ConnectError(Code.DEADLINE_EXCEEDED, "Request timed out") from e
  385. except ConnectError:
  386. raise
  387. except Exception as e:
  388. # If a context manager's exit raises an exception, it overwrites any raised
  389. # by our own stream handling. This seems to happen when we end the response without
  390. # fully consuming it due to message limits. It should always be fine to prioritize
  391. # the stream error here.
  392. if stream_error is not None:
  393. raise stream_error from None
  394. if rst_err := _client_shared.maybe_map_stream_reset(e, ctx):
  395. # It is possible for a reset to come with trailers which should
  396. # be used.
  397. if reader and resp:
  398. reader.handle_response_complete(resp, rst_err)
  399. raise rst_err from e
  400. raise ConnectError(Code.UNAVAILABLE, str(e)) from e
  401. def _streaming_request_content(
  402. msgs: Iterator[Any], codec: Codec, compression: Compression | None
  403. ) -> Iterator[bytes]:
  404. writer = ConnectEnvelopeWriter(codec, compression)
  405. for msg in msgs:
  406. yield writer.write(msg)
  407. def _consume_single_response(stream: Iterator[RES]) -> RES:
  408. res = None
  409. for message in stream:
  410. if res is not None:
  411. raise ConnectError(
  412. Code.UNIMPLEMENTED, "unary response has multiple messages"
  413. )
  414. res = message
  415. if res is None:
  416. raise ConnectError(Code.UNIMPLEMENTED, "unary response has zero messages")
  417. return res