Skip to content

Commit 2ff704b

Browse files
Fix spurious LocalProtocolError errors when processing pipelined requests (#2243)
1 parent 4f74ed1 commit 2ff704b

File tree

3 files changed

+61
-11
lines changed

3 files changed

+61
-11
lines changed

tests/protocols/test_http.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ def set_protocol(self, protocol):
176176
pass
177177

178178

179+
class MockTimerHandle:
180+
def __init__(self, loop_later_list, delay, callback, args):
181+
self.loop_later_list = loop_later_list
182+
self.delay = delay
183+
self.callback = callback
184+
self.args = args
185+
self.cancelled = False
186+
187+
def cancel(self):
188+
if not self.cancelled:
189+
self.cancelled = True
190+
self.loop_later_list.remove(self)
191+
192+
179193
class MockLoop:
180194
def __init__(self):
181195
self._tasks = []
@@ -186,18 +200,20 @@ def create_task(self, coroutine):
186200
return MockTask()
187201

188202
def call_later(self, delay, callback, *args):
189-
self._later.insert(0, (delay, callback, args))
203+
handle = MockTimerHandle(self._later, delay, callback, args)
204+
self._later.insert(0, handle)
205+
return handle
190206

191207
async def run_one(self):
192208
return await self._tasks.pop()
193209

194210
def run_later(self, with_delay):
195211
later = []
196-
for delay, callback, args in self._later:
197-
if with_delay >= delay:
198-
callback(*args)
212+
for timer_handle in self._later:
213+
if with_delay >= timer_handle.delay:
214+
timer_handle.callback(*timer_handle.args)
199215
else:
200-
later.append((delay, callback, args))
216+
later.append(timer_handle)
201217
self._later = later
202218

203219

@@ -315,6 +331,32 @@ async def test_keepalive_timeout(http_protocol_cls: HTTPProtocol):
315331
assert protocol.transport.is_closing()
316332

317333

334+
@pytest.mark.anyio
335+
async def test_keepalive_timeout_with_pipelined_requests(
336+
http_protocol_cls: HTTPProtocol,
337+
):
338+
app = Response("Hello, world", media_type="text/plain")
339+
340+
protocol = get_connected_protocol(app, http_protocol_cls)
341+
protocol.data_received(SIMPLE_GET_REQUEST)
342+
protocol.data_received(SIMPLE_GET_REQUEST)
343+
344+
# After processing the first request, the keep-alive task should be
345+
# disabled because the second request is not responded yet.
346+
await protocol.loop.run_one()
347+
assert b"HTTP/1.1 200 OK" in protocol.transport.buffer
348+
assert b"Hello, world" in protocol.transport.buffer
349+
assert protocol.timeout_keep_alive_task is None
350+
351+
# Process the second request and ensure that the keep-alive task
352+
# has been enabled again as the connection is now idle.
353+
protocol.transport.clear_buffer()
354+
await protocol.loop.run_one()
355+
assert b"HTTP/1.1 200 OK" in protocol.transport.buffer
356+
assert b"Hello, world" in protocol.transport.buffer
357+
assert protocol.timeout_keep_alive_task is not None
358+
359+
318360
@pytest.mark.anyio
319361
async def test_close(http_protocol_cls: HTTPProtocol):
320362
app = Response(b"", status_code=204, headers={"connection": "close"})

uvicorn/protocols/http/h11_impl.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,14 @@ def handle_events(self) -> None:
236236
else:
237237
app = self.app
238238

239+
# When starting to process a request, disable the keep-alive
240+
# timeout. Normally we disable this when receiving data from
241+
# client and set back when finishing processing its request.
242+
# However, for pipelined requests processing finishes after
243+
# already receiving the next request and thus the timer may
244+
# be set here, which we don't want.
245+
self._unset_keepalive_if_required()
246+
239247
self.cycle = RequestResponseCycle(
240248
scope=self.scope,
241249
conn=self.conn,

uvicorn/protocols/http/httptools_impl.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -326,22 +326,22 @@ def on_response_complete(self) -> None:
326326
if self.transport.is_closing():
327327
return
328328

329-
# Set a short Keep-Alive timeout.
330329
self._unset_keepalive_if_required()
331330

332-
self.timeout_keep_alive_task = self.loop.call_later(
333-
self.timeout_keep_alive, self.timeout_keep_alive_handler
334-
)
335-
336331
# Unpause data reads if needed.
337332
self.flow.resume_reading()
338333

339-
# Unblock any pipelined events.
334+
# Unblock any pipelined events. If there are none, arm the
335+
# Keep-Alive timeout instead.
340336
if self.pipeline:
341337
cycle, app = self.pipeline.pop()
342338
task = self.loop.create_task(cycle.run_asgi(app))
343339
task.add_done_callback(self.tasks.discard)
344340
self.tasks.add(task)
341+
else:
342+
self.timeout_keep_alive_task = self.loop.call_later(
343+
self.timeout_keep_alive, self.timeout_keep_alive_handler
344+
)
345345

346346
def shutdown(self) -> None:
347347
"""

0 commit comments

Comments
 (0)