wsbuilder 1.2.0__tar.gz → 1.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wsbuilder-1.2.0/src/wsbuilder.egg-info → wsbuilder-1.2.1}/PKG-INFO +1 -1
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/pyproject.toml +1 -1
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/__init__.py +1 -1
- {wsbuilder-1.2.0 → wsbuilder-1.2.1/src/wsbuilder.egg-info}/PKG-INFO +1 -1
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/SOURCES.txt +1 -0
- wsbuilder-1.2.1/tests/test_server_errors.py +423 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/LICENSE +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/MANIFEST.in +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/README.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/404.html +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/CNAME +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/advanced.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/ai-predict.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/api-map.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/app-http.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/architecture.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/assets/wsbuilder-mark.svg +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/beginners.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/cache-security.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/cache.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/capabilities.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/data-orm.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/dns.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/full-project.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/getting-started.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/help/index.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/http-versions.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/http.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/ia.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/index.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/install.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/intermediate.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/mdns.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/network-edge.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/observability-tasks.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/observability.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/persistence.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/proxy.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/reference/index.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/security.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/stylesheets/extra.css +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/tasks.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/tls-certificates.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/websocket.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/.gitignore +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/README.md +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/app.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/pyproject.toml +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/upstream.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/mkdocs.yml +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/setup.cfg +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/__main__.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/app.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/cache.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/caches.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/constants.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/cookies.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/db_replicas.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/dns.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/framework.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/headers.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/hpack.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http1.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http2.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http3.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http3_server.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http3_websocket.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/ia.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/logs.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/mdns.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/metrics.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/orm.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/pki.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/predicts.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/proxyi.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/qpack.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/__init__.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/address.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/connection.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/crypto.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/frames.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/packet.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/recovery.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/tls.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/varint.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/security.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/server.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/tasks.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/ws.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/ws_demo.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/entry_points.txt +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/requires.txt +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/top_level.txt +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_app_threads.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_cache.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_caches.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_contributing_policy.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_demo_monitor.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_dns.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_docs.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_documentation_files.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_headers_cookies.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_hpack.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http1.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http2.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3_server.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3_streaming.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3_streaming_udp.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_ia.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_logs.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_mdns.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_metrics.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_orm.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_orm_replicas.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_pki.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_pr_identity_policy.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_predicts.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_proxyi.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_address.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_crypto.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_protocol.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_recovery.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_tls.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_security.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_server.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_tasks.py +0 -0
- {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_ws.py +0 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Failure paths of HTTPServer: malformed input, timeouts, TLS and shutdown."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
import threading
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
from wsbuilder import App, Response
|
|
11
|
+
from wsbuilder.logs import console
|
|
12
|
+
from wsbuilder.metrics import AppMetrics
|
|
13
|
+
from wsbuilder.security import SecurityDecision
|
|
14
|
+
from wsbuilder.server import HTTPServer
|
|
15
|
+
|
|
16
|
+
WS_HEADERS = (
|
|
17
|
+
b"Host: example.test\r\n"
|
|
18
|
+
b"Upgrade: websocket\r\n"
|
|
19
|
+
b"Connection: Upgrade\r\n"
|
|
20
|
+
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
|
21
|
+
b"Sec-WebSocket-Version: 13\r\n"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScriptedConn:
|
|
26
|
+
"""A socket whose reads follow a script of byte chunks and exceptions."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, script, *, fail_send=None):
|
|
29
|
+
self.script = list(script)
|
|
30
|
+
self.sent = []
|
|
31
|
+
self.closed = False
|
|
32
|
+
self.timeouts = []
|
|
33
|
+
self.fail_send = fail_send
|
|
34
|
+
|
|
35
|
+
def recv(self, n):
|
|
36
|
+
if not self.script:
|
|
37
|
+
return b""
|
|
38
|
+
item = self.script.pop(0)
|
|
39
|
+
if isinstance(item, BaseException):
|
|
40
|
+
raise item
|
|
41
|
+
if len(item) > n:
|
|
42
|
+
self.script.insert(0, item[n:])
|
|
43
|
+
item = item[:n]
|
|
44
|
+
return item
|
|
45
|
+
|
|
46
|
+
def sendall(self, data):
|
|
47
|
+
if self.fail_send is not None and self.fail_send(bytes(data)):
|
|
48
|
+
raise BrokenPipeError("peer went away")
|
|
49
|
+
self.sent.append(bytes(data))
|
|
50
|
+
|
|
51
|
+
def settimeout(self, value):
|
|
52
|
+
self.timeouts.append(value)
|
|
53
|
+
|
|
54
|
+
def close(self):
|
|
55
|
+
self.closed = True
|
|
56
|
+
|
|
57
|
+
def __enter__(self):
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def __exit__(self, exc_type, exc, tb):
|
|
61
|
+
self.closed = True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def wire(self):
|
|
66
|
+
return b"".join(self.sent)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class QuietConsole(unittest.TestCase):
|
|
70
|
+
"""Capture framework log lines instead of printing them."""
|
|
71
|
+
|
|
72
|
+
def setUp(self):
|
|
73
|
+
self.log = io.StringIO()
|
|
74
|
+
patcher = patch.object(console, "stream", self.log)
|
|
75
|
+
patcher.start()
|
|
76
|
+
self.addCleanup(patcher.stop)
|
|
77
|
+
|
|
78
|
+
def _app(self):
|
|
79
|
+
app = App()
|
|
80
|
+
self.addCleanup(app.close)
|
|
81
|
+
app.metrics = AppMetrics("errors-test")
|
|
82
|
+
app.calls = []
|
|
83
|
+
|
|
84
|
+
@app.api("/echo", methods=("GET", "POST"))
|
|
85
|
+
def echo(request):
|
|
86
|
+
app.calls.append(request.body)
|
|
87
|
+
return {"ok": True}
|
|
88
|
+
|
|
89
|
+
return app
|
|
90
|
+
|
|
91
|
+
def _serve(self, app, script, **kwargs):
|
|
92
|
+
server = HTTPServer("127.0.0.1", 0, app, **kwargs)
|
|
93
|
+
conn = ScriptedConn(script)
|
|
94
|
+
server.handle_conn(conn, ("127.0.0.1", 1234))
|
|
95
|
+
return conn
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestMalformedRequests(QuietConsole):
|
|
99
|
+
def test_status_codes_for_broken_request_heads(self):
|
|
100
|
+
cases = (
|
|
101
|
+
("slow headers", [b"GET /echo HTTP/1.1\r\n", TimeoutError()], {}, b"408 Request Timeout"),
|
|
102
|
+
(
|
|
103
|
+
"oversized headers",
|
|
104
|
+
[b"GET /echo HTTP/1.1\r\nX-Big: " + b"a" * 200 + b"\r\n\r\n"],
|
|
105
|
+
{"max_request_header_bytes": 64},
|
|
106
|
+
b"431 ",
|
|
107
|
+
),
|
|
108
|
+
("unknown version", [b"GET /echo HTTP/7.0\r\nHost: x\r\n\r\n"], {}, b"505 "),
|
|
109
|
+
("garbage line", [b"NONSENSE\r\n\r\n"], {}, b"400 Bad Request"),
|
|
110
|
+
)
|
|
111
|
+
for name, script, limits, status in cases:
|
|
112
|
+
with self.subTest(name):
|
|
113
|
+
app = self._app()
|
|
114
|
+
conn = self._serve(app, script, **limits)
|
|
115
|
+
self.assertIn(status, conn.wire)
|
|
116
|
+
self.assertEqual(app.calls, [])
|
|
117
|
+
|
|
118
|
+
def test_expectations_other_than_100_continue_are_refused(self):
|
|
119
|
+
app = self._app()
|
|
120
|
+
conn = self._serve(
|
|
121
|
+
app,
|
|
122
|
+
[b"POST /echo HTTP/1.1\r\nHost: x\r\nExpect: magic\r\nContent-Length: 2\r\n\r\nhi"],
|
|
123
|
+
)
|
|
124
|
+
self.assertIn(b"417 Expectation Failed", conn.wire)
|
|
125
|
+
|
|
126
|
+
def test_100_continue_needs_a_declared_body(self):
|
|
127
|
+
app = self._app()
|
|
128
|
+
conn = self._serve(app, [b"POST /echo HTTP/1.1\r\nHost: x\r\nExpect: 100-continue\r\n\r\n"])
|
|
129
|
+
self.assertIn(b"100-continue requires Content-Length", conn.wire)
|
|
130
|
+
self.assertNotIn(b"100 Continue\r\n", conn.wire)
|
|
131
|
+
|
|
132
|
+
def test_100_continue_stops_quietly_when_the_client_is_gone(self):
|
|
133
|
+
for framing in (b"Content-Length: 2\r\n", b"Transfer-Encoding: chunked\r\n"):
|
|
134
|
+
with self.subTest(framing=framing):
|
|
135
|
+
app = self._app()
|
|
136
|
+
server = HTTPServer("127.0.0.1", 0, app)
|
|
137
|
+
conn = ScriptedConn(
|
|
138
|
+
[b"POST /echo HTTP/1.1\r\nHost: x\r\nExpect: 100-continue\r\n" + framing + b"\r\n"],
|
|
139
|
+
fail_send=lambda data: data.startswith(b"HTTP/1.1 100"),
|
|
140
|
+
)
|
|
141
|
+
server.handle_conn(conn, ("127.0.0.1", 1234))
|
|
142
|
+
self.assertEqual(conn.sent, [])
|
|
143
|
+
self.assertEqual(app.calls, [])
|
|
144
|
+
|
|
145
|
+
def test_bodies_that_stall_or_stop_short(self):
|
|
146
|
+
head = b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\n"
|
|
147
|
+
chunked = b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n"
|
|
148
|
+
cases = (
|
|
149
|
+
("fixed length timeout", [head, b"abc", TimeoutError()], b"408 Request Timeout"),
|
|
150
|
+
("fixed length cut off", [head, b"abc"], b"Incomplete Request Body"),
|
|
151
|
+
("chunked timeout", [chunked, b"5\r\nab", TimeoutError()], b"408 Request Timeout"),
|
|
152
|
+
("chunked cut off", [chunked, b"5\r\nab"], b"Incomplete Request Body"),
|
|
153
|
+
("bad chunk size", [chunked, b"zz\r\nab\r\n0\r\n\r\n"], b"400 Bad Request"),
|
|
154
|
+
)
|
|
155
|
+
for name, script, status in cases:
|
|
156
|
+
with self.subTest(name):
|
|
157
|
+
app = self._app()
|
|
158
|
+
conn = self._serve(app, script)
|
|
159
|
+
self.assertIn(status, conn.wire)
|
|
160
|
+
self.assertEqual(app.calls, [])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TestDispatchFailures(QuietConsole):
|
|
164
|
+
def test_a_failing_dispatch_returns_500_and_is_counted(self):
|
|
165
|
+
app = self._app()
|
|
166
|
+
|
|
167
|
+
def explode(_request):
|
|
168
|
+
raise RuntimeError("router broke")
|
|
169
|
+
|
|
170
|
+
app.dispatch = explode
|
|
171
|
+
conn = self._serve(app, [b"GET /echo HTTP/1.1\r\nHost: x\r\n\r\n"])
|
|
172
|
+
|
|
173
|
+
self.assertIn(b"500 Internal Server Error", conn.wire)
|
|
174
|
+
self.assertEqual(app.metrics.total_errors, 1)
|
|
175
|
+
self.assertIn("http_dispatch: router broke", app.metrics.last_error)
|
|
176
|
+
|
|
177
|
+
def test_an_unsendable_response_falls_back_to_500(self):
|
|
178
|
+
app = self._app()
|
|
179
|
+
|
|
180
|
+
@app.view("/bad-header")
|
|
181
|
+
def bad_header(_request):
|
|
182
|
+
response = Response.text("x")
|
|
183
|
+
# Set after construction so only the wire encoder can catch it.
|
|
184
|
+
response.headers["X-Split"] = "a\r\nInjected: yes"
|
|
185
|
+
return response
|
|
186
|
+
|
|
187
|
+
conn = self._serve(app, [b"GET /bad-header HTTP/1.1\r\nHost: x\r\n\r\n"])
|
|
188
|
+
|
|
189
|
+
self.assertIn(b"500 Internal Server Error", conn.wire)
|
|
190
|
+
self.assertNotIn(b"Injected: yes", conn.wire)
|
|
191
|
+
self.assertIn("http_response", app.metrics.last_error)
|
|
192
|
+
|
|
193
|
+
def test_keep_alive_stops_at_the_request_ceiling(self):
|
|
194
|
+
app = self._app()
|
|
195
|
+
request = b"GET /echo HTTP/1.1\r\nHost: x\r\n\r\n"
|
|
196
|
+
conn = self._serve(app, [request * 3], max_keepalive_requests=2)
|
|
197
|
+
self.assertEqual(len(app.calls), 2)
|
|
198
|
+
self.assertEqual(conn.wire.count(b"200 OK"), 2)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class DenyAll:
|
|
202
|
+
def __init__(self):
|
|
203
|
+
self.observed = []
|
|
204
|
+
|
|
205
|
+
def evaluate(self, _request):
|
|
206
|
+
return SecurityDecision(allowed=False, status=403, reason="acl_deny", message="Forbidden")
|
|
207
|
+
|
|
208
|
+
def observe_response(self, _request, status):
|
|
209
|
+
self.observed.append(int(status))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestWebSocketFailures(QuietConsole):
|
|
213
|
+
def _upgrade(self, app, path, headers=WS_HEADERS):
|
|
214
|
+
return self._serve(app, [f"GET {path} HTTP/1.1\r\n".encode() + headers + b"\r\n"])
|
|
215
|
+
|
|
216
|
+
def test_an_unknown_websocket_route_is_404(self):
|
|
217
|
+
app = self._app()
|
|
218
|
+
conn = self._upgrade(app, "/nowhere")
|
|
219
|
+
self.assertIn(b"404 Not Found", conn.wire)
|
|
220
|
+
self.assertEqual(app.metrics.http_status.get("404"), 1)
|
|
221
|
+
|
|
222
|
+
def test_security_can_refuse_an_upgrade(self):
|
|
223
|
+
app = self._app()
|
|
224
|
+
app.security = DenyAll()
|
|
225
|
+
opened = []
|
|
226
|
+
|
|
227
|
+
@app.ws("/ws")
|
|
228
|
+
def handler(_ws, _request):
|
|
229
|
+
opened.append(True)
|
|
230
|
+
|
|
231
|
+
conn = self._upgrade(app, "/ws")
|
|
232
|
+
self.assertIn(b"403 Forbidden", conn.wire)
|
|
233
|
+
self.assertEqual(opened, [])
|
|
234
|
+
self.assertEqual(app.security.observed, [403])
|
|
235
|
+
|
|
236
|
+
def test_a_handshake_without_a_key_is_rejected(self):
|
|
237
|
+
app = self._app()
|
|
238
|
+
|
|
239
|
+
@app.ws("/ws")
|
|
240
|
+
def handler(_ws, _request):
|
|
241
|
+
raise AssertionError("must not run")
|
|
242
|
+
|
|
243
|
+
headers = WS_HEADERS.replace(b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n", b"")
|
|
244
|
+
conn = self._upgrade(app, "/ws", headers)
|
|
245
|
+
self.assertIn(b"400 Bad Request", conn.wire)
|
|
246
|
+
self.assertNotIn(b"101 Switching Protocols", conn.wire)
|
|
247
|
+
|
|
248
|
+
def test_a_crashing_handler_is_logged_and_the_socket_released(self):
|
|
249
|
+
app = self._app()
|
|
250
|
+
|
|
251
|
+
@app.ws("/ws")
|
|
252
|
+
def handler(_ws, _request):
|
|
253
|
+
raise RuntimeError("handler crashed")
|
|
254
|
+
|
|
255
|
+
conn = self._upgrade(app, "/ws")
|
|
256
|
+
|
|
257
|
+
self.assertIn(b"101 Switching Protocols", conn.wire)
|
|
258
|
+
self.assertTrue(conn.closed)
|
|
259
|
+
self.assertIn("ws_handler: handler crashed", app.metrics.last_error)
|
|
260
|
+
self.assertIn("[ws] handler error: handler crashed\nTraceback", self.log.getvalue())
|
|
261
|
+
self.assertEqual(app.metrics.active_ws_connections, 0)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ExplodingContext:
|
|
265
|
+
def __init__(self, error):
|
|
266
|
+
self.error = error
|
|
267
|
+
|
|
268
|
+
def wrap_socket(self, _conn, server_side):
|
|
269
|
+
raise self.error
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class TestTLSFailures(QuietConsole):
|
|
273
|
+
def test_handshake_and_wrap_errors_close_the_connection(self):
|
|
274
|
+
for error, line in (
|
|
275
|
+
(ssl.SSLError("bad handshake"), "[tls] handshake error"),
|
|
276
|
+
(OSError("reset during wrap"), "[tls] wrap error"),
|
|
277
|
+
):
|
|
278
|
+
with self.subTest(error=type(error).__name__):
|
|
279
|
+
app = self._app()
|
|
280
|
+
server = HTTPServer("127.0.0.1", 0, app, ssl_context=ExplodingContext(error))
|
|
281
|
+
conn = ScriptedConn([])
|
|
282
|
+
server.handle_conn(conn, ("127.0.0.1", 1234))
|
|
283
|
+
self.assertTrue(conn.closed)
|
|
284
|
+
self.assertEqual(conn.sent, [])
|
|
285
|
+
self.assertIn(line, self.log.getvalue())
|
|
286
|
+
|
|
287
|
+
def test_a_context_that_cannot_be_resolved_is_counted(self):
|
|
288
|
+
app = self._app()
|
|
289
|
+
|
|
290
|
+
def provider():
|
|
291
|
+
raise RuntimeError("authority expired")
|
|
292
|
+
|
|
293
|
+
server = HTTPServer("127.0.0.1", 0, app, ssl_context=provider)
|
|
294
|
+
conn = ScriptedConn([])
|
|
295
|
+
server.handle_conn(conn, ("127.0.0.1", 1234))
|
|
296
|
+
self.assertTrue(conn.closed)
|
|
297
|
+
self.assertIn("tls_context: authority expired", app.metrics.last_error)
|
|
298
|
+
|
|
299
|
+
def test_an_alpn_probe_that_fails_falls_back_to_http1(self):
|
|
300
|
+
app = self._app()
|
|
301
|
+
server = HTTPServer("127.0.0.1", 0, app)
|
|
302
|
+
conn = ScriptedConn([b"GET /echo HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"])
|
|
303
|
+
|
|
304
|
+
def broken_alpn():
|
|
305
|
+
raise ssl.SSLError("no ALPN state")
|
|
306
|
+
|
|
307
|
+
conn.selected_alpn_protocol = broken_alpn
|
|
308
|
+
server.handle_conn(conn, ("127.0.0.1", 1234))
|
|
309
|
+
self.assertIn(b"200 OK", conn.wire)
|
|
310
|
+
|
|
311
|
+
def test_a_crashing_http2_connection_is_logged(self):
|
|
312
|
+
app = self._app()
|
|
313
|
+
server = HTTPServer("127.0.0.1", 0, app)
|
|
314
|
+
conn = ScriptedConn([b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"])
|
|
315
|
+
with patch("wsbuilder.http2.Http2Connection.serve", side_effect=RuntimeError("h2 broke")):
|
|
316
|
+
server.handle_conn(conn, ("127.0.0.1", 1234))
|
|
317
|
+
self.assertIn("[http2] connection error", self.log.getvalue())
|
|
318
|
+
self.assertIn("http2: h2 broke", app.metrics.last_error)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class TestServeForeverFailures(QuietConsole):
|
|
322
|
+
def _run(self, server):
|
|
323
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
324
|
+
thread.start()
|
|
325
|
+
self.addCleanup(thread.join, 5.0)
|
|
326
|
+
self.addCleanup(server.stop)
|
|
327
|
+
self.assertTrue(server.wait_until_serving(timeout=5.0))
|
|
328
|
+
return thread
|
|
329
|
+
|
|
330
|
+
def _get(self, address):
|
|
331
|
+
with socket.create_connection(address, timeout=5.0) as sock:
|
|
332
|
+
sock.sendall(b"GET /echo HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
|
333
|
+
received = b""
|
|
334
|
+
while chunk := sock.recv(4096):
|
|
335
|
+
received += chunk
|
|
336
|
+
return received
|
|
337
|
+
|
|
338
|
+
def test_failing_startup_hooks_and_close_do_not_stop_the_server(self):
|
|
339
|
+
app = self._app()
|
|
340
|
+
|
|
341
|
+
def broken_hook():
|
|
342
|
+
raise RuntimeError("hook failed")
|
|
343
|
+
|
|
344
|
+
def broken_close():
|
|
345
|
+
raise RuntimeError("close failed")
|
|
346
|
+
|
|
347
|
+
app.add_startup(broken_hook)
|
|
348
|
+
app.close = broken_close
|
|
349
|
+
server = HTTPServer("127.0.0.1", 0, app)
|
|
350
|
+
thread = self._run(server)
|
|
351
|
+
|
|
352
|
+
self.assertIn(b"200 OK", self._get(server.server_address))
|
|
353
|
+
server.stop()
|
|
354
|
+
thread.join(5.0)
|
|
355
|
+
|
|
356
|
+
self.assertFalse(thread.is_alive())
|
|
357
|
+
output = self.log.getvalue()
|
|
358
|
+
self.assertIn("[startup] error: hook failed", output)
|
|
359
|
+
self.assertIn("[shutdown] app.close() error: close failed", output)
|
|
360
|
+
|
|
361
|
+
def test_connections_beyond_the_worker_limit_are_closed(self):
|
|
362
|
+
app = self._app()
|
|
363
|
+
release = threading.Event()
|
|
364
|
+
started = threading.Event()
|
|
365
|
+
|
|
366
|
+
@app.api("/hold", methods=("GET",))
|
|
367
|
+
def hold(_request):
|
|
368
|
+
started.set()
|
|
369
|
+
release.wait(5.0)
|
|
370
|
+
return {"held": True}
|
|
371
|
+
|
|
372
|
+
server = HTTPServer("127.0.0.1", 0, app, max_connection_workers=1)
|
|
373
|
+
server.ACQUIRE_WORKER_TIMEOUT_SECONDS = 0.05
|
|
374
|
+
self._run(server)
|
|
375
|
+
|
|
376
|
+
holder = socket.create_connection(server.server_address, timeout=5.0)
|
|
377
|
+
self.addCleanup(holder.close)
|
|
378
|
+
holder.sendall(b"GET /hold HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
|
379
|
+
self.assertTrue(started.wait(5.0))
|
|
380
|
+
|
|
381
|
+
with socket.create_connection(server.server_address, timeout=5.0) as extra:
|
|
382
|
+
extra.sendall(b"GET /echo HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
383
|
+
try:
|
|
384
|
+
refused = extra.recv(4096)
|
|
385
|
+
except ConnectionResetError:
|
|
386
|
+
refused = b""
|
|
387
|
+
self.assertEqual(refused, b"")
|
|
388
|
+
release.set()
|
|
389
|
+
self.assertIn(b"200 OK", holder.recv(4096))
|
|
390
|
+
|
|
391
|
+
def test_ctrl_c_stops_the_loop_and_closes_the_app(self):
|
|
392
|
+
app = self._app()
|
|
393
|
+
closed = []
|
|
394
|
+
app.close = lambda: closed.append(True)
|
|
395
|
+
server = HTTPServer("127.0.0.1", 0, app)
|
|
396
|
+
|
|
397
|
+
class InterruptingListener:
|
|
398
|
+
def __init__(self, real):
|
|
399
|
+
self.real = real
|
|
400
|
+
|
|
401
|
+
def accept(self):
|
|
402
|
+
raise KeyboardInterrupt
|
|
403
|
+
|
|
404
|
+
def __getattr__(self, name):
|
|
405
|
+
return getattr(self.real, name)
|
|
406
|
+
|
|
407
|
+
original = server._create_listening_socket
|
|
408
|
+
server._create_listening_socket = lambda: InterruptingListener(original())
|
|
409
|
+
server.serve_forever()
|
|
410
|
+
|
|
411
|
+
self.assertEqual(closed, [True])
|
|
412
|
+
self.assertIn("[shutdown] server stopped.", self.log.getvalue())
|
|
413
|
+
|
|
414
|
+
def test_connections_still_open_after_the_timeout_are_reported(self):
|
|
415
|
+
server = HTTPServer("127.0.0.1", 0, self._app(), graceful_shutdown_timeout=0)
|
|
416
|
+
stuck = threading.Thread(target=threading.Event().wait, args=(0.5,), daemon=True)
|
|
417
|
+
stuck.start()
|
|
418
|
+
server._workers.add(stuck)
|
|
419
|
+
self.assertEqual(server._drain_workers(), 1)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
if __name__ == "__main__":
|
|
423
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|