from __future__ import annotations import json from base64 import b64decode, b64encode from dataclasses import dataclass from http import HTTPStatus from typing import TYPE_CHECKING, Protocol, TypeVar, cast from protobuf import message_to_json_value from protobuf.wkt import Any from ._compression import Compression from .code import Code from .errors import ConnectError, ErrorDetail if TYPE_CHECKING: from collections.abc import Mapping, Sequence from pyqwest import FullResponse from pyqwest import Headers as HTTPHeaders from ._codec import Codec from ._compression import Compression from ._envelope import EnvelopeReader, EnvelopeWriter from .method import MethodInfo from .request import Headers, RequestContext REQ = TypeVar("REQ") RES = TypeVar("RES") T = TypeVar("T") # Define a custom class for HTTP Status to allow adding 499 status code @dataclass(frozen=True) class ExtendedHTTPStatus: code: int reason: str @staticmethod def from_http_status(status: HTTPStatus) -> ExtendedHTTPStatus: return ExtendedHTTPStatus(code=status.value, reason=status.phrase) # Dedupe statuses that are mapped multiple times _BAD_REQUEST = ExtendedHTTPStatus.from_http_status(HTTPStatus.BAD_REQUEST) _CONFLICT = ExtendedHTTPStatus.from_http_status(HTTPStatus.CONFLICT) _INTERNAL_SERVER_ERROR = ExtendedHTTPStatus.from_http_status( HTTPStatus.INTERNAL_SERVER_ERROR ) _error_to_http_status = { Code.CANCELED: ExtendedHTTPStatus(499, "Client Closed Request"), Code.UNKNOWN: _INTERNAL_SERVER_ERROR, Code.INVALID_ARGUMENT: _BAD_REQUEST, Code.DEADLINE_EXCEEDED: ExtendedHTTPStatus.from_http_status( HTTPStatus.GATEWAY_TIMEOUT ), Code.NOT_FOUND: ExtendedHTTPStatus.from_http_status(HTTPStatus.NOT_FOUND), Code.ALREADY_EXISTS: _CONFLICT, Code.PERMISSION_DENIED: ExtendedHTTPStatus.from_http_status(HTTPStatus.FORBIDDEN), Code.RESOURCE_EXHAUSTED: ExtendedHTTPStatus.from_http_status( HTTPStatus.TOO_MANY_REQUESTS ), Code.FAILED_PRECONDITION: _BAD_REQUEST, Code.ABORTED: _CONFLICT, Code.OUT_OF_RANGE: _BAD_REQUEST, Code.UNIMPLEMENTED: ExtendedHTTPStatus.from_http_status(HTTPStatus.NOT_IMPLEMENTED), Code.INTERNAL: _INTERNAL_SERVER_ERROR, Code.UNAVAILABLE: ExtendedHTTPStatus.from_http_status( HTTPStatus.SERVICE_UNAVAILABLE ), Code.DATA_LOSS: _INTERNAL_SERVER_ERROR, Code.UNAUTHENTICATED: ExtendedHTTPStatus.from_http_status(HTTPStatus.UNAUTHORIZED), } _http_status_code_to_error = { 400: Code.INTERNAL, 401: Code.UNAUTHENTICATED, 403: Code.PERMISSION_DENIED, 404: Code.UNIMPLEMENTED, 429: Code.UNAVAILABLE, 502: Code.UNAVAILABLE, 503: Code.UNAVAILABLE, 504: Code.UNAVAILABLE, } @dataclass(frozen=True) class ConnectWireError: code: Code message: str details: Sequence[ErrorDetail] @staticmethod def from_exception(exc: Exception) -> ConnectWireError: if isinstance(exc, ConnectError): return ConnectWireError(exc.code, exc.message, exc.details) return ConnectWireError(Code.UNKNOWN, str(exc), details=()) @staticmethod def from_response(response: FullResponse) -> ConnectWireError: try: data = response.json() except Exception: data = None if isinstance(data, dict): return ConnectWireError.from_dict(data, response.status, Code.UNAVAILABLE) return ConnectWireError.from_http_status(response.status) @staticmethod def from_dict( data: dict, http_status: int, unexpected_code: Code ) -> ConnectWireError: code_str = data.get("code") if code_str: try: code = Code(code_str) except ValueError: code = unexpected_code else: code = _http_status_code_to_error.get(http_status, Code.UNKNOWN) message = data.get("message", "") details: Sequence[ErrorDetail] = () details_json = cast("list[dict[str, str]] | None", data.get("details")) if details_json: details = [] for detail in details_json: detail_type = detail.get("type") detail_value = detail.get("value") if detail_type is None or detail_value is None: # Ignore malformed details continue details.append( ErrorDetail( Any( type_url="type.googleapis.com/" + detail_type, value=b64decode(detail_value + "==="), ) ) ) return ConnectWireError(code, message, details) @staticmethod def from_http_status(status_code: int) -> ConnectWireError: code = _http_status_code_to_error.get(status_code, Code.UNKNOWN) try: http_status = HTTPStatus(status_code) message = http_status.phrase except ValueError: message = "Client Closed Request" if status_code == 499 else "" return ConnectWireError(code, message, details=()) def to_exception(self) -> ConnectError: return ConnectError(self.code, self.message, details=self.details) def to_http_status(self) -> ExtendedHTTPStatus: return _error_to_http_status.get(self.code, _INTERNAL_SERVER_ERROR) def to_dict(self) -> dict: data: dict = {"code": self.code.value, "message": self.message} if self.details: details: list[dict] = [] for detail in self.details: detail_dict: dict = { "type": detail.type_name, # Connect requires unpadded base64 "value": b64encode(detail.message_bytes) .decode("utf-8") .rstrip("="), } # Try to produce debug info, but expect failure when we don't # have descriptors for the message type. if debug := detail.value(): try: debug_value = message_to_json_value(debug) except Exception: # noqa: S110 pass else: detail_dict["debug"] = debug_value details.append(detail_dict) data["details"] = details return data def to_json_bytes(self) -> bytes: return json.dumps(self.to_dict()).encode("utf-8") class ServerProtocol(Protocol): def create_request_context( self, method: MethodInfo[REQ, RES], http_method: str, http_scheme: str, headers: Headers, client_address: str | None = None, ) -> RequestContext[REQ, RES]: """Creates a RequestContext from the HTTP method and headers.""" ... def create_envelope_writer( self, codec: Codec[T, Any], compression: Compression | None ) -> EnvelopeWriter[T]: """Creates the EnvelopeWriter to write response messages.""" ... def uses_trailers(self) -> bool: """Returns whether the protocol uses trailers for status reporting.""" ... def content_type(self, codec: Codec) -> str: """Returns the content type for the given codec.""" ... def compression_header_name(self) -> str: """Returns the compression header name and value.""" ... def codec_name_from_content_type(self, content_type: str, *, stream: bool) -> str: """Extracts the codec name from the content type.""" ... def negotiate_stream_compression( self, headers: Headers, compressions: dict[str, Compression] ) -> tuple[Compression | None, Compression]: """Negotiates request and response compression based on headers.""" ... class ClientProtocol(Protocol): def create_request_context( self, *, method: MethodInfo[REQ, RES], address: str, http_method: str, user_headers: Headers | Mapping[str, str] | None, timeout_ms: int | None, codec: Codec, stream: bool, accept_compression: str, send_compression: Compression | None, ) -> RequestContext[REQ, RES]: """Creates a RequestContext for the given method and headers.""" ... def validate_response( self, request_codec_name: str, status_code: int, response_content_type: str ) -> None: """Validates a unary response""" ... def validate_stream_response( self, request_codec_name: str, response_content_type: str ) -> None: """Validates a streaming response""" ... def handle_response_compression( self, headers: HTTPHeaders, *, stream: bool ) -> Compression: """Handles response compression based on the response headers.""" ... def create_envelope_reader( self, message_class: type[RES], codec: Codec, compression: Compression, read_max_bytes: int | None, ) -> EnvelopeReader[RES]: """Creates the EnvelopeReader to read response messages.""" ... class HTTPException(Exception): """An HTTP exception returned directly before starting the connect protocol.""" def __init__(self, status: HTTPStatus, headers: list[tuple[str, str]]) -> None: self.status = status self.headers = headers def host_to_server_address(host: str | None, http_scheme: str) -> str | None: if host is None: return None if ":" not in host: match http_scheme: case "https": host += ":443" case "http": host += ":80" return host def url_to_server_address(address: str) -> str | None: if address.startswith("https://"): scheme = "https" address = address[len("https://") :] else: scheme = "http" address = address[len("http://") :] return host_to_server_address(address, scheme)