Compare commits

...

7 Commits

Author SHA1 Message Date
Jonatan Nevo 88f360a47d
Merge 7e2ed1a412 into 62f968a4c8 2024-08-30 12:03:57 +10:00
Vladimir Magamedov 62f968a4c8 Updated changelog, issued 0.4.8rc2 release candidate 2024-07-24 23:09:16 +03:00
Vladimir Magamedov e9adb679ec Fixed Channel to construct valid authority header when host is the IPv6 address, closes #197 2024-07-21 21:34:11 +03:00
Jonatan Nevo 7e2ed1a412 add kwargs to HTTPDetails 2023-02-22 10:44:24 +02:00
Jonatan Nevo afe3af9bac fix bug with passing incorrect status to GRPCError 2023-02-22 10:36:14 +02:00
Jonatan Nevo db09b3d4ae moved http details to dataclass 2023-02-22 10:19:38 +02:00
Jonatan Nevo a1d1bb4ad0 add http headers to GRPCError 2023-02-21 18:01:45 +02:00
5 changed files with 81 additions and 27 deletions

View File

@ -4,6 +4,9 @@ Changelog
0.4.8
~~~~~
- Fixed ``authority`` header for the case when Channel's ``host`` argument
is the IPv6 address
- Fixed ``Channel`` for the case when ``ssl`` module is not available
- Dropped Python 3.7 support
- Added "wheel" packaging format

View File

