Skip to content

Commit 77d17d1

Browse files
fix(async): apply socket_timeout per read in async parsers
The async client was wrapping the entire parser.read_response() call in async_timeout, which meant socket_timeout applied to the whole response. The sync client applies it per socket recv() instead. Change the async Python parsers (RESP2/RESP3) and the Hiredis async parser to accept a timeout parameter and apply it around each individual stream read. Connection.read_response now passes the timeout down to the parser rather than wrapping the parser call. Fixes #3454
1 parent edd392f commit 77d17d1

6 files changed

Lines changed: 416 additions & 55 deletions

File tree

redis/_parsers/base.py

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import logging
2+
import sys
23
from abc import ABC, abstractmethod
34
from asyncio import IncompleteReadError, StreamReader
45
from typing import Awaitable, Callable, List, Optional, Protocol, Union
56

7+
if sys.version_info >= (3, 11, 3):
8+
from asyncio import timeout as async_timeout
9+
else:
10+
from async_timeout import timeout as async_timeout
11+
612
from redis.maint_notifications import (
713
MaintenanceNotification,
814
NodeFailedOverNotification,
@@ -13,7 +19,6 @@
1319
OSSNodeMigratedNotification,
1420
OSSNodeMigratingNotification,
1521
)
16-
from redis.utils import deprecated_function, safe_str
1722

1823
from ..exceptions import (
1924
AskError,
@@ -36,6 +41,7 @@
3641
TryAgainError,
3742
)
3843
from ..typing import EncodableT
44+
from ..utils import SENTINEL, deprecated_function, safe_str
3945
from .encoders import Encoder
4046
from .socket import SERVER_CLOSED_CONNECTION_ERROR, SocketBuffer
4147

@@ -183,7 +189,10 @@ async def can_read(self) -> bool:
183189
pass
184190

185191
async def read_response(
186-
self, disable_decoding: bool = False
192+
self,
193+
disable_decoding: bool = False,
194+
push_request: bool = False,
195+
timeout: Union[float, object] = SENTINEL,
187196
) -> Union[EncodableT, ResponseError, None, List[EncodableT]]:
188197
raise NotImplementedError()
189198

@@ -545,40 +554,65 @@ async def can_read(self) -> bool:
545554
# parser and fail loudly if the private buffer API changes.
546555
return bool(self._stream._buffer) or self._stream.at_eof()
547556

548-
async def _read(self, length: int) -> bytes:
557+
async def _read_from_stream(
558+
self, timeout: Union[float, object] = SENTINEL, max_bytes: int = 0
559+
) -> bytes:
560+
"""
561+
Read the next chunk from the underlying stream with an optional
562+
per-read timeout. This mirrors the sync client's per-recv timeout
563+
semantics: each individual socket read gets its own timeout window.
564+
565+
``max_bytes`` limits how many bytes may be returned. When 0, the
566+
parser's ``_read_size`` is used.
567+
"""
568+
stream = self._stream
569+
if stream is None:
570+
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
571+
size = max_bytes if max_bytes > 0 else self._read_size
572+
if timeout is not SENTINEL and isinstance(timeout, (int, float)):
573+
async with async_timeout(timeout):
574+
return await stream.read(size)
575+
return await stream.read(size)
576+
577+
async def _read(
578+
self, length: int, timeout: Union[float, object] = SENTINEL
579+
) -> bytes:
549580
"""
550581
Read `length` bytes of data. These are assumed to be followed
551582
by a '\r\n' terminator which is subsequently discarded.
552583
"""
553584
want = length + 2
554585
end = self._pos + want
555-
if len(self._buffer) >= end:
556-
result = self._buffer[self._pos : end - 2]
557-
else:
558-
tail = self._buffer[self._pos :]
586+
while len(self._buffer) < end:
587+
need = end - len(self._buffer)
559588
try:
560-
data = await self._stream.readexactly(want - len(tail))
589+
chunk = await self._read_from_stream(
590+
timeout=timeout, max_bytes=min(need, self._read_size)
591+
)
561592
except IncompleteReadError as error:
562593
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from error
563-
result = (tail + data)[:-2]
564-
self._chunks.append(data)
565-
self._pos += want
594+
if not chunk:
595+
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
596+
self._buffer += chunk
597+
result = self._buffer[self._pos : end - 2]
598+
self._pos = end
566599
return result
567600

568-
async def _readline(self) -> bytes:
601+
async def _readline(self, timeout: Union[float, object] = SENTINEL) -> bytes:
569602
"""
570603
read an unknown number of bytes up to the next '\r\n'
571604
line separator, which is discarded.
572605
"""
573-
found = self._buffer.find(b"\r\n", self._pos)
574-
if found >= 0:
575-
result = self._buffer[self._pos : found]
576-
else:
577-
tail = self._buffer[self._pos :]
578-
data = await self._stream.readline()
579-
if not data.endswith(b"\r\n"):
606+
while True:
607+
found = self._buffer.find(b"\r\n", self._pos)
608+
if found >= 0:
609+
result = self._buffer[self._pos : found]
610+
self._pos = found + 2
611+
return result
612+
try:
613+
chunk = await self._read_from_stream(timeout=timeout)
614+
except IncompleteReadError as error:
615+
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from error
616+
if not chunk:
580617
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
581-
result = (tail + data)[:-2]
582-
self._chunks.append(data)
583-
self._pos += len(result) + 2
584-
return result
618+
self._buffer += chunk

