| """gRPC-Web framing and status helpers. |
| |
| Encodes data frames for gRPC-Web requests and parses response frames, |
| including trailer extraction and gRPC status code mapping. |
| """ |
|
|
| import base64 |
| import json |
| import re |
| import struct |
| from dataclasses import dataclass |
| from typing import Dict, List, Mapping, Optional, Tuple |
| from urllib.parse import unquote |
|
|
| from app.platform.logging.logger import logger |
|
|
| _B64_RE = re.compile(rb"^[A-Za-z0-9+/=\r\n]+$") |
|
|
| |
| _GRPC_HTTP: Dict[int, int] = { |
| 0: 200, |
| 4: 504, |
| 7: 403, |
| 8: 429, |
| 14: 503, |
| 16: 401, |
| } |
|
|
|
|
| @dataclass(frozen=True) |
| class GrpcStatus: |
| code: int |
| message: str = "" |
|
|
| @property |
| def ok(self) -> bool: |
| return self.code == 0 |
|
|
| @property |
| def http_equiv(self) -> int: |
| return _GRPC_HTTP.get(self.code, 502) |
|
|
|
|
| class GrpcClient: |
| """gRPC-Web framing helpers. |
| |
| All methods are static; instantiate only if you want a namespace handle. |
| """ |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def encode_payload(data: bytes) -> bytes: |
| """Wrap *data* in a gRPC-Web data frame (flag=0x00).""" |
| return b"\x00" + struct.pack(">I", len(data)) + data |
|
|
| |
| |
| |
|
|
| @staticmethod |
| def _maybe_decode_base64(body: bytes, content_type: Optional[str]) -> bytes: |
| ct = (content_type or "").lower() |
| if "grpc-web-text" in ct: |
| return base64.b64decode(b"".join(body.split()), validate=False) |
| head = body[: min(len(body), 2048)] |
| if head and _B64_RE.fullmatch(head): |
| compact = b"".join(body.split()) |
| try: |
| return base64.b64decode(compact, validate=True) |
| except ValueError: |
| pass |
| return body |
|
|
| @staticmethod |
| def _parse_trailers(payload: bytes) -> Dict[str, str]: |
| text = payload.decode("utf-8", errors="replace") |
| result: Dict[str, str] = {} |
| for line in re.split(r"\r\n|\n", text): |
| if ":" not in line: |
| continue |
| k, v = line.split(":", 1) |
| result[k.strip().lower()] = v.strip() |
| if "grpc-message" in result: |
| result["grpc-message"] = unquote(result["grpc-message"]) |
| return result |
|
|
| @classmethod |
| def parse_response( |
| cls, |
| body: bytes, |
| content_type: Optional[str] = None, |
| headers: Optional[Mapping[str, str]] = None, |
| ) -> Tuple[List[bytes], Dict[str, str]]: |
| """Parse a gRPC-Web response body. |
| |
| Returns ``(messages, trailers)`` where *messages* is a list of |
| raw protobuf payloads and *trailers* contains gRPC metadata |
| (including ``grpc-status`` and ``grpc-message``). |
| """ |
| decoded = cls._maybe_decode_base64(body, content_type) |
|
|
| messages: List[bytes] = [] |
| trailers: Dict[str, str] = {} |
|
|
| i, n = 0, len(decoded) |
| while i < n: |
| if n - i < 5: |
| break |
| flag = decoded[i] |
| length = int.from_bytes(decoded[i + 1: i + 5], "big") |
| i += 5 |
| if n - i < length: |
| break |
| payload = decoded[i: i + length] |
| i += length |
|
|
| if flag & 0x80: |
| trailers.update(cls._parse_trailers(payload)) |
| elif flag & 0x01: |
| raise ValueError("grpc-web compressed frame is not supported") |
| else: |
| messages.append(payload) |
|
|
| |
| if headers: |
| lower = {k.lower(): v for k, v in headers.items()} |
| for key in ("grpc-status", "grpc-message"): |
| if key in lower and key not in trailers: |
| val = str(lower[key]).strip() |
| trailers[key] = unquote(val) if key == "grpc-message" else val |
|
|
| raw_code = str(trailers.get("grpc-status", "")).strip() |
| try: |
| code = int(raw_code) |
| except ValueError: |
| code = -1 |
|
|
| if code not in (0, -1): |
| logger.error( |
| "grpc response reported error: grpc_status={} grpc_message={} content_type={}", |
| code, |
| trailers.get("grpc-message", ""), |
| content_type or "", |
| ) |
|
|
| return messages, trailers |
|
|
| @staticmethod |
| def get_status(trailers: Mapping[str, str]) -> GrpcStatus: |
| """Extract ``GrpcStatus`` from parsed trailers.""" |
| raw = str(trailers.get("grpc-status", "")).strip() |
| msg = str(trailers.get("grpc-message", "")).strip() |
| try: |
| code = int(raw) |
| except ValueError: |
| code = -1 |
| return GrpcStatus(code=code, message=msg) |
|
|
|
|
| __all__ = ["GrpcClient", "GrpcStatus"] |
|
|