Skip to content

Commit 12a25fd

Browse files
Tweak dropped connection detection (#185)
* Tweak sync dropped connection detection * Nits * Use common helper across sync, asyncio, curio, anyio * Update * Add test case * Rename is_connection_dropped -> is_socket_readable, tweak comments * Switch to stealing util from trio Co-authored-by: Tom Christie <[email protected]>
1 parent f9f59af commit 12a25fd

22 files changed

+162
-58
lines changed

httpcore/_async/connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ def state(self) -> ConnectionState:
165165
return ConnectionState.PENDING
166166
return self.connection.get_state()
167167

168-
def is_connection_dropped(self) -> bool:
169-
return self.connection is not None and self.connection.is_connection_dropped()
168+
def is_socket_readable(self) -> bool:
169+
return self.connection is not None and self.connection.is_socket_readable()
170170

171171
def mark_as_ready(self) -> None:
172172
if self.connection is not None:

httpcore/_async/connection_pool.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,14 @@ async def _get_connection_from_pool(
245245
seen_http11 = True
246246

247247
if connection.state == ConnectionState.IDLE:
248-
if connection.is_connection_dropped():
248+
if connection.is_socket_readable():
249+
# If the socket is readable while the connection is idle (meaning
250+
# we don't expect the server to send any data), then the only valid
251+
# reason is that the other end has disconnected, which means we
252+
# should drop the connection too.
253+
# (For a detailed run-through of what a "readable" socket is, and
254+
# why this is the best thing for us to do here, see:
255+
# https://github.com/encode/httpx/pull/143#issuecomment-515181778)
249256
logger.trace("removing dropped idle connection=%r", connection)
250257
# IDLE connections that have been dropped should be
251258
# removed from the pool.

httpcore/_async/http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ def mark_as_ready(self) -> None:
2020
"""
2121
raise NotImplementedError() # pragma: nocover
2222

23-
def is_connection_dropped(self) -> bool:
23+
def is_socket_readable(self) -> bool:
2424
"""
25-
Return 'True' if the connection has been dropped by the remote end.
25+
Return 'True' if the underlying network socket is readable.
2626
"""
2727
raise NotImplementedError() # pragma: nocover
2828

httpcore/_async/http11.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,5 +201,5 @@ async def aclose(self) -> None:
201201

202202
await self.socket.aclose()
203203

204-
def is_connection_dropped(self) -> bool:
205-
return self.socket.is_connection_dropped()
204+
def is_socket_readable(self) -> bool:
205+
return self.socket.is_readable()

httpcore/_async/http2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ async def send_connection_init(self, timeout: TimeoutDict) -> None:
158158
def is_closed(self) -> bool:
159159
return False
160160

161-
def is_connection_dropped(self) -> bool:
162-
return self.socket.is_connection_dropped()
161+
def is_socket_readable(self) -> bool:
162+
return self.socket.is_readable()
163163

164164
async def aclose(self) -> None:
165165
logger.trace("close_connection=%r", self)

httpcore/_backends/anyio.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import select
21
from ssl import SSLContext
32
from typing import Optional
43

@@ -17,6 +16,7 @@
1716
WriteTimeout,
1817
)
1918
from .._types import TimeoutDict
19+
from .._utils import is_socket_readable
2020
from .base import AsyncBackend, AsyncLock, AsyncSemaphore, AsyncSocketStream
2121

2222

@@ -85,10 +85,9 @@ async def aclose(self) -> None:
8585
except BrokenResourceError as exc:
8686
raise CloseError from exc
8787

88-
def is_connection_dropped(self) -> bool:
89-
raw_socket = self.stream.extra(SocketAttribute.raw_socket)
90-
rready, _wready, _xready = select.select([raw_socket], [], [], 0)
91-
return bool(rready)
88+
def is_readable(self) -> bool:
89+
sock = self.stream.extra(SocketAttribute.raw_socket)
90+
return is_socket_readable(sock.fileno())
9291

9392

9493
class Lock(AsyncLock):

httpcore/_backends/asyncio.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import socket
23
from ssl import SSLContext
34
from typing import Optional
45

@@ -13,6 +14,7 @@
1314
map_exceptions,
1415
)
1516
from .._types import TimeoutDict
17+
from .._utils import is_socket_readable
1618
from .base import AsyncBackend, AsyncLock, AsyncSemaphore, AsyncSocketStream
1719

1820
SSL_MONKEY_PATCH_APPLIED = False
@@ -171,21 +173,10 @@ async def aclose(self) -> None:
171173
with map_exceptions({OSError: CloseError}):
172174
self.stream_writer.close()
173175

174-
def is_connection_dropped(self) -> bool:
175-
# Counter-intuitively, what we really want to know here is whether the socket is
176-
# *readable*, i.e. whether it would return immediately with empty bytes if we
177-
# called `.recv()` on it, indicating that the other end has closed the socket.
178-
# See: https://github.com/encode/httpx/pull/143#issuecomment-515181778
179-
#
180-
# As it turns out, asyncio checks for readability in the background
181-
# (see: https://github.com/encode/httpx/pull/276#discussion_r322000402),
182-
# so checking for EOF or readability here would yield the same result.
183-
#
184-
# At the cost of rigour, we check for EOF instead of readability because asyncio
185-
# does not expose any public API to check for readability.
186-
# (For a solution that uses private asyncio APIs, see:
187-
# https://github.com/encode/httpx/pull/143#issuecomment-515202982)
188-
return self.stream_reader.at_eof()
176+
def is_readable(self) -> bool:
177+
transport = self.stream_reader._transport # type: ignore
178+
sock: socket.socket = transport.get_extra_info("socket")
179+
return is_socket_readable(sock.fileno())
189180

190181

191182
class Lock(AsyncLock):

httpcore/_backends/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def write(self, data: bytes, timeout: TimeoutDict) -> None:
6363
async def aclose(self) -> None:
6464
raise NotImplementedError() # pragma: no cover
6565

66-
def is_connection_dropped(self) -> bool:
66+
def is_readable(self) -> bool:
6767
raise NotImplementedError() # pragma: no cover
6868

6969

httpcore/_backends/curio.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import select
21
from ssl import SSLContext, SSLSocket
32
from typing import Optional
43

@@ -15,7 +14,7 @@
1514
map_exceptions,
1615
)
1716
from .._types import TimeoutDict
18-
from .._utils import get_logger
17+
from .._utils import get_logger, is_socket_readable
1918
from .base import AsyncBackend, AsyncLock, AsyncSemaphore, AsyncSocketStream
2019

2120
logger = get_logger(__name__)
@@ -132,10 +131,8 @@ async def aclose(self) -> None:
132131
await self.stream.close()
133132
await self.socket.close()
134133

135-
def is_connection_dropped(self) -> bool:
136-
rready, _, _ = select.select([self.socket.fileno()], [], [], 0)
137-
138-
return bool(rready)
134+
def is_readable(self) -> bool:
135+
return is_socket_readable(self.socket.fileno())
139136

140137

141138
class CurioBackend(AsyncBackend):

httpcore/_backends/sync.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import select
21
import socket
32
import threading
43
import time
@@ -17,6 +16,7 @@
1716
map_exceptions,
1817
)
1918
from .._types import TimeoutDict
19+
from .._utils import is_socket_readable
2020

2121

2222
class SyncSocketStream:
@@ -77,9 +77,8 @@ def close(self) -> None:
7777
with map_exceptions({socket.error: CloseError}):
7878
self.sock.close()
7979

80-
def is_connection_dropped(self) -> bool:
81-
rready, _wready, _xready = select.select([self.sock], [], [], 0)
82-
return bool(rready)
80+
def is_readable(self) -> bool:
81+
return is_socket_readable(self.sock.fileno())
8382

8483

8584
class SyncLock:

0 commit comments

Comments
 (0)