redis/_parsers/hiredis.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import select
22
import socket
3+
import sys
34
from logging import getLogger
45
from typing import Callable, List, Optional, TypedDict, Union
56

7+
if sys.version_info >= (3, 11, 3):
8+
from asyncio import timeout as async_timeout
9+
else:
10+
from async_timeout import timeout as async_timeout
11+
612
from ..exceptions import ConnectionError, InvalidResponse, RedisError, TimeoutError
713
from ..typing import EncodableT
814
from ..utils import HIREDIS_AVAILABLE, SENTINEL, deprecated_function
@@ -244,8 +250,12 @@ async def can_read(self) -> bool:
244250
# with a real StreamReader guard this private buffer API in CI.
245251
return bool(self._stream._buffer)
246252

247-
async def read_from_socket(self):
248-
buffer = await self._stream.read(self._read_size)
253+
async def read_from_socket(self, timeout: Union[float, object] = SENTINEL):
254+
if timeout is not SENTINEL:
255+
async with async_timeout(timeout):
256+
buffer = await self._stream.read(self._read_size)
257+
else:
258+
buffer = await self._stream.read(self._read_size)
249259
if not buffer or not isinstance(buffer, bytes):
250260
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from None
251261
self._reader.feed(buffer)
@@ -254,7 +264,10 @@ async def read_from_socket(self):
254264
return True
255265

