@@ -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
0 commit comments