Skip to content

Commit e401864

Browse files
authored
test: add edge case tests for HTTP/2 GOAWAY handling to achieve 100% coverage (#3)
1 parent f2e483e commit e401864

File tree

2 files changed

+336
-0
lines changed

2 files changed

+336
-0
lines changed

tests/_async/test_http2_goaway.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,3 +826,171 @@ def create_connection(
826826

827827
# Verify the first connection was called twice (retry happened)
828828
assert pool._mock_connections[0]._calls == 2 # type: ignore[attr-defined]
829+
830+
831+
# =============================================================================
832+
# Tests for edge cases in HTTP/2 GOAWAY handling
833+
# =============================================================================
834+
835+
836+
@pytest.mark.anyio
837+
async def test_http2_receive_events_with_terminated_connection_no_stream_id():
838+
"""
839+
Test _receive_events when connection is terminated and stream_id is None.
840+
This covers line 435 in http2.py - the RemoteProtocolError path.
841+
842+
This scenario occurs when _receive_events is called without a stream_id
843+
(e.g., from _wait_for_outgoing_flow) after the connection has terminated.
844+
We test this by directly manipulating the connection state.
845+
"""
846+
import h2.events
847+
848+
origin = httpcore.Origin(b"https", b"example.com", 443)
849+
stream = httpcore.AsyncMockStream(
850+
[
851+
hyperframe.frame.SettingsFrame().serialize(),
852+
# We'll manipulate the connection state after initialization
853+
]
854+
)
855+
async with httpcore.AsyncHTTP2Connection(
856+
origin=origin, stream=stream, keepalive_expiry=5.0
857+
) as conn:
858+
# Directly set _connection_terminated to simulate a terminated connection
859+
# This mimics the state after receiving a GOAWAY but before cleanup
860+
terminated = h2.events.ConnectionTerminated()
861+
terminated.error_code = 0
862+
terminated.last_stream_id = 0
863+
terminated.additional_data = b""
864+
conn._connection_terminated = terminated
865+
866+
# Create a mock request for the _receive_events call
867+
request = httpcore.Request(
868+
method=b"GET",
869+
url=httpcore.URL("https://example.com/"),
870+
headers=[(b"host", b"example.com")],
871+
)
872+
873+
# Call _receive_events with stream_id=None to trigger line 435
874+
with pytest.raises(httpcore.RemoteProtocolError):
875+
await conn._receive_events(request, stream_id=None)
876+
877+
878+
@pytest.mark.anyio
879+
async def test_http2_server_disconnect_with_h2_closed_state():
880+
"""
881+
Test server disconnect when h2 state machine is CLOSED but _connection_terminated
882+
is not yet set. This covers line 558 in http2.py.
883+
884+
This simulates a race condition where h2 has processed GOAWAY internally
885+
(transitioning to CLOSED state) but we haven't processed the event yet.
886+
"""
887+
import h2.connection
888+
889+
origin = httpcore.Origin(b"https", b"example.com", 443)
890+
891+
# Create a mock stream that sets state to CLOSED BEFORE returning empty data
892+
# This simulates the race condition accurately
893+
class MockStreamWithClosedStateOnDisconnect(httpcore.AsyncMockStream):
894+
def __init__(self, conn_ref: list) -> None:
895+
self._conn_ref = conn_ref
896+
self._read_count = 0
897+
super().__init__([hyperframe.frame.SettingsFrame().serialize()], http2=True)
898+
899+
async def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
900+
self._read_count += 1
901+
if self._read_count == 1:
902+
# First read returns settings
903+
return hyperframe.frame.SettingsFrame().serialize()
904+
# Before returning empty (disconnect), set h2 state to CLOSED
905+
# This simulates h2 having processed GOAWAY internally
906+
if self._conn_ref and self._conn_ref[0]:
907+
self._conn_ref[
908+
0
909+
]._h2_state.state_machine.state = h2.connection.ConnectionState.CLOSED
910+
self._conn_ref[0]._connection_terminated = None
911+
return b"" # Server disconnect
912+
913+
conn_ref: list = []
914+
stream = MockStreamWithClosedStateOnDisconnect(conn_ref)
915+
916+
async with httpcore.AsyncHTTP2Connection(
917+
origin=origin, stream=stream, keepalive_expiry=5.0
918+
) as conn:
919+
conn_ref.append(conn)
920+
921+
# Set up stream tracking for the request
922+
conn._stream_requests[1] = {"headers_sent": True, "body_sent": False}
923+
924+
# Create a mock request
925+
request = httpcore.Request(
926+
method=b"GET",
927+
url=httpcore.URL("https://example.com/"),
928+
headers=[(b"host", b"example.com")],
929+
)
930+
931+
# First call consumes the initial settings frame
932+
await conn._read_incoming_data(request, stream_id=1)
933+
934+
# Second call should hit line 558 when mock returns empty data
935+
# and sets h2 state to CLOSED
936+
with pytest.raises(httpcore.ConnectionGoingAway) as exc_info:
937+
await conn._read_incoming_data(request, stream_id=1)
938+
939+
# Verify the exception has the expected properties
940+
assert exc_info.value.request_stream_id == 1
941+
assert exc_info.value.error_code == 0 # Assumed graceful
942+
943+
944+
@pytest.mark.anyio
945+
async def test_http2_protocol_error_with_h2_closed_state():
946+
"""
947+
Test h2 ProtocolError when state machine is CLOSED.
948+
This covers lines 213-225 in http2.py.
949+
950+
This simulates the race condition where h2 raises a ProtocolError
951+
and the state machine is in CLOSED state, but _connection_terminated
952+
is not yet set.
953+
"""
954+
import h2.connection
955+
956+
origin = httpcore.Origin(b"https", b"example.com", 443)
957+
958+
# Create a mock stream that sets state to CLOSED during write
959+
# This causes h2 to raise ProtocolError when trying to read the next frame
960+
class MockStreamWithClosedOnWrite(httpcore.AsyncMockStream):
961+
def __init__(self, conn_ref: list) -> None:
962+
self._conn_ref = conn_ref
963+
self._write_count = 0
964+
super().__init__([hyperframe.frame.SettingsFrame().serialize()], http2=True)
965+
966+
async def write(self, data: bytes, timeout: float | None = None) -> None:
967+
self._write_count += 1
968+
# After first write (settings ACK), set state to CLOSED
969+
# to simulate race condition during request sending
970+
if self._write_count > 1 and self._conn_ref and self._conn_ref[0]:
971+
self._conn_ref[
972+
0
973+
]._h2_state.state_machine.state = h2.connection.ConnectionState.CLOSED
974+
self._conn_ref[0]._connection_terminated = None
975+
976+
conn_ref: list = []
977+
stream = MockStreamWithClosedOnWrite(conn_ref)
978+
979+
async with httpcore.AsyncHTTP2Connection(
980+
origin=origin, stream=stream, keepalive_expiry=5.0
981+
) as conn:
982+
conn_ref.append(conn)
983+
984+
# Use handle_async_request which has the try-except block
985+
# The request will fail when h2 raises ProtocolError in CLOSED state
986+
with pytest.raises(httpcore.ConnectionGoingAway) as exc_info:
987+
await conn.handle_async_request(
988+
httpcore.Request(
989+
method=b"GET",
990+
url=httpcore.URL("https://example.com/"),
991+
headers=[(b"host", b"example.com")],
992+
)
993+
)
994+
995+
# Verify the exception properties
996+
assert exc_info.value.error_code == 0 # Assumed graceful

tests/_sync/test_http2_goaway.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,3 +826,171 @@ def create_connection(
826826

827827
# Verify the first connection was called twice (retry happened)
828828
assert pool._mock_connections[0]._calls == 2 # type: ignore[attr-defined]
829+
830+
831+
# =============================================================================
832+
# Tests for edge cases in HTTP/2 GOAWAY handling
833+
# =============================================================================
834+
835+
836+
837+
def test_http2_receive_events_with_terminated_connection_no_stream_id():
838+
"""
839+
Test _receive_events when connection is terminated and stream_id is None.
840+
This covers line 435 in http2.py - the RemoteProtocolError path.
841+
842+
This scenario occurs when _receive_events is called without a stream_id
843+
(e.g., from _wait_for_outgoing_flow) after the connection has terminated.
844+
We test this by directly manipulating the connection state.
845+
"""
846+
import h2.events
847+
848+
origin = httpcore.Origin(b"https", b"example.com", 443)
849+
stream = httpcore.MockStream(
850+
[
851+
hyperframe.frame.SettingsFrame().serialize(),
852+
# We'll manipulate the connection state after initialization
853+
]
854+
)
855+
with httpcore.HTTP2Connection(
856+
origin=origin, stream=stream, keepalive_expiry=5.0
857+
) as conn:
858+
# Directly set _connection_terminated to simulate a terminated connection
859+
# This mimics the state after receiving a GOAWAY but before cleanup
860+
terminated = h2.events.ConnectionTerminated()
861+
terminated.error_code = 0
862+
terminated.last_stream_id = 0
863+
terminated.additional_data = b""
864+
conn._connection_terminated = terminated
865+
866+
# Create a mock request for the _receive_events call
867+
request = httpcore.Request(
868+
method=b"GET",
869+
url=httpcore.URL("https://example.com/"),
870+
headers=[(b"host", b"example.com")],
871+
)
872+
873+
# Call _receive_events with stream_id=None to trigger line 435
874+
with pytest.raises(httpcore.RemoteProtocolError):
875+
conn._receive_events(request, stream_id=None)
876+
877+
878+
879+
def test_http2_server_disconnect_with_h2_closed_state():
880+
"""
881+
Test server disconnect when h2 state machine is CLOSED but _connection_terminated
882+
is not yet set. This covers line 558 in http2.py.
883+
884+
This simulates a race condition where h2 has processed GOAWAY internally
885+
(transitioning to CLOSED state) but we haven't processed the event yet.
886+
"""
887+
import h2.connection
888+
889+
origin = httpcore.Origin(b"https", b"example.com", 443)
890+
891+
# Create a mock stream that sets state to CLOSED BEFORE returning empty data
892+
# This simulates the race condition accurately
893+
class MockStreamWithClosedStateOnDisconnect(httpcore.MockStream):
894+
def __init__(self, conn_ref: list) -> None:
895+
self._conn_ref = conn_ref
896+
self._read_count = 0
897+
super().__init__([hyperframe.frame.SettingsFrame().serialize()], http2=True)
898+
899+
def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
900+
self._read_count += 1
901+
if self._read_count == 1:
902+
# First read returns settings
903+
return hyperframe.frame.SettingsFrame().serialize()
904+
# Before returning empty (disconnect), set h2 state to CLOSED
905+
# This simulates h2 having processed GOAWAY internally
906+
if self._conn_ref and self._conn_ref[0]:
907+
self._conn_ref[
908+
0
909+
]._h2_state.state_machine.state = h2.connection.ConnectionState.CLOSED
910+
self._conn_ref[0]._connection_terminated = None
911+
return b"" # Server disconnect
912+
913+
conn_ref: list = []
914+
stream = MockStreamWithClosedStateOnDisconnect(conn_ref)
915+
916+
with httpcore.HTTP2Connection(
917+
origin=origin, stream=stream, keepalive_expiry=5.0
918+
) as conn:
919+
conn_ref.append(conn)
920+
921+
# Set up stream tracking for the request
922+
conn._stream_requests[1] = {"headers_sent": True, "body_sent": False}
923+
924+
# Create a mock request
925+
request = httpcore.Request(
926+
method=b"GET",
927+
url=httpcore.URL("https://example.com/"),
928+
headers=[(b"host", b"example.com")],
929+
)
930+
931+
# First call consumes the initial settings frame
932+
conn._read_incoming_data(request, stream_id=1)
933+
934+
# Second call should hit line 558 when mock returns empty data
935+
# and sets h2 state to CLOSED
936+
with pytest.raises(httpcore.ConnectionGoingAway) as exc_info:
937+
conn._read_incoming_data(request, stream_id=1)
938+
939+
# Verify the exception has the expected properties
940+
assert exc_info.value.request_stream_id == 1
941+
assert exc_info.value.error_code == 0 # Assumed graceful
942+
943+
944+
945+
def test_http2_protocol_error_with_h2_closed_state():
946+
"""
947+
Test h2 ProtocolError when state machine is CLOSED.
948+
This covers lines 213-225 in http2.py.
949+
950+
This simulates the race condition where h2 raises a ProtocolError
951+
and the state machine is in CLOSED state, but _connection_terminated
952+
is not yet set.
953+
"""
954+
import h2.connection
955+
956+
origin = httpcore.Origin(b"https", b"example.com", 443)
957+
958+
# Create a mock stream that sets state to CLOSED during write
959+
# This causes h2 to raise ProtocolError when trying to read the next frame
960+
class MockStreamWithClosedOnWrite(httpcore.MockStream):
961+
def __init__(self, conn_ref: list) -> None:
962+
self._conn_ref = conn_ref
963+
self._write_count = 0
964+
super().__init__([hyperframe.frame.SettingsFrame().serialize()], http2=True)
965+
966+
def write(self, data: bytes, timeout: float | None = None) -> None:
967+
self._write_count += 1
968+
# After first write (settings ACK), set state to CLOSED
969+
# to simulate race condition during request sending
970+
if self._write_count > 1 and self._conn_ref and self._conn_ref[0]:
971+
self._conn_ref[
972+
0
973+
]._h2_state.state_machine.state = h2.connection.ConnectionState.CLOSED
974+
self._conn_ref[0]._connection_terminated = None
975+
976+
conn_ref: list = []
977+
stream = MockStreamWithClosedOnWrite(conn_ref)
978+
979+
with httpcore.HTTP2Connection(
980+
origin=origin, stream=stream, keepalive_expiry=5.0
981+
) as conn:
982+
conn_ref.append(conn)
983+
984+
# Use handle_request which has the try-except block
985+
# The request will fail when h2 raises ProtocolError in CLOSED state
986+
with pytest.raises(httpcore.ConnectionGoingAway) as exc_info:
987+
conn.handle_request(
988+
httpcore.Request(
989+
method=b"GET",
990+
url=httpcore.URL("https://example.com/"),
991+
headers=[(b"host", b"example.com")],
992+
)
993+
)
994+
995+
# Verify the exception properties
996+
assert exc_info.value.error_code == 0 # Assumed graceful

0 commit comments

Comments
 (0)