256266
async def read_response(
257-
self, disable_decoding: bool = False, push_request: bool = False
267+
self,
268+
disable_decoding: bool = False,
269+
push_request: bool = False,
270+
timeout: Union[float, object] = SENTINEL,
258271
) -> Union[EncodableT, List[EncodableT]]:
259272
# If `on_disconnect()` has been called, prohibit any more reads
260273
# even if they could happen because data might be present.
@@ -268,7 +281,7 @@ async def read_response(
268281
response = self._reader.gets()
269282

270283
while response is NOT_ENOUGH_DATA:
271-
await self.read_from_socket()
284+
await self.read_from_socket(timeout=timeout)
272285
if disable_decoding:
273286
response = self._reader.gets(False)
274287
else:
@@ -285,7 +298,9 @@ async def read_response(
285298
response = await self.handle_push_response(response)
286299
if not push_request:
287300
return await self.read_response(
288-
disable_decoding=disable_decoding, push_request=push_request
301+
disable_decoding=disable_decoding,
302+
push_request=push_request,
303+
timeout=timeout,
289304
)
290305
else:
291306
return response

redis/_parsers/resp2.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,27 @@ def _read_response(
7878
class _AsyncRESP2Parser(_AsyncRESPBase):
7979
"""Async class for the RESP2 protocol"""
8080

81-
async def read_response(self, disable_decoding: bool = False):
81+
async def read_response(
82+
self, disable_decoding: bool = False, timeout: Union[float, object] = SENTINEL
83+
):
8284
if not self._connected:
8385
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
8486
if self._chunks:
8587
# augment parsing buffer with previously read data
8688
self._buffer += b"".join(self._chunks)
8789
self._chunks.clear()
8890
self._pos = 0
89-
response = await self._read_response(disable_decoding=disable_decoding)
91+
response = await self._read_response(
92+
disable_decoding=disable_decoding, timeout=timeout
93+
)
9094
# Successfully parsing a response allows us to clear our parsing buffer
9195
self._clear()
9296
return response
9397

9498
async def _read_response(
95-
self, disable_decoding: bool = False
99+
self, disable_decoding: bool = False, timeout: Union[float, object] = SENTINEL
96100
) -> Union[EncodableT, ResponseError, None]:
97-
raw = await self._readline()
101+
raw = await self._readline(timeout=timeout)
98102
response: Any
99103
byte, response = raw[:1], raw[1:]
100104

@@ -122,13 +126,17 @@ async def _read_response(
122126
elif byte == b"$" and response == b"-1":
123127
return None
124128
elif byte == b"$":
125-
response = await self._read(int(response))
129+
response = await self._read(int(response), timeout=timeout)
126130
# multi-bulk response
127131
elif byte == b"*" and response == b"-1":
128132
return None
129133
elif byte == b"*":
130134
response = [
131-
(await self._read_response(disable_decoding))
135+
(
136+
await self._read_response(
137+
disable_decoding=disable_decoding, timeout=timeout
138+
)
139+
)
132140
for _ in range(int(response)) # noqa
133141
]
134142
else:

redis/_parsers/resp3.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ def _read_response(
153153
return self._read_response(
154154
disable_decoding=disable_decoding,
155155
push_request=push_request,
156+
timeout=timeout,
156157
)
157158
else:
158159
raise InvalidResponse(f"Protocol Error: {raw!r}")
@@ -175,26 +176,34 @@ async def handle_pubsub_push_response(self, response):
175176
return response
176177

177178
async def read_response(
178-
self, disable_decoding: bool = False, push_request: bool = False
179+
self,
180+
disable_decoding: bool = False,
181+
push_request: bool = False,
182+
timeout: Union[float, object] = SENTINEL,
179183
):
180184
if self._chunks:
181185
# augment parsing buffer with previously read data
182186
self._buffer += b"".join(self._chunks)
183187
self._chunks.clear()
184188
self._pos = 0
185189
response = await self._read_response(
186-
disable_decoding=disable_decoding, push_request=push_request
190+
disable_decoding=disable_decoding,
191+
push_request=push_request,
192+
timeout=timeout,
187193
)
188194
# Successfully parsing a response allows us to clear our parsing buffer
189195
self._clear()
190196
return response
191197

192198
async def _read_response(
193-
self, disable_decoding: bool = False, push_request: bool = False
199+
self,
200+
disable_decoding: bool = False,
201+
push_request: bool = False,
202+
timeout: Union[float, object] = SENTINEL,
194203
) -> Union[EncodableT, ResponseError, None]:
195204
if not self._stream or not self.encoder:
196205
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
197-
raw = await self._readline()
206+
raw = await self._readline(timeout=timeout)
198207
response: Any
199208
byte, response = raw[:1], raw[1:]
200209

@@ -204,7 +213,7 @@ async def _read_response(
204213
# server returned an error
205214
if byte in (b"-", b"!"):
206215
if byte == b"!":
207-
response = await self._read(int(response))
216+
response = await self._read(int(response), timeout=timeout)
208217
response = response.decode("utf-8", errors="replace")
209218
error = self.parse_error(response)
210219
# if the error is a ConnectionError, raise immediately so the user
@@ -234,22 +243,30 @@ async def _read_response(
234243
return response == b"t"
235244
# bulk response
236245
elif byte == b"$":
237-
response = await self._read(int(response))
246+
response = await self._read(int(response), timeout=timeout)
238247
# verbatim string response
239248
elif byte == b"=":
240-
response = (await self._read(int(response)))[4:]
249+
response = (await self._read(int(response), timeout=timeout))[4:]
241250
# array response
242251
elif byte == b"*":
243252
response = [
244-
(await self._read_response(disable_decoding=disable_decoding))
253+
(
254+
await self._read_response(
255+
disable_decoding=disable_decoding, timeout=timeout
256+
)
257+
)
245258
for _ in range(int(response))
246259
]
247260
# set response
248261
elif byte == b"~":
249262
# redis can return unhashable types (like dict) in a set,
250263
# so we always convert to a list, to have predictable return types
251264
response = [
252-
(await self._read_response(disable_decoding=disable_decoding))
265+
(
266+
await self._read_response(
267+
disable_decoding=disable_decoding, timeout=timeout
268+
)
269+
)
253270
for _ in range(int(response))
254271
]
255272
# map response
@@ -259,25 +276,33 @@ async def _read_response(
259276
# became defined to be left-right in version 3.8
260277
resp_dict = {}
261278
for _ in range(int(response)):
262-
key = await self._read_response(disable_decoding=disable_decoding)
279+
key = await self._read_response(
280+
disable_decoding=disable_decoding, timeout=timeout
281+
)
263282
resp_dict[key] = await self._read_response(
264-
disable_decoding=disable_decoding, push_request=push_request
283+
disable_decoding=disable_decoding,
284+
push_request=push_request,
285+
timeout=timeout,
265286
)
266287
response = resp_dict
267288
# push response
268289
elif byte == b">":
269290
response = [
270291
(
271292
await self._read_response(
272-
disable_decoding=disable_decoding, push_request=push_request
293+
disable_decoding=disable_decoding,
294+
push_request=push_request,
295+
timeout=timeout,
273296
)
274297
)
275298
for _ in range(int(response))
276299
]
277300
response = await self.handle_push_response(response)
278301
if not push_request:
279302
return await self._read_response(
280-
disable_decoding=disable_decoding, push_request=push_request
303+
disable_decoding=disable_decoding,
304+
push_request=push_request,
305+
timeout=timeout,
281306
)
282307
else:
283308
return response

redis/asyncio/connection.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -751,15 +751,16 @@ async def read_response(
751751
host_error = self._host_error()
752752
try:
753753
if read_timeout is not None and self.protocol in ["3", 3]:
754-
async with async_timeout(read_timeout):
755-
response = await self._parser.read_response(
756-
disable_decoding=disable_decoding, push_request=push_request
757-
)
754+
response = await self._parser.read_response(
755+
disable_decoding=disable_decoding,
756+
push_request=push_request,
757+
timeout=read_timeout,
758+
)
758759
elif read_timeout is not None:
759-
async with async_timeout(read_timeout):
760-
response = await self._parser.read_response(
761-
disable_decoding=disable_decoding
762-
)
760+
response = await self._parser.read_response(
761+
disable_decoding=disable_decoding,
762+
timeout=read_timeout,
763+
)
763764
elif self.protocol in ["3", 3]:
764765
response = await self._parser.read_response(
765766
disable_decoding=disable_decoding, push_request=push_request

0 commit comments

Comments
 (0)