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.
Files changed (131) hide show
  1. {wsbuilder-1.2.0/src/wsbuilder.egg-info → wsbuilder-1.2.1}/PKG-INFO +1 -1
  2. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/pyproject.toml +1 -1
  3. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/__init__.py +1 -1
  4. {wsbuilder-1.2.0 → wsbuilder-1.2.1/src/wsbuilder.egg-info}/PKG-INFO +1 -1
  5. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/SOURCES.txt +1 -0
  6. wsbuilder-1.2.1/tests/test_server_errors.py +423 -0
  7. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/LICENSE +0 -0
  8. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/MANIFEST.in +0 -0
  9. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/README.md +0 -0
  10. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/404.html +0 -0
  11. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/CNAME +0 -0
  12. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/advanced.md +0 -0
  13. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/ai-predict.md +0 -0
  14. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/api-map.md +0 -0
  15. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/app-http.md +0 -0
  16. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/architecture.md +0 -0
  17. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/assets/wsbuilder-mark.svg +0 -0
  18. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/beginners.md +0 -0
  19. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/cache-security.md +0 -0
  20. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/cache.md +0 -0
  21. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/capabilities.md +0 -0
  22. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/data-orm.md +0 -0
  23. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/dns.md +0 -0
  24. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/full-project.md +0 -0
  25. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/getting-started.md +0 -0
  26. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/help/index.md +0 -0
  27. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/http-versions.md +0 -0
  28. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/http.md +0 -0
  29. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/ia.md +0 -0
  30. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/index.md +0 -0
  31. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/install.md +0 -0
  32. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/intermediate.md +0 -0
  33. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/mdns.md +0 -0
  34. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/network-edge.md +0 -0
  35. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/observability-tasks.md +0 -0
  36. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/observability.md +0 -0
  37. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/persistence.md +0 -0
  38. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/proxy.md +0 -0
  39. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/reference/index.md +0 -0
  40. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/security.md +0 -0
  41. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/stylesheets/extra.css +0 -0
  42. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/tasks.md +0 -0
  43. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/tls-certificates.md +0 -0
  44. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/docs/websocket.md +0 -0
  45. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/.gitignore +0 -0
  46. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/README.md +0 -0
  47. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/app.py +0 -0
  48. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/pyproject.toml +0 -0
  49. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/examples/full-project/upstream.py +0 -0
  50. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/mkdocs.yml +0 -0
  51. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/setup.cfg +0 -0
  52. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/__main__.py +0 -0
  53. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/app.py +0 -0
  54. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/cache.py +0 -0
  55. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/caches.py +0 -0
  56. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/constants.py +0 -0
  57. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/cookies.py +0 -0
  58. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/db_replicas.py +0 -0
  59. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/dns.py +0 -0
  60. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/framework.py +0 -0
  61. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/headers.py +0 -0
  62. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/hpack.py +0 -0
  63. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http.py +0 -0
  64. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http1.py +0 -0
  65. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http2.py +0 -0
  66. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http3.py +0 -0
  67. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http3_server.py +0 -0
  68. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/http3_websocket.py +0 -0
  69. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/ia.py +0 -0
  70. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/logs.py +0 -0
  71. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/mdns.py +0 -0
  72. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/metrics.py +0 -0
  73. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/orm.py +0 -0
  74. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/pki.py +0 -0
  75. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/predicts.py +0 -0
  76. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/proxyi.py +0 -0
  77. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/qpack.py +0 -0
  78. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/__init__.py +0 -0
  79. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/address.py +0 -0
  80. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/connection.py +0 -0
  81. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/crypto.py +0 -0
  82. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/frames.py +0 -0
  83. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/packet.py +0 -0
  84. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/recovery.py +0 -0
  85. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/tls.py +0 -0
  86. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/quic/varint.py +0 -0
  87. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/security.py +0 -0
  88. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/server.py +0 -0
  89. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/tasks.py +0 -0
  90. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/ws.py +0 -0
  91. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder/ws_demo.py +0 -0
  92. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
  93. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/entry_points.txt +0 -0
  94. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/requires.txt +0 -0
  95. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/src/wsbuilder.egg-info/top_level.txt +0 -0
  96. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_app_threads.py +0 -0
  97. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_cache.py +0 -0
  98. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_caches.py +0 -0
  99. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_contributing_policy.py +0 -0
  100. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_demo_monitor.py +0 -0
  101. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_dns.py +0 -0
  102. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_docs.py +0 -0
  103. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_documentation_files.py +0 -0
  104. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_headers_cookies.py +0 -0
  105. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_hpack.py +0 -0
  106. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http.py +0 -0
  107. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http1.py +0 -0
  108. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http2.py +0 -0
  109. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3.py +0 -0
  110. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3_server.py +0 -0
  111. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3_streaming.py +0 -0
  112. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_http3_streaming_udp.py +0 -0
  113. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_ia.py +0 -0
  114. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_logs.py +0 -0
  115. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_mdns.py +0 -0
  116. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_metrics.py +0 -0
  117. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_orm.py +0 -0
  118. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_orm_replicas.py +0 -0
  119. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_pki.py +0 -0
  120. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_pr_identity_policy.py +0 -0
  121. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_predicts.py +0 -0
  122. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_proxyi.py +0 -0
  123. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_address.py +0 -0
  124. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_crypto.py +0 -0
  125. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_protocol.py +0 -0
  126. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_recovery.py +0 -0
  127. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_quic_tls.py +0 -0
  128. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_security.py +0 -0
  129. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_server.py +0 -0
  130. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_tasks.py +0 -0
  131. {wsbuilder-1.2.0 → wsbuilder-1.2.1}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wsbuilder
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Lightweight HTTP + WebSocket framework for Python.
5
5
  Author: wsbuilder Authors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wsbuilder"
7
- version = "1.2.0"
7
+ version = "1.2.1"
8
8
  description = "Lightweight HTTP + WebSocket framework for Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -126,7 +126,7 @@ def __getattr__(name):
126
126
  def __dir__():
127
127
  return sorted(set(globals()) | _PKI_EXPORTS)
128
128
 
129
- __version__ = "1.2.0"
129
+ __version__ = "1.2.1"
130
130
 
131
131
  __all__ = [
132
132
  "App",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wsbuilder
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Lightweight HTTP + WebSocket framework for Python.
5
5
  Author: wsbuilder Authors
6
6
  License-Expression: MIT
@@ -124,5 +124,6 @@ tests/test_quic_recovery.py
124
124
  tests/test_quic_tls.py
125
125
  tests/test_security.py
126
126
  tests/test_server.py
127
+ tests/test_server_errors.py
127
128
  tests/test_tasks.py
128
129
  tests/test_ws.py
@@ -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