Skip to content

Commit 587d1fa

Browse files
authored
Merge pull request #745 from itamarst/503-when-overloaded
2 parents fdaa24d + fd19874 commit 587d1fa

File tree

3 files changed

+107
-2
lines changed

3 files changed

+107
-2
lines changed

cheroot/server.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,36 @@
101101
)
102102

103103

104+
if sys.version_info[:2] >= (3, 13):
105+
from queue import (
106+
Queue as QueueWithShutdown,
107+
ShutDown as QueueShutDown,
108+
)
109+
else:
110+
111+
class QueueShutDown(Exception):
112+
"""Queue has been shut down."""
113+
114+
class QueueWithShutdown(queue.Queue):
115+
"""Add shutdown() similar to Python 3.13+ Queue."""
116+
117+
_queue_shut_down: bool = False
118+
119+
def shutdown(self, immediate=False):
120+
if immediate:
121+
while True:
122+
try:
123+
self.get_nowait()
124+
except queue.Empty:
125+
break
126+
self._queue_shut_down = True
127+
128+
def get(self, *args, **kwargs):
129+
if self._queue_shut_down:
130+
raise QueueShutDown
131+
return super().get(*args, **kwargs)
132+
133+
104134
IS_WINDOWS = platform.system() == 'Windows'
105135
"""Flag indicating whether the app is running under Windows."""
106136

@@ -1658,6 +1688,8 @@ def __init__(
16581688
self.reuse_port = reuse_port
16591689
self.clear_stats()
16601690

1691+
self._unservicable_conns = QueueWithShutdown()
1692+
16611693
def clear_stats(self):
16621694
"""Reset server stat counters.."""
16631695
self._start_time = None
@@ -1866,8 +1898,39 @@ def prepare(self): # noqa: C901 # FIXME
18661898
self.ready = True
18671899
self._start_time = time.time()
18681900

1901+
def _serve_unservicable(self):
1902+
"""Serve connections we can't handle a 503."""
1903+
while self.ready:
1904+
try:
1905+
conn = self._unservicable_conns.get()
1906+
except QueueShutDown:
1907+
return
1908+
request = HTTPRequest(self, conn)
1909+
try:
1910+
request.simple_response('503 Service Unavailable')
1911+
except (OSError, errors.FatalSSLAlert):
1912+
# We're sending the 503 error to be polite, it it fails that's
1913+
# fine.
1914+
continue
1915+
except Exception as ex:
1916+
# We can't just raise an exception because that will kill this
1917+
# thread, and prevent 503 errors from being sent to future
1918+
# connections.
1919+
self.server.error_log(
1920+
repr(ex),
1921+
level=logging.ERROR,
1922+
traceback=True,
1923+
)
1924+
conn.linger = True
1925+
conn.close()
1926+
18691927
def serve(self):
18701928
"""Serve requests, after invoking :func:`prepare()`."""
1929+
# This thread will handle unservicable connections, as added to
1930+
# self._unservicable_conns queue. It will run forever, until
1931+
# self.stop() tells it to shut down.
1932+
threading.Thread(target=self._serve_unservicable).start()
1933+
18711934
while self.ready and not self.interrupt:
18721935
try:
18731936
self._connections.run(self.expiration_interval)
@@ -2162,8 +2225,7 @@ def process_conn(self, conn):
21622225
try:
21632226
self.requests.put(conn)
21642227
except queue.Full:
2165-
# Just drop the conn. TODO: write 503 back?
2166-
conn.close()
2228+
self._unservicable_conns.put(conn)
21672229

21682230
@property
21692231
def interrupt(self):
@@ -2201,6 +2263,11 @@ def stop(self): # noqa: C901 # FIXME
22012263
return # already stopped
22022264

22032265
self.ready = False
2266+
2267+
# This tells the thread that handles unservicable connections to shut
2268+
# down:
2269+
self._unservicable_conns.shutdown(immediate=True)
2270+
22042271
if self._start_time is not None:
22052272
self._run_time += time.time() - self._start_time
22062273
self._start_time = None

cheroot/test/test_server.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import types
99
import urllib.parse # noqa: WPS301
1010
import uuid
11+
from http import HTTPStatus
1112

1213
import pytest
1314

@@ -570,3 +571,36 @@ def test_threadpool_multistart_validation(monkeypatch):
570571
match='Threadpools can only be started once.',
571572
):
572573
tp.start()
574+
575+
576+
def test_overload_results_in_suitable_http_error(request):
577+
"""A server that can't keep up with requests returns a 503 HTTP error."""
578+
localhost = '127.0.0.1'
579+
httpserver = HTTPServer(
580+
bind_addr=(localhost, EPHEMERAL_PORT),
581+
gateway=Gateway,
582+
)
583+
# Can only handle on request in parallel:
584+
httpserver.requests = ThreadPool(
585+
min=1,
586+
max=1,
587+
accepted_queue_size=1,
588+
accepted_queue_timeout=0,
589+
server=httpserver,
590+
)
591+
592+
httpserver.prepare()
593+
serve_thread = threading.Thread(target=httpserver.serve)
594+
serve_thread.start()
595+
request.addfinalizer(httpserver.stop)
596+
# Stop the thread pool to ensure the queue fills up:
597+
httpserver.requests.stop()
598+
599+
_host, port = httpserver.bind_addr
600+
601+
# Use up the very limited thread pool queue we've set up, so future
602+
# requests fail:
603+
httpserver.requests._queue.put(None)
604+
605+
response = requests.get(f'http://{localhost}:{port}', timeout=20)
606+
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
When load is too high, Cheroot now responds with a 503 Service Unavailable HTTP error.
2+
Previously it silently closed the connection.
3+
4+
-- by :user:`itamarst`

0 commit comments

Comments
 (0)