@ -1,7 +1,7 @@
from .const import Status
from .exceptions import GRPCError
__version__ = '0.4.8rc1'
__version__ = '0.4.8rc2'
__all__ = (
'Status',

View File

@ -4,6 +4,7 @@ import http
import time
import asyncio
import warnings
import ipaddress
from types import TracebackType
from typing import Generic, Optional, Union, Type, List, Sequence, Any, cast
@ -27,7 +28,7 @@ from .protocol import H2Protocol, AbstractHandler, Stream as _Stream, Peer
from .metadata import Deadline, USER_AGENT, decode_grpc_message, encode_timeout
from .metadata import encode_metadata, decode_metadata, _MetadataLike, _Metadata
from .metadata import _STATUS_DETAILS_KEY, decode_bin_value
from .exceptions import GRPCError, ProtocolError, StreamTerminatedError
from .exceptions import GRPCError, ProtocolError, StreamTerminatedError, HTTPDetails
from .encoding.base import GRPC_CONTENT_TYPE, CodecBase, StatusDetailsCodecBase
from .encoding.proto import ProtoCodec, ProtoStatusDetailsCodec
from .encoding.proto import _googleapis_available
@ -294,13 +295,15 @@ class Stream(StreamIterator[_RecvType], Generic[_SendType, _RecvType]):
if status is not None and status != _H2_OK:
grpc_status = _H2_TO_GRPC_STATUS_MAP.get(status, Status.UNKNOWN)
raise GRPCError(grpc_status,
'Received :status = {!r}'.format(status))
'Received :status = {!r}'.format(status),
http_details=HTTPDetails(status, headers_map))
def _raise_for_content_type(self, headers_map: Dict[str, str]) -> None:
content_type = headers_map.get('content-type')
if content_type is None:
raise GRPCError(Status.UNKNOWN,
'Missing content-type header')
'Missing content-type header',
http_details=HTTPDetails(headers_map.get(":status"), headers_map))
base_content_type, _, sub_type = content_type.partition('+')
sub_type = sub_type or ProtoCodec.__content_subtype__
@ -310,19 +313,23 @@ class Stream(StreamIterator[_RecvType], Generic[_SendType, _RecvType]):
):
raise GRPCError(Status.UNKNOWN,
'Invalid content-type: {!r}'
.format(content_type))
.format(content_type),
http_details=HTTPDetails(headers_map.get(":status"), headers_map))
def _process_grpc_status(
self, headers_map: Dict[str, str],
) -> Tuple[Status, Optional[str], Any]:
grpc_status = headers_map.get('grpc-status')
if grpc_status is None:
raise GRPCError(Status.UNKNOWN, 'Missing grpc-status header')
raise GRPCError(Status.UNKNOWN,
'Missing grpc-status header',
http_details=HTTPDetails(headers_map.get(":status"), headers_map))
try:
status = Status(int(grpc_status))
except ValueError:
raise GRPCError(Status.UNKNOWN, ('Invalid grpc-status: {!r}'
.format(grpc_status)))
raise GRPCError(Status.UNKNOWN,
'Invalid grpc-status: {!r}'.format(grpc_status),
http_details=HTTPDetails(headers_map.get(":status"), headers_map))
else:
message, details = None, None
if status is not Status.OK:
@ -339,10 +346,15 @@ class Stream(StreamIterator[_RecvType], Generic[_SendType, _RecvType]):
return status, message, details
def _raise_for_grpc_status(
self, status: Status, message: Optional[str], details: Any,
self,
status: Status,
message: Optional[str],
details: Any,
headers: Dict[str, str] = None
) -> None:
if status is not Status.OK:
raise GRPCError(status, message, details)
http_status = headers.get(":status") if headers is not None else None
raise GRPCError(status, message, details, HTTPDetails(http_status, headers))
async def recv_initial_metadata(self) -> None:
"""Coroutine to wait for headers with initial metadata from the server.
@ -390,7 +402,7 @@ class Stream(StreamIterator[_RecvType], Generic[_SendType, _RecvType]):
)
self.trailing_metadata = tm
self._raise_for_grpc_status(status, message, details)
self._raise_for_grpc_status(status, message, details, headers_map)
else:
im = decode_metadata(headers)
im, = await self._dispatch.recv_initial_metadata(im)
@ -523,20 +535,22 @@ class Stream(StreamIterator[_RecvType], Generic[_SendType, _RecvType]):
await self.recv_trailing_metadata()
def _maybe_raise(self) -> None:
headers_map = {}
if self._stream.headers is not None:
self._raise_for_status(dict(self._stream.headers))
headers_map = dict(self._stream.headers)
self._raise_for_status(headers_map)
if self._stream.trailers is not None:
status, message, details = self._process_grpc_status(
dict(self._stream.trailers),
)
self._raise_for_grpc_status(status, message, details)
self._raise_for_grpc_status(status, message, details, headers)
elif self._stream.headers is not None:
headers_map = dict(self._stream.headers)
if 'grpc-status' in headers_map:
status, message, details = self._process_grpc_status(
headers_map,
)
self._raise_for_grpc_status(status, message, details)
self._raise_for_grpc_status(status, message, details, headers_map)
async def __aexit__(
self,
@ -683,9 +697,8 @@ class Channel:
self._codec = codec
self._status_details_codec = status_details_codec
self._ssl = ssl or None
self._authority = '{}:{}'.format(self._host, self._port)
self._scheme = 'https' if self._ssl else 'http'
self._authority = '{}:{}'.format(self._host, self._port)
self._authority = self._get_authority(self._host, self._port)
self._h2_config = H2Configuration(
client_side=True,
header_encoding='ascii',
@ -779,6 +792,15 @@ class Channel:
ctx.set_alpn_protocols(['h2'])
return ctx
def _get_authority(self, host: str, port: int) -> str:
try:
ipv6_address = ipaddress.IPv6Address(host)
except ipaddress.AddressValueError:
pass
else:
host = f"[{ipv6_address}]"
return "{}:{}".format(host, port)
def request(
self,
name: str,

View File

@ -1,8 +1,15 @@
from typing import Optional, Any
from typing import Optional, Any, Dict
from dataclasses import dataclass, field
from .const import Status
@dataclass(frozen=True)
class HTTPDetails:
status: str = field(default="")
headers: Dict[str, str] = field(default_factory=dict)
class GRPCError(Exception):
"""Expected error, may be raised during RPC call
@ -26,11 +33,13 @@ class GRPCError(Exception):
`(e.g. server returned unsupported` ``:content-type`` `header)`
"""
def __init__(
self,
status: Status,
message: Optional[str] = None,
details: Any = None,
self,
status: Status,
message: Optional[str] = None,
details: Any = None,
http_details: HTTPDetails = None
) -> None:
super().__init__(status, message, details)
#: :py:class:`~grpclib.const.Status` of the error
@ -39,6 +48,8 @@ class GRPCError(Exception):
self.message = message
#: Error details
self.details = details
#: Http details
self.http_details = http_details
class ProtocolError(Exception):

View File

@ -1,6 +1,7 @@
import os
import socket
import tempfile
import ipaddress
import pytest
@ -46,19 +47,27 @@ class ClientServer:
channel = None
channel_ctx = None
def __init__(self, *, host="127.0.0.1"):
self.host = host
async def __aenter__(self):
host = '127.0.0.1'
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
_, port = s.getsockname()
try:
ipaddress.IPv6Address(self.host)
except ipaddress.AddressValueError:
family = socket.AF_INET
else:
family = socket.AF_INET6
with socket.socket(family, socket.SOCK_STREAM) as s:
s.bind((self.host, 0))
_, port, *_ = s.getsockname()
dummy_service = DummyService()
self.server = Server([dummy_service])
await self.server.start(host, port)
await self.server.start(self.host, port)
self.server_ctx = await self.server.__aenter__()
self.channel = Channel(host=host, port=port)
self.channel = Channel(host=self.host, port=port)
self.channel_ctx = await self.channel.__aenter__()
dummy_stub = DummyServiceStub(self.channel)
return dummy_service, dummy_stub
@ -211,3 +220,12 @@ async def test_stream_stream_advanced():
assert await stream.recv_message() == DummyReply(value='baz')
assert await stream.recv_message() is None
@pytest.mark.asyncio
@pytest.mark.skipif(not socket.has_ipv6, reason="No IPv6 support")
async def test_ipv6():
async with ClientServer(host="::1") as (handler, stub):
reply = await stub.UnaryUnary(DummyRequest(value='ping'))
assert reply == DummyReply(value='pong')
assert handler.log == [DummyRequest(value='ping')]