Skip to content

Commit 2c0ea0a

Browse files
JonasKsKludex
andauthored
Add --timeout-graceful-shutdown parameter (#1950)
Co-authored-by: Marcelo Trylesinski <[email protected]>
1 parent 176b4be commit 2c0ea0a

File tree

8 files changed

+154
-4
lines changed

8 files changed

+154
-4
lines changed

docs/deployment.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ Options:
104104
--timeout-keep-alive INTEGER Close Keep-Alive connections if no new data
105105
is received within this timeout. [default:
106106
5]
107+
--timeout-graceful-shutdown INTEGER
108+
Maximum number of seconds to wait for
109+
graceful shutdown.
107110
--ssl-keyfile TEXT SSL key file
108111
--ssl-certfile TEXT SSL certificate file
109112
--ssl-keyfile-password TEXT SSL keyfile password

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ Options:
171171
--timeout-keep-alive INTEGER Close Keep-Alive connections if no new data
172172
is received within this timeout. [default:
173173
5]
174+
--timeout-graceful-shutdown INTEGER
175+
Maximum number of seconds to wait for
176+
graceful shutdown.
174177
--ssl-keyfile TEXT SSL key file
175178
--ssl-certfile TEXT SSL certificate file
176179
--ssl-keyfile-password TEXT SSL keyfile password

docs/settings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,4 @@ connecting IPs in the `forwarded-allow-ips` configuration.
115115
## Timeouts
116116

117117
* `--timeout-keep-alive <int>` - Close Keep-Alive connections if no new data is received within this timeout. **Default:** *5*.
118+
* `--timeout-graceful-shutdown <int>` - Maximum number of seconds to wait for graceful shutdown. After this timeout, the server will start terminating requests.

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ rules =
5252
"sys_platform == 'darwin'": py-darwin
5353
"sys_version_info >= (3, 8)": py-gte-38
5454
"sys_version_info < (3, 8)": py-lt-38
55+
"sys_version_info < (3, 9)": py-gte-39
56+
"sys_version_info < (3, 9)": py-lt-39

tests/supervisors/test_signal.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import asyncio
2+
import signal
3+
from asyncio import Event
4+
5+
import httpx
6+
import pytest
7+
8+
from tests.utils import run_server
9+
from uvicorn import Server
10+
from uvicorn.config import Config
11+
12+
13+
@pytest.mark.anyio
14+
async def test_sigint_finish_req(unused_tcp_port: int):
15+
"""
16+
1. Request is sent
17+
2. Sigint is sent to uvicorn
18+
3. Shutdown sequence start
19+
4. Request is finished before timeout_graceful_shutdown=1
20+
21+
Result: Request should go through, even though the server was cancelled.
22+
"""
23+
24+
server_event = Event()
25+
26+
async def wait_app(scope, receive, send):
27+
await send({"type": "http.response.start", "status": 200, "headers": []})
28+
await send({"type": "http.response.body", "body": b"start", "more_body": True})
29+
await server_event.wait()
30+
await send({"type": "http.response.body", "body": b"end", "more_body": False})
31+
32+
config = Config(
33+
app=wait_app, reload=False, port=unused_tcp_port, timeout_graceful_shutdown=1
34+
)
35+
server: Server
36+
async with run_server(config) as server:
37+
async with httpx.AsyncClient() as client:
38+
Event()
39+
req = asyncio.create_task(client.get(f"http://127.0.0.1:{unused_tcp_port}"))
40+
await asyncio.sleep(0.1) # ensure next tick
41+
server.handle_exit(sig=signal.SIGINT, frame=None) # exit
42+
server_event.set() # continue request
43+
# ensure httpx has processed the response and result is complete
44+
await req
45+
assert req.result().status_code == 200
46+
47+
48+
@pytest.mark.anyio
49+
async def test_sigint_abort_req(unused_tcp_port: int, caplog):
50+
"""
51+
1. Request is sent
52+
2. Sigint is sent to uvicorn
53+
3. Shutdown sequence start
54+
4. Request is _NOT_ finished before timeout_graceful_shutdown=1
55+
56+
Result: Request is cancelled mid-execution, and httpx will raise a `RemoteProtocolError`
57+
"""
58+
59+
async def forever_app(scope, receive, send):
60+
server_event = Event()
61+
await send({"type": "http.response.start", "status": 200, "headers": []})
62+
await send({"type": "http.response.body", "body": b"start", "more_body": True})
63+
await server_event.wait() # we never continue this one, so this request will time out
64+
await send({"type": "http.response.body", "body": b"end", "more_body": False})
65+
66+
config = Config(
67+
app=forever_app, reload=False, port=unused_tcp_port, timeout_graceful_shutdown=1
68+
)
69+
server: Server
70+
async with run_server(config) as server:
71+
async with httpx.AsyncClient() as client:
72+
Event()
73+
req = asyncio.create_task(client.get(f"http://127.0.0.1:{unused_tcp_port}"))
74+
await asyncio.sleep(0.1) # next tick
75+
# trigger exit, this request should time out in ~1 sec
76+
server.handle_exit(sig=signal.SIGINT, frame=None)
77+
with pytest.raises(httpx.RemoteProtocolError):
78+
await req
79+
80+
# req.result()
81+
assert (
82+
"Cancel 1 running task(s), timeout graceful shutdown exceeded"
83+
in caplog.messages
84+
)
85+
86+
87+
@pytest.mark.anyio
88+
async def test_sigint_deny_request_after_triggered(unused_tcp_port: int, caplog):
89+
"""
90+
1. Server is started
91+
2. Shutdown sequence start
92+
3. Request is sent, but not accepted
93+
94+
Result: Request should fail, and not be able to be sent, since server is no longer accepting connections
95+
"""
96+
97+
async def app(scope, receive, send):
98+
await send({"type": "http.response.start", "status": 200, "headers": []})
99+
await asyncio.sleep(1)
100+
101+
config = Config(
102+
app=app, reload=False, port=unused_tcp_port, timeout_graceful_shutdown=1
103+
)
104+
server: Server
105+
async with run_server(config) as server:
106+
# exit and ensure we do not accept more requests
107+
server.handle_exit(sig=signal.SIGINT, frame=None)
108+
await asyncio.sleep(0.1) # next tick
109+
async with httpx.AsyncClient() as client:
110+
with pytest.raises(httpx.ConnectError):
111+
await client.get(f"http://127.0.0.1:{unused_tcp_port}")

uvicorn/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def __init__(
230230
backlog: int = 2048,
231231
timeout_keep_alive: int = 5,
232232
timeout_notify: int = 30,
233+
timeout_graceful_shutdown: Optional[int] = None,
233234
callback_notify: Optional[Callable[..., Awaitable[None]]] = None,
234235
ssl_keyfile: Optional[str] = None,
235236
ssl_certfile: Optional[Union[str, os.PathLike]] = None,
@@ -272,6 +273,7 @@ def __init__(
272273
self.backlog = backlog
273274
self.timeout_keep_alive = timeout_keep_alive
274275
self.timeout_notify = timeout_notify
276+
self.timeout_graceful_shutdown = timeout_graceful_shutdown
275277
self.callback_notify = callback_notify
276278
self.ssl_keyfile = ssl_keyfile
277279
self.ssl_certfile = ssl_certfile

uvicorn/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
273273
help="Close Keep-Alive connections if no new data is received within this timeout.",
274274
show_default=True,
275275
)
276+
@click.option(
277+
"--timeout-graceful-shutdown",
278+
type=int,
279+
default=None,
280+
help="Maximum number of seconds to wait for graceful shutdown.",
281+
)
276282
@click.option(
277283
"--ssl-keyfile", type=str, default=None, help="SSL key file", show_default=True
278284
)
@@ -387,6 +393,7 @@ def main(
387393
backlog: int,
388394
limit_max_requests: int,
389395
timeout_keep_alive: int,
396+
timeout_graceful_shutdown: typing.Optional[int],
390397
ssl_keyfile: str,
391398
ssl_certfile: str,
392399
ssl_keyfile_password: str,
@@ -434,6 +441,7 @@ def main(
434441
backlog=backlog,
435442
limit_max_requests=limit_max_requests,
436443
timeout_keep_alive=timeout_keep_alive,
444+
timeout_graceful_shutdown=timeout_graceful_shutdown,
437445
ssl_keyfile=ssl_keyfile,
438446
ssl_certfile=ssl_certfile,
439447
ssl_keyfile_password=ssl_keyfile_password,
@@ -486,6 +494,7 @@ def run(
486494
backlog: int = 2048,
487495
limit_max_requests: typing.Optional[int] = None,
488496
timeout_keep_alive: int = 5,
497+
timeout_graceful_shutdown: typing.Optional[int] = None,
489498
ssl_keyfile: typing.Optional[str] = None,
490499
ssl_certfile: typing.Optional[typing.Union[str, os.PathLike]] = None,
491500
ssl_keyfile_password: typing.Optional[str] = None,
@@ -536,6 +545,7 @@ def run(
536545
backlog=backlog,
537546
limit_max_requests=limit_max_requests,
538547
timeout_keep_alive=timeout_keep_alive,
548+
timeout_graceful_shutdown=timeout_graceful_shutdown,
539549
ssl_keyfile=ssl_keyfile,
540550
ssl_certfile=ssl_certfile,
541551
ssl_keyfile_password=ssl_keyfile_password,

uvicorn/server.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,28 @@ async def shutdown(self, sockets: Optional[List[socket.socket]] = None) -> None:
277277
connection.shutdown()
278278
await asyncio.sleep(0.1)
279279

280+
# When 3.10 is not supported anymore, use `async with asyncio.timeout(...):`.
281+
try:
282+
await asyncio.wait_for(
283+
self._wait_tasks_to_complete(),
284+
timeout=self.config.timeout_graceful_shutdown,
285+
)
286+
except asyncio.TimeoutError:
287+
logger.error(
288+
"Cancel %s running task(s), timeout graceful shutdown exceeded",
289+
len(self.server_state.tasks),
290+
)
291+
for t in self.server_state.tasks:
292+
if sys.version_info < (3, 9): # pragma: py-gte-39
293+
t.cancel()
294+
else: # pragma: py-lt-39
295+
t.cancel(msg="Task cancelled, timeout graceful shutdown exceeded")
296+
297+
# Send the lifespan shutdown event, and wait for application shutdown.
298+
if not self.force_exit:
299+
await self.lifespan.shutdown()
300+
301+
async def _wait_tasks_to_complete(self) -> None:
280302
# Wait for existing connections to finish sending responses.
281303
if self.server_state.connections and not self.force_exit:
282304
msg = "Waiting for connections to close. (CTRL+C to force quit)"
@@ -291,10 +313,6 @@ async def shutdown(self, sockets: Optional[List[socket.socket]] = None) -> None:
291313
while self.server_state.tasks and not self.force_exit:
292314
await asyncio.sleep(0.1)
293315

294-
# Send the lifespan shutdown event, and wait for application shutdown.
295-
if not self.force_exit:
296-
await self.lifespan.shutdown()
297-
298316
def install_signal_handlers(self) -> None:
299317
if threading.current_thread() is not threading.main_thread():
300318
# Signals can only be listened to from the main thread.

0 commit comments

Comments
 (0)