Skip to content

Commit 7e288a4

Browse files
igor.stulikovigor.stulikov
igor.stulikov
authored and
igor.stulikov
committed
Tests for pool poisoning (encode#550)
1 parent a406468 commit 7e288a4

File tree

3 files changed

+200
-14
lines changed

3 files changed

+200
-14
lines changed

httpcore/backends/mock.py

+40-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import ssl
2+
import time
23
import typing
3-
from typing import Optional
4+
from typing import Optional, Type
45

5-
from .._exceptions import ReadError
6+
import anyio
7+
8+
from httpcore import ReadTimeout
69
from .base import AsyncNetworkBackend, AsyncNetworkStream, NetworkBackend, NetworkStream
10+
from .._exceptions import ReadError
711

812

913
class MockSSLObject:
@@ -45,10 +49,24 @@ def get_extra_info(self, info: str) -> typing.Any:
4549
return MockSSLObject(http2=self._http2) if info == "ssl_object" else None
4650

4751

52+
class HangingStream(MockStream):
53+
def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes:
54+
if self._closed:
55+
raise ReadError("Connection closed")
56+
time.sleep(timeout or 0.1)
57+
raise ReadTimeout
58+
59+
4860
class MockBackend(NetworkBackend):
49-
def __init__(self, buffer: typing.List[bytes], http2: bool = False) -> None:
61+
def __init__(
62+
self,
63+
buffer: typing.List[bytes],
64+
http2: bool = False,
65+
resp_stream_cls: Optional[Type[NetworkStream]] = None,
66+
) -> None:
5067
self._buffer = buffer
5168
self._http2 = http2
69+
self._resp_stream_cls: Type[MockStream] = resp_stream_cls or MockStream
5270

5371
def connect_tcp(
5472
self,
@@ -57,12 +75,12 @@ def connect_tcp(
5775
timeout: Optional[float] = None,
5876
local_address: Optional[str] = None,
5977
) -> NetworkStream:
60-
return MockStream(list(self._buffer), http2=self._http2)
78+
return self._resp_stream_cls(list(self._buffer), http2=self._http2)
6179

6280
def connect_unix_socket(
6381
self, path: str, timeout: Optional[float] = None
6482
) -> NetworkStream:
65-
return MockStream(list(self._buffer), http2=self._http2)
83+
return self._resp_stream_cls(list(self._buffer), http2=self._http2)
6684

6785
def sleep(self, seconds: float) -> None:
6886
pass
@@ -99,10 +117,24 @@ def get_extra_info(self, info: str) -> typing.Any:
99117
return MockSSLObject(http2=self._http2) if info == "ssl_object" else None
100118

101119

120+
class AsyncHangingStream(AsyncMockStream):
121+
async def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes:
122+
if self._closed:
123+
raise ReadError("Connection closed")
124+
await anyio.sleep(timeout or 0.1)
125+
raise ReadTimeout
126+
127+
102128
class AsyncMockBackend(AsyncNetworkBackend):
103-
def __init__(self, buffer: typing.List[bytes], http2: bool = False) -> None:
129+
def __init__(
130+
self,
131+
buffer: typing.List[bytes],
132+
http2: bool = False,
133+
resp_stream_cls: Optional[Type[AsyncNetworkStream]] = None,
134+
) -> None:
104135
self._buffer = buffer
105136
self._http2 = http2
137+
self._resp_stream_cls: Type[AsyncMockStream] = resp_stream_cls or AsyncMockStream
106138

107139
async def connect_tcp(
108140
self,
@@ -111,12 +143,12 @@ async def connect_tcp(
111143
timeout: Optional[float] = None,
112144
local_address: Optional[str] = None,
113145
) -> AsyncNetworkStream:
114-
return AsyncMockStream(list(self._buffer), http2=self._http2)
146+
return self._resp_stream_cls(list(self._buffer), http2=self._http2)
115147

116148
async def connect_unix_socket(
117149
self, path: str, timeout: Optional[float] = None
118150
) -> AsyncNetworkStream:
119-
return AsyncMockStream(list(self._buffer), http2=self._http2)
151+
return self._resp_stream_cls(list(self._buffer), http2=self._http2)
120152

121153
async def sleep(self, seconds: float) -> None:
122154
pass

tests/_async/test_connection_pool.py

+79-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import List, Optional
1+
import contextlib
2+
from typing import List, Optional, Type
23

34
import pytest
45
import trio as concurrency
@@ -7,11 +8,12 @@
78
AsyncConnectionPool,
89
ConnectError,
910
PoolTimeout,
11+
ReadTimeout,
1012
ReadError,
1113
UnsupportedProtocol,
1214
)
1315
from httpcore.backends.base import AsyncNetworkStream
14-
from httpcore.backends.mock import AsyncMockBackend
16+
from httpcore.backends.mock import AsyncMockBackend, AsyncHangingStream
1517

1618

1719
@pytest.mark.anyio
@@ -502,6 +504,43 @@ async def test_connection_pool_timeout():
502504
await pool.request("GET", "https://example.com/", extensions=extensions)
503505

504506

507+
@pytest.mark.trio
508+
async def test_pool_under_load():
509+
"""
510+
Pool must remain operational after some peak load.
511+
"""
512+
network_backend = AsyncMockBackend([], resp_stream_cls=AsyncHangingStream)
513+
514+
async def fetch(_pool: AsyncConnectionPool, *exceptions: Type[BaseException]):
515+
with contextlib.suppress(*exceptions):
516+
async with pool.stream(
517+
"GET",
518+
"http://a.com/",
519+
extensions={
520+
"timeout": {
521+
"connect": 0.1,
522+
"read": 0.1,
523+
"pool": 0.1,
524+
"write": 0.1,
525+
},
526+
},
527+
) as response:
528+
await response.aread()
529+
530+
async with AsyncConnectionPool(
531+
max_connections=1, network_backend=network_backend
532+
) as pool:
533+
async with concurrency.open_nursery() as nursery:
534+
for _ in range(300):
535+
# Sending many requests to the same url. All of them but one will have PoolTimeout. One will
536+
# be finished with ReadTimeout
537+
nursery.start_soon(fetch, pool, PoolTimeout, ReadTimeout)
538+
if pool.connections: # There is one connection in pool in "CONNECTING" state
539+
assert pool.connections[0].is_connecting()
540+
with pytest.raises(ReadTimeout): # ReadTimeout indicates that connection could be retrieved
541+
await fetch(pool)
542+
543+
505544
@pytest.mark.anyio
506545
async def test_http11_upgrade_connection():
507546
"""
@@ -534,3 +573,41 @@ async def test_http11_upgrade_connection():
534573
network_stream = response.extensions["network_stream"]
535574
content = await network_stream.read(max_bytes=1024)
536575
assert content == b"..."
576+
577+
578+
@pytest.mark.trio
579+
async def test_pool_timeout_connection_cleanup():
580+
"""
581+
Test that pool cleans up connections after zero pool timeout. In case of stale
582+
connection after timeout pool must not hang.
583+
"""
584+
network_backend = AsyncMockBackend(
585+
[
586+
b"HTTP/1.1 200 OK\r\n",
587+
b"Content-Type: plain/text\r\n",
588+
b"Content-Length: 13\r\n",
589+
b"\r\n",
590+
b"Hello, world!",
591+
] * 2,
592+
)
593+
594+
async with AsyncConnectionPool(
595+
network_backend=network_backend, max_connections=1
596+
) as pool:
597+
timeout = {
598+
"connect": 0.1,
599+
"read": 0.1,
600+
"pool": 0,
601+
"write": 0.1,
602+
}
603+
with contextlib.suppress(PoolTimeout):
604+
await pool.request("GET", "https://example.com/", extensions={"timeout": timeout})
605+
606+
# wait for a considerable amount of time to make sure all requests time out
607+
await concurrency.sleep(0.1)
608+
609+
await pool.request("GET", "https://example.com/", extensions={"timeout": {**timeout, 'pool': 0.1}})
610+
611+
if pool.connections:
612+
for conn in pool.connections:
613+
assert not conn.is_connecting()

tests/_sync/test_connection_pool.py

+81-4
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
from typing import List, Optional
1+
import contextlib
2+
import time
3+
from typing import List, Optional, Type
24

35
import pytest
4-
from tests import concurrency
56

67
from httpcore import (
78
ConnectionPool,
89
ConnectError,
910
PoolTimeout,
11+
ReadTimeout,
1012
ReadError,
1113
UnsupportedProtocol,
1214
)
1315
from httpcore.backends.base import NetworkStream
14-
from httpcore.backends.mock import MockBackend
15-
16+
from httpcore.backends.mock import MockBackend, HangingStream
17+
from tests import concurrency
1618

1719

1820
def test_connection_pool_with_keepalive():
@@ -503,6 +505,81 @@ def test_connection_pool_timeout():
503505

504506

505507

508+
def test_pool_under_load():
509+
"""
510+
Pool must remain operational after some peak load.
511+
"""
512+
network_backend = MockBackend([], resp_stream_cls=HangingStream)
513+
514+
def fetch(_pool: ConnectionPool, *exceptions: Type[BaseException]):
515+
with contextlib.suppress(*exceptions):
516+
with pool.stream(
517+
"GET",
518+
"http://a.com/",
519+
extensions={
520+
"timeout": {
521+
"connect": 0.1,
522+
"read": 0.1,
523+
"pool": 0.1,
524+
"write": 0.1,
525+
},
526+
},
527+
) as response:
528+
response.read()
529+
530+
with ConnectionPool(
531+
max_connections=1, network_backend=network_backend
532+
) as pool:
533+
with concurrency.open_nursery() as nursery:
534+
for _ in range(300):
535+
# Sending many requests to the same url. All of them but one will have PoolTimeout. One will
536+
# be finished with ReadTimeout
537+
nursery.start_soon(fetch, pool, PoolTimeout, ReadTimeout)
538+
if pool.connections: # There is one connection in pool in "CONNECTING" state
539+
assert pool.connections[0].is_connecting()
540+
with pytest.raises(ReadTimeout): # ReadTimeout indicates that connection could be retrieved
541+
fetch(pool)
542+
543+
544+
545+
def test_pool_timeout_connection_cleanup():
546+
"""
547+
Test that pool cleans up connections after zero pool timeout. In case of stale
548+
connection after timeout pool must not hang.
549+
"""
550+
network_backend = MockBackend(
551+
[
552+
b"HTTP/1.1 200 OK\r\n",
553+
b"Content-Type: plain/text\r\n",
554+
b"Content-Length: 13\r\n",
555+
b"\r\n",
556+
b"Hello, world!",
557+
] * 2,
558+
)
559+
560+
with ConnectionPool(
561+
network_backend=network_backend, max_connections=2
562+
) as pool:
563+
timeout = {
564+
"connect": 0.1,
565+
"read": 0.1,
566+
"pool": 0,
567+
"write": 0.1,
568+
}
569+
with contextlib.suppress(PoolTimeout):
570+
pool.request("GET", "https://example.com/", extensions={"timeout": timeout})
571+
572+
# wait for a considerable amount of time to make sure all requests time out
573+
time.sleep(0.1)
574+
575+
pool.request("GET", "https://example.com/", extensions={"timeout": {**timeout, 'pool': 0.1}})
576+
577+
if pool.connections:
578+
for conn in pool.connections:
579+
assert not conn.is_connecting()
580+
581+
582+
506583
def test_http11_upgrade_connection():
507584
"""
508585
HTTP "101 Switching Protocols" indicates an upgraded connection.

0 commit comments

Comments
 (0)