_protocol.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. from __future__ import annotations
  2. import json
  3. from base64 import b64decode, b64encode
  4. from dataclasses import dataclass
  5. from http import HTTPStatus
  6. from typing import TYPE_CHECKING, Protocol, TypeVar, cast
  7. from protobuf import message_to_json_value
  8. from protobuf.wkt import Any
  9. from ._compression import Compression
  10. from .code import Code
  11. from .errors import ConnectError, ErrorDetail
  12. if TYPE_CHECKING:
  13. from collections.abc import Mapping, Sequence
  14. from pyqwest import FullResponse
  15. from pyqwest import Headers as HTTPHeaders
  16. from ._codec import Codec
  17. from ._compression import Compression
  18. from ._envelope import EnvelopeReader, EnvelopeWriter
  19. from .method import MethodInfo
  20. from .request import Headers, RequestContext
  21. REQ = TypeVar("REQ")
  22. RES = TypeVar("RES")
  23. T = TypeVar("T")
  24. # Define a custom class for HTTP Status to allow adding 499 status code
  25. @dataclass(frozen=True)
  26. class ExtendedHTTPStatus:
  27. code: int
  28. reason: str
  29. @staticmethod
  30. def from_http_status(status: HTTPStatus) -> ExtendedHTTPStatus:
  31. return ExtendedHTTPStatus(code=status.value, reason=status.phrase)
  32. # Dedupe statuses that are mapped multiple times
  33. _BAD_REQUEST = ExtendedHTTPStatus.from_http_status(HTTPStatus.BAD_REQUEST)
  34. _CONFLICT = ExtendedHTTPStatus.from_http_status(HTTPStatus.CONFLICT)
  35. _INTERNAL_SERVER_ERROR = ExtendedHTTPStatus.from_http_status(
  36. HTTPStatus.INTERNAL_SERVER_ERROR
  37. )
  38. _error_to_http_status = {
  39. Code.CANCELED: ExtendedHTTPStatus(499, "Client Closed Request"),
  40. Code.UNKNOWN: _INTERNAL_SERVER_ERROR,
  41. Code.INVALID_ARGUMENT: _BAD_REQUEST,
  42. Code.DEADLINE_EXCEEDED: ExtendedHTTPStatus.from_http_status(
  43. HTTPStatus.GATEWAY_TIMEOUT
  44. ),
  45. Code.NOT_FOUND: ExtendedHTTPStatus.from_http_status(HTTPStatus.NOT_FOUND),
  46. Code.ALREADY_EXISTS: _CONFLICT,
  47. Code.PERMISSION_DENIED: ExtendedHTTPStatus.from_http_status(HTTPStatus.FORBIDDEN),
  48. Code.RESOURCE_EXHAUSTED: ExtendedHTTPStatus.from_http_status(
  49. HTTPStatus.TOO_MANY_REQUESTS
  50. ),
  51. Code.FAILED_PRECONDITION: _BAD_REQUEST,
  52. Code.ABORTED: _CONFLICT,
  53. Code.OUT_OF_RANGE: _BAD_REQUEST,
  54. Code.UNIMPLEMENTED: ExtendedHTTPStatus.from_http_status(HTTPStatus.NOT_IMPLEMENTED),
  55. Code.INTERNAL: _INTERNAL_SERVER_ERROR,
  56. Code.UNAVAILABLE: ExtendedHTTPStatus.from_http_status(
  57. HTTPStatus.SERVICE_UNAVAILABLE
  58. ),
  59. Code.DATA_LOSS: _INTERNAL_SERVER_ERROR,
  60. Code.UNAUTHENTICATED: ExtendedHTTPStatus.from_http_status(HTTPStatus.UNAUTHORIZED),
  61. }
  62. _http_status_code_to_error = {
  63. 400: Code.INTERNAL,
  64. 401: Code.UNAUTHENTICATED,
  65. 403: Code.PERMISSION_DENIED,
  66. 404: Code.UNIMPLEMENTED,
  67. 429: Code.UNAVAILABLE,
  68. 502: Code.UNAVAILABLE,
  69. 503: Code.UNAVAILABLE,
  70. 504: Code.UNAVAILABLE,
  71. }
  72. @dataclass(frozen=True)
  73. class ConnectWireError:
  74. code: Code
  75. message: str
  76. details: Sequence[ErrorDetail]
  77. @staticmethod
  78. def from_exception(exc: Exception) -> ConnectWireError:
  79. if isinstance(exc, ConnectError):
  80. return ConnectWireError(exc.code, exc.message, exc.details)
  81. return ConnectWireError(Code.UNKNOWN, str(exc), details=())
  82. @staticmethod
  83. def from_response(response: FullResponse) -> ConnectWireError:
  84. try:
  85. data = response.json()
  86. except Exception:
  87. data = None
  88. if isinstance(data, dict):
  89. return ConnectWireError.from_dict(data, response.status, Code.UNAVAILABLE)
  90. return ConnectWireError.from_http_status(response.status)
  91. @staticmethod
  92. def from_dict(
  93. data: dict, http_status: int, unexpected_code: Code
  94. ) -> ConnectWireError:
  95. code_str = data.get("code")
  96. if code_str:
  97. try:
  98. code = Code(code_str)
  99. except ValueError:
  100. code = unexpected_code
  101. else:
  102. code = _http_status_code_to_error.get(http_status, Code.UNKNOWN)
  103. message = data.get("message", "")
  104. details: Sequence[ErrorDetail] = ()
  105. details_json = cast("list[dict[str, str]] | None", data.get("details"))
  106. if details_json:
  107. details = []
  108. for detail in details_json:
  109. detail_type = detail.get("type")
  110. detail_value = detail.get("value")
  111. if detail_type is None or detail_value is None:
  112. # Ignore malformed details
  113. continue
  114. details.append(
  115. ErrorDetail(
  116. Any(
  117. type_url="type.googleapis.com/" + detail_type,
  118. value=b64decode(detail_value + "==="),
  119. )
  120. )
  121. )
  122. return ConnectWireError(code, message, details)
  123. @staticmethod
  124. def from_http_status(status_code: int) -> ConnectWireError:
  125. code = _http_status_code_to_error.get(status_code, Code.UNKNOWN)
  126. try:
  127. http_status = HTTPStatus(status_code)
  128. message = http_status.phrase
  129. except ValueError:
  130. message = "Client Closed Request" if status_code == 499 else ""
  131. return ConnectWireError(code, message, details=())
  132. def to_exception(self) -> ConnectError:
  133. return ConnectError(self.code, self.message, details=self.details)
  134. def to_http_status(self) -> ExtendedHTTPStatus:
  135. return _error_to_http_status.get(self.code, _INTERNAL_SERVER_ERROR)
  136. def to_dict(self) -> dict:
  137. data: dict = {"code": self.code.value, "message": self.message}
  138. if self.details:
  139. details: list[dict] = []
  140. for detail in self.details:
  141. detail_dict: dict = {
  142. "type": detail.type_name,
  143. # Connect requires unpadded base64
  144. "value": b64encode(detail.message_bytes)
  145. .decode("utf-8")
  146. .rstrip("="),
  147. }
  148. # Try to produce debug info, but expect failure when we don't
  149. # have descriptors for the message type.
  150. if debug := detail.value():
  151. try:
  152. debug_value = message_to_json_value(debug)
  153. except Exception: # noqa: S110
  154. pass
  155. else:
  156. detail_dict["debug"] = debug_value
  157. details.append(detail_dict)
  158. data["details"] = details
  159. return data
  160. def to_json_bytes(self) -> bytes:
  161. return json.dumps(self.to_dict()).encode("utf-8")
  162. class ServerProtocol(Protocol):
  163. def create_request_context(
  164. self,
  165. method: MethodInfo[REQ, RES],
  166. http_method: str,
  167. http_scheme: str,
  168. headers: Headers,
  169. client_address: str | None = None,
  170. ) -> RequestContext[REQ, RES]:
  171. """Creates a RequestContext from the HTTP method and headers."""
  172. ...
  173. def create_envelope_writer(
  174. self, codec: Codec[T, Any], compression: Compression | None
  175. ) -> EnvelopeWriter[T]:
  176. """Creates the EnvelopeWriter to write response messages."""
  177. ...
  178. def uses_trailers(self) -> bool:
  179. """Returns whether the protocol uses trailers for status reporting."""
  180. ...
  181. def content_type(self, codec: Codec) -> str:
  182. """Returns the content type for the given codec."""
  183. ...
  184. def compression_header_name(self) -> str:
  185. """Returns the compression header name and value."""
  186. ...
  187. def codec_name_from_content_type(self, content_type: str, *, stream: bool) -> str:
  188. """Extracts the codec name from the content type."""
  189. ...
  190. def negotiate_stream_compression(
  191. self, headers: Headers, compressions: dict[str, Compression]
  192. ) -> tuple[Compression | None, Compression]:
  193. """Negotiates request and response compression based on headers."""
  194. ...
  195. class ClientProtocol(Protocol):
  196. def create_request_context(
  197. self,
  198. *,
  199. method: MethodInfo[REQ, RES],
  200. address: str,
  201. http_method: str,
  202. user_headers: Headers | Mapping[str, str] | None,
  203. timeout_ms: int | None,
  204. codec: Codec,
  205. stream: bool,
  206. accept_compression: str,
  207. send_compression: Compression | None,
  208. ) -> RequestContext[REQ, RES]:
  209. """Creates a RequestContext for the given method and headers."""
  210. ...
  211. def validate_response(
  212. self, request_codec_name: str, status_code: int, response_content_type: str
  213. ) -> None:
  214. """Validates a unary response"""
  215. ...
  216. def validate_stream_response(
  217. self, request_codec_name: str, response_content_type: str
  218. ) -> None:
  219. """Validates a streaming response"""
  220. ...
  221. def handle_response_compression(
  222. self, headers: HTTPHeaders, *, stream: bool
  223. ) -> Compression:
  224. """Handles response compression based on the response headers."""
  225. ...
  226. def create_envelope_reader(
  227. self,
  228. message_class: type[RES],
  229. codec: Codec,
  230. compression: Compression,
  231. read_max_bytes: int | None,
  232. ) -> EnvelopeReader[RES]:
  233. """Creates the EnvelopeReader to read response messages."""
  234. ...
  235. class HTTPException(Exception):
  236. """An HTTP exception returned directly before starting the connect protocol."""
  237. def __init__(self, status: HTTPStatus, headers: list[tuple[str, str]]) -> None:
  238. self.status = status
  239. self.headers = headers
  240. def host_to_server_address(host: str | None, http_scheme: str) -> str | None:
  241. if host is None:
  242. return None
  243. if ":" not in host:
  244. match http_scheme:
  245. case "https":
  246. host += ":443"
  247. case "http":
  248. host += ":80"
  249. return host
  250. def url_to_server_address(address: str) -> str | None:
  251. if address.startswith("https://"):
  252. scheme = "https"
  253. address = address[len("https://") :]
  254. else:
  255. scheme = "http"
  256. address = address[len("http://") :]
  257. return host_to_server_address(address, scheme)