netizen-cli 0.10.0__py3-none-any.whl
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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
"""Bounded HTTP/1.1 transport for the in-process Admin Web.
|
|
2
|
+
|
|
3
|
+
This module deliberately stops at the transport boundary. Authentication,
|
|
4
|
+
routing, and business operations belong to the Admin Web application layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import errno
|
|
12
|
+
import logging
|
|
13
|
+
import secrets
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Final, TypeAlias
|
|
18
|
+
|
|
19
|
+
import h11
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MAX_ACTIVE_CONNECTIONS: Final = 32
|
|
26
|
+
HEADER_TIMEOUT_SECONDS: Final = 5.0
|
|
27
|
+
BODY_TIMEOUT_SECONDS: Final = 5.0
|
|
28
|
+
KEEPALIVE_TIMEOUT_SECONDS: Final = 15.0
|
|
29
|
+
MAX_REQUEST_OR_HEADER_LINE_BYTES: Final = 8192
|
|
30
|
+
MAX_HEADER_SECTION_BYTES: Final = 32768
|
|
31
|
+
MAX_BODY_BYTES: Final = 65536
|
|
32
|
+
|
|
33
|
+
_READ_CHUNK_BYTES: Final = 16384
|
|
34
|
+
_DRAIN_CANCELLATION_GRACE_SECONDS: Final = 0.05
|
|
35
|
+
_HEADER_TERMINATOR: Final = b"\r\n\r\n"
|
|
36
|
+
_RESERVED_RESPONSE_HEADERS: Final = frozenset(
|
|
37
|
+
{b"connection", b"content-length", b"transfer-encoding"}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
Address: TypeAlias = tuple[object, ...] | str | None
|
|
41
|
+
RawHeaders: TypeAlias = tuple[tuple[bytes, bytes], ...]
|
|
42
|
+
Handler: TypeAlias = Callable[["Request"], Awaitable["Response"]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Request:
|
|
47
|
+
"""One fully buffered, parser-validated HTTP request."""
|
|
48
|
+
|
|
49
|
+
method: bytes
|
|
50
|
+
target: bytes
|
|
51
|
+
http_version: bytes
|
|
52
|
+
raw_headers: RawHeaders
|
|
53
|
+
body: bytes
|
|
54
|
+
peer: Address
|
|
55
|
+
sockname: Address
|
|
56
|
+
request_id: str
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
for value, field_name in (
|
|
60
|
+
(self.method, "method"),
|
|
61
|
+
(self.target, "target"),
|
|
62
|
+
(self.http_version, "http_version"),
|
|
63
|
+
(self.body, "body"),
|
|
64
|
+
):
|
|
65
|
+
if not isinstance(value, bytes):
|
|
66
|
+
raise TypeError(f"request {field_name} must be bytes")
|
|
67
|
+
if not isinstance(self.request_id, str):
|
|
68
|
+
raise TypeError("request_id must be a string")
|
|
69
|
+
object.__setattr__(self, "raw_headers", _freeze_headers(self.raw_headers))
|
|
70
|
+
object.__setattr__(self, "peer", _immutable_address(self.peer))
|
|
71
|
+
object.__setattr__(self, "sockname", _immutable_address(self.sockname))
|
|
72
|
+
|
|
73
|
+
def header_values(self, name: bytes) -> tuple[bytes, ...]:
|
|
74
|
+
lowered = name.lower()
|
|
75
|
+
return tuple(
|
|
76
|
+
value for key, value in self.raw_headers if key.lower() == lowered
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class Response:
|
|
82
|
+
"""A complete response returned by an Admin HTTP handler."""
|
|
83
|
+
|
|
84
|
+
status: int
|
|
85
|
+
headers: RawHeaders = field(default_factory=tuple)
|
|
86
|
+
body: bytes = b""
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
object.__setattr__(self, "headers", _freeze_headers(self.headers))
|
|
90
|
+
if not isinstance(self.body, bytes):
|
|
91
|
+
raise TypeError("response body must be bytes")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AdminHttpState(str, Enum):
|
|
95
|
+
NEW = "new"
|
|
96
|
+
BOUND_CLOSED = "bound_closed"
|
|
97
|
+
CLOSED = "closed"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _RequestRejected(Exception):
|
|
101
|
+
def __init__(self, status: int, code: str) -> None:
|
|
102
|
+
super().__init__(code)
|
|
103
|
+
self.status = status
|
|
104
|
+
self.code = code
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _ClientDisconnected(Exception):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(slots=True, eq=False)
|
|
112
|
+
class _Connection:
|
|
113
|
+
writer: asyncio.StreamWriter
|
|
114
|
+
accepted_at: float
|
|
115
|
+
task: asyncio.Task[None] | None = None
|
|
116
|
+
handler_task: asyncio.Task[Response] | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AdminHttpTransport:
|
|
120
|
+
"""A loop-owned, bounded HTTP transport with explicit admission control.
|
|
121
|
+
|
|
122
|
+
``bind`` reserves the listener but leaves the transport in
|
|
123
|
+
:attr:`AdminHttpState.BOUND_CLOSED`. Call ``open_admission`` only after
|
|
124
|
+
the surrounding application is ready. Shutdown is intentionally split:
|
|
125
|
+
``close`` closes admission and the listener first, while ``drain`` waits
|
|
126
|
+
for tracked connection/handler tasks until an absolute monotonic deadline.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, host: str, port: int, handler: Handler) -> None:
|
|
130
|
+
if not isinstance(host, str) or not host:
|
|
131
|
+
raise ValueError("host must be a non-empty string")
|
|
132
|
+
if not isinstance(port, int) or isinstance(port, bool):
|
|
133
|
+
raise TypeError("port must be an integer")
|
|
134
|
+
if not 0 <= port <= 65535:
|
|
135
|
+
raise ValueError("port must be between 0 and 65535")
|
|
136
|
+
if not callable(handler):
|
|
137
|
+
raise TypeError("handler must be callable")
|
|
138
|
+
|
|
139
|
+
self._host = host
|
|
140
|
+
self._port = port
|
|
141
|
+
self._handler = handler
|
|
142
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
143
|
+
self._server: asyncio.AbstractServer | None = None
|
|
144
|
+
self._closed_servers: list[asyncio.AbstractServer] = []
|
|
145
|
+
self._state = AdminHttpState.NEW
|
|
146
|
+
self._admission_open = False
|
|
147
|
+
self._closing = False
|
|
148
|
+
self._connections: set[_Connection] = set()
|
|
149
|
+
self._connection_tasks: set[asyncio.Task[None]] = set()
|
|
150
|
+
self._handler_tasks: set[asyncio.Task[Response]] = set()
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def state(self) -> AdminHttpState:
|
|
154
|
+
return self._state
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def admission_open(self) -> bool:
|
|
158
|
+
return self._admission_open
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def active_connection_count(self) -> int:
|
|
162
|
+
return len(self._connections)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def active_handler_count(self) -> int:
|
|
166
|
+
return len(self._handler_tasks)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def addresses(self) -> tuple[Address, ...]:
|
|
170
|
+
server = self._server
|
|
171
|
+
if server is None or server.sockets is None:
|
|
172
|
+
return ()
|
|
173
|
+
return tuple(sock.getsockname() for sock in server.sockets)
|
|
174
|
+
|
|
175
|
+
async def bind(self) -> None:
|
|
176
|
+
"""Bind the listener on the current loop with admission still closed."""
|
|
177
|
+
|
|
178
|
+
loop = asyncio.get_running_loop()
|
|
179
|
+
if self._loop is None:
|
|
180
|
+
self._loop = loop
|
|
181
|
+
self._assert_loop()
|
|
182
|
+
if self._state is not AdminHttpState.NEW:
|
|
183
|
+
raise RuntimeError("Admin HTTP transport can only be bound once")
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# asyncio closes every partially bound address if bind fails.
|
|
187
|
+
# Defer serving until we own the returned server so cancellation
|
|
188
|
+
# or listen/start_serving failure can also close every socket.
|
|
189
|
+
server = await asyncio.start_server(
|
|
190
|
+
self._accepted,
|
|
191
|
+
self._host,
|
|
192
|
+
self._port,
|
|
193
|
+
start_serving=False,
|
|
194
|
+
limit=MAX_HEADER_SECTION_BYTES + MAX_BODY_BYTES + 1,
|
|
195
|
+
reuse_port=False,
|
|
196
|
+
)
|
|
197
|
+
self._server = server
|
|
198
|
+
if not server.sockets:
|
|
199
|
+
raise OSError(errno.EADDRNOTAVAIL, "Admin listener has no bound sockets")
|
|
200
|
+
await server.start_serving()
|
|
201
|
+
except BaseException:
|
|
202
|
+
if self._server is not None:
|
|
203
|
+
self._server.close()
|
|
204
|
+
await self._server.wait_closed()
|
|
205
|
+
self._server = None
|
|
206
|
+
self._state = AdminHttpState.CLOSED
|
|
207
|
+
self._closing = True
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
self._state = AdminHttpState.BOUND_CLOSED
|
|
211
|
+
|
|
212
|
+
def open_admission(self) -> None:
|
|
213
|
+
self._assert_loop()
|
|
214
|
+
if self._state is not AdminHttpState.BOUND_CLOSED or self._closing:
|
|
215
|
+
raise RuntimeError("Admin HTTP listener is not available")
|
|
216
|
+
self._admission_open = True
|
|
217
|
+
|
|
218
|
+
def close_admission(self) -> None:
|
|
219
|
+
self._assert_loop()
|
|
220
|
+
self._admission_open = False
|
|
221
|
+
|
|
222
|
+
async def close(self) -> None:
|
|
223
|
+
"""Idempotently close admission and the listener, but not handlers."""
|
|
224
|
+
|
|
225
|
+
self._assert_loop()
|
|
226
|
+
if self._state is AdminHttpState.CLOSED:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
self._admission_open = False
|
|
230
|
+
self._closing = True
|
|
231
|
+
server = self._server
|
|
232
|
+
self._server = None
|
|
233
|
+
if server is not None:
|
|
234
|
+
server.close()
|
|
235
|
+
# Python 3.12's wait_closed() also waits for accepted clients.
|
|
236
|
+
# Retain the server and defer that wait to drain(), so close()
|
|
237
|
+
# remains the listener-first half of shutdown.
|
|
238
|
+
self._closed_servers.append(server)
|
|
239
|
+
self._state = AdminHttpState.CLOSED
|
|
240
|
+
|
|
241
|
+
# Once the listener is closed, sockets which are not executing a
|
|
242
|
+
# handler cannot contribute useful shutdown work. Active handlers get
|
|
243
|
+
# the drain budget and close their socket when they finish.
|
|
244
|
+
for connection in tuple(self._connections):
|
|
245
|
+
if connection.handler_task is None:
|
|
246
|
+
connection.writer.close()
|
|
247
|
+
|
|
248
|
+
async def drain(self, deadline: float) -> None:
|
|
249
|
+
"""Drain tracked work until an absolute ``loop.time()`` deadline.
|
|
250
|
+
|
|
251
|
+
The listener is always closed before waiting. At the deadline all
|
|
252
|
+
remaining transport-owned work is cancelled and every socket is
|
|
253
|
+
closed. Application handlers which shield independently tracked
|
|
254
|
+
native mutations may continue their application-owned work.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
self._assert_loop()
|
|
258
|
+
if not isinstance(deadline, (int, float)) or isinstance(deadline, bool):
|
|
259
|
+
raise TypeError("deadline must be a monotonic timestamp")
|
|
260
|
+
await self.close()
|
|
261
|
+
|
|
262
|
+
tasks = self._all_tasks()
|
|
263
|
+
remaining = max(0.0, deadline - self._owned_loop().time())
|
|
264
|
+
if tasks and remaining > 0:
|
|
265
|
+
cancellation_grace = min(
|
|
266
|
+
_DRAIN_CANCELLATION_GRACE_SECONDS,
|
|
267
|
+
remaining,
|
|
268
|
+
)
|
|
269
|
+
_done, pending = await asyncio.wait(
|
|
270
|
+
tasks,
|
|
271
|
+
timeout=max(0.0, remaining - cancellation_grace),
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
pending = tasks
|
|
275
|
+
|
|
276
|
+
for connection in tuple(self._connections):
|
|
277
|
+
connection.writer.close()
|
|
278
|
+
for task in pending:
|
|
279
|
+
task.cancel()
|
|
280
|
+
if pending:
|
|
281
|
+
# Give cooperative cancellation a chance without allowing an
|
|
282
|
+
# application handler which suppresses cancellation to overrun
|
|
283
|
+
# the caller's absolute deadline.
|
|
284
|
+
await asyncio.sleep(0)
|
|
285
|
+
remaining = max(0.0, deadline - self._owned_loop().time())
|
|
286
|
+
if remaining > 0:
|
|
287
|
+
await asyncio.wait(pending, timeout=remaining)
|
|
288
|
+
remaining = max(0.0, deadline - self._owned_loop().time())
|
|
289
|
+
if remaining > 0:
|
|
290
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
291
|
+
await asyncio.wait_for(
|
|
292
|
+
self._wait_writers_closed(),
|
|
293
|
+
timeout=remaining,
|
|
294
|
+
)
|
|
295
|
+
remaining = max(0.0, deadline - self._owned_loop().time())
|
|
296
|
+
servers, self._closed_servers = self._closed_servers, []
|
|
297
|
+
if servers and remaining > 0:
|
|
298
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
299
|
+
await asyncio.wait_for(
|
|
300
|
+
asyncio.gather(*(server.wait_closed() for server in servers)),
|
|
301
|
+
timeout=remaining,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _assert_loop(self) -> None:
|
|
305
|
+
loop = self._loop
|
|
306
|
+
if loop is None:
|
|
307
|
+
raise RuntimeError("Admin HTTP transport is not bound")
|
|
308
|
+
try:
|
|
309
|
+
running = asyncio.get_running_loop()
|
|
310
|
+
except RuntimeError as exc:
|
|
311
|
+
raise RuntimeError(
|
|
312
|
+
"Admin HTTP lifecycle requires its creator event loop"
|
|
313
|
+
) from exc
|
|
314
|
+
if running is not loop:
|
|
315
|
+
raise RuntimeError(
|
|
316
|
+
"Admin HTTP lifecycle requires its creator event loop"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def _owned_loop(self) -> asyncio.AbstractEventLoop:
|
|
320
|
+
loop = self._loop
|
|
321
|
+
assert loop is not None
|
|
322
|
+
return loop
|
|
323
|
+
|
|
324
|
+
def _accepted(
|
|
325
|
+
self,
|
|
326
|
+
reader: asyncio.StreamReader,
|
|
327
|
+
writer: asyncio.StreamWriter,
|
|
328
|
+
) -> None:
|
|
329
|
+
# The callback runs synchronously on the owner loop. Reserving the
|
|
330
|
+
# slot before creating the task prevents simultaneous accepts from
|
|
331
|
+
# exceeding the application-visible cap.
|
|
332
|
+
if (
|
|
333
|
+
not self._admission_open
|
|
334
|
+
or self._closing
|
|
335
|
+
or len(self._connections) >= MAX_ACTIVE_CONNECTIONS
|
|
336
|
+
):
|
|
337
|
+
# Do not let a flood above the admitted-connection cap create an
|
|
338
|
+
# unbounded task set. StreamWriter.close() flushes the already
|
|
339
|
+
# buffered fixed response while preventing further reads.
|
|
340
|
+
with contextlib.suppress(ConnectionError, OSError, RuntimeError):
|
|
341
|
+
writer.write(_minimal_response_bytes(503, b"service unavailable"))
|
|
342
|
+
writer.close()
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
connection = _Connection(
|
|
346
|
+
writer=writer,
|
|
347
|
+
accepted_at=self._owned_loop().time(),
|
|
348
|
+
)
|
|
349
|
+
self._connections.add(connection)
|
|
350
|
+
task = self._owned_loop().create_task(
|
|
351
|
+
self._serve_connection(connection, reader)
|
|
352
|
+
)
|
|
353
|
+
connection.task = task
|
|
354
|
+
self._track_task(task, self._connection_tasks)
|
|
355
|
+
|
|
356
|
+
async def _serve_connection(
|
|
357
|
+
self,
|
|
358
|
+
connection: _Connection,
|
|
359
|
+
reader: asyncio.StreamReader,
|
|
360
|
+
) -> None:
|
|
361
|
+
writer = connection.writer
|
|
362
|
+
peer = _immutable_address(writer.get_extra_info("peername"))
|
|
363
|
+
sockname = _immutable_address(writer.get_extra_info("sockname"))
|
|
364
|
+
parser = h11.Connection(
|
|
365
|
+
h11.SERVER,
|
|
366
|
+
max_incomplete_event_size=MAX_HEADER_SECTION_BYTES,
|
|
367
|
+
)
|
|
368
|
+
pending = b""
|
|
369
|
+
first_request = True
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
while not self._closing:
|
|
373
|
+
request_id = secrets.token_hex(16)
|
|
374
|
+
try:
|
|
375
|
+
request, pending = await self._read_request(
|
|
376
|
+
reader=reader,
|
|
377
|
+
parser=parser,
|
|
378
|
+
pending=pending,
|
|
379
|
+
first_request=first_request,
|
|
380
|
+
accepted_at=connection.accepted_at,
|
|
381
|
+
peer=peer,
|
|
382
|
+
sockname=sockname,
|
|
383
|
+
request_id=request_id,
|
|
384
|
+
)
|
|
385
|
+
except _ClientDisconnected:
|
|
386
|
+
return
|
|
387
|
+
except asyncio.TimeoutError:
|
|
388
|
+
await self._safe_error(
|
|
389
|
+
parser, writer, 408, b"request timeout", request_id
|
|
390
|
+
)
|
|
391
|
+
return
|
|
392
|
+
except _RequestRejected as exc:
|
|
393
|
+
self._log_rejection(request_id, exc.code)
|
|
394
|
+
await self._safe_error(
|
|
395
|
+
parser,
|
|
396
|
+
writer,
|
|
397
|
+
exc.status,
|
|
398
|
+
_status_body(exc.status),
|
|
399
|
+
request_id,
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
except (h11.RemoteProtocolError, h11.LocalProtocolError):
|
|
403
|
+
self._log_rejection(request_id, "protocol_error")
|
|
404
|
+
await self._safe_error(
|
|
405
|
+
parser, writer, 400, b"bad request", request_id
|
|
406
|
+
)
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
first_request = False
|
|
410
|
+
if self._closing:
|
|
411
|
+
return
|
|
412
|
+
if not self._admission_open:
|
|
413
|
+
await self._safe_error(
|
|
414
|
+
parser,
|
|
415
|
+
writer,
|
|
416
|
+
503,
|
|
417
|
+
b"service unavailable",
|
|
418
|
+
request_id,
|
|
419
|
+
)
|
|
420
|
+
return
|
|
421
|
+
try:
|
|
422
|
+
await _reject_immediately_buffered_pipeline(reader)
|
|
423
|
+
except _ClientDisconnected:
|
|
424
|
+
return
|
|
425
|
+
except _RequestRejected as exc:
|
|
426
|
+
self._log_rejection(request_id, exc.code)
|
|
427
|
+
await self._safe_error(
|
|
428
|
+
parser,
|
|
429
|
+
writer,
|
|
430
|
+
exc.status,
|
|
431
|
+
_status_body(exc.status),
|
|
432
|
+
request_id,
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
force_close = _must_close_request(request)
|
|
436
|
+
handler_task = self._owned_loop().create_task(self._handler(request))
|
|
437
|
+
connection.handler_task = handler_task
|
|
438
|
+
self._track_task(handler_task, self._handler_tasks)
|
|
439
|
+
watcher = self._owned_loop().create_task(reader.read(1))
|
|
440
|
+
|
|
441
|
+
done, _pending_tasks = await asyncio.wait(
|
|
442
|
+
{handler_task, watcher},
|
|
443
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
444
|
+
)
|
|
445
|
+
if watcher in done:
|
|
446
|
+
extra = watcher.result()
|
|
447
|
+
if not handler_task.done():
|
|
448
|
+
handler_task.cancel()
|
|
449
|
+
await asyncio.gather(handler_task, return_exceptions=True)
|
|
450
|
+
connection.handler_task = None
|
|
451
|
+
if extra:
|
|
452
|
+
self._log_rejection(request_id, "pipelined_request")
|
|
453
|
+
await self._safe_error(
|
|
454
|
+
parser, writer, 400, b"bad request", request_id
|
|
455
|
+
)
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
watcher.cancel()
|
|
459
|
+
await asyncio.gather(watcher, return_exceptions=True)
|
|
460
|
+
connection.handler_task = None
|
|
461
|
+
try:
|
|
462
|
+
response = handler_task.result()
|
|
463
|
+
await self._write_response(
|
|
464
|
+
parser,
|
|
465
|
+
writer,
|
|
466
|
+
response,
|
|
467
|
+
force_close=force_close or self._closing,
|
|
468
|
+
)
|
|
469
|
+
except asyncio.CancelledError:
|
|
470
|
+
raise
|
|
471
|
+
except Exception:
|
|
472
|
+
# Never render exception strings or tracebacks into either
|
|
473
|
+
# the response or logs; handler inputs can reach both.
|
|
474
|
+
logger.error(
|
|
475
|
+
"admin HTTP handler failed request_id=%s",
|
|
476
|
+
request_id,
|
|
477
|
+
)
|
|
478
|
+
await self._safe_error(
|
|
479
|
+
parser, writer, 500, b"internal server error", request_id
|
|
480
|
+
)
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
if force_close or self._closing:
|
|
484
|
+
return
|
|
485
|
+
try:
|
|
486
|
+
parser.start_next_cycle()
|
|
487
|
+
except h11.LocalProtocolError:
|
|
488
|
+
return
|
|
489
|
+
pending = b""
|
|
490
|
+
except asyncio.CancelledError:
|
|
491
|
+
handler = connection.handler_task
|
|
492
|
+
if handler is not None and not handler.done():
|
|
493
|
+
handler.cancel()
|
|
494
|
+
await asyncio.gather(handler, return_exceptions=True)
|
|
495
|
+
raise
|
|
496
|
+
except (ConnectionError, OSError, h11.LocalProtocolError):
|
|
497
|
+
return
|
|
498
|
+
finally:
|
|
499
|
+
connection.handler_task = None
|
|
500
|
+
self._connections.discard(connection)
|
|
501
|
+
await _close_writer(writer)
|
|
502
|
+
|
|
503
|
+
async def _read_request(
|
|
504
|
+
self,
|
|
505
|
+
*,
|
|
506
|
+
reader: asyncio.StreamReader,
|
|
507
|
+
parser: h11.Connection,
|
|
508
|
+
pending: bytes,
|
|
509
|
+
first_request: bool,
|
|
510
|
+
accepted_at: float,
|
|
511
|
+
peer: Address,
|
|
512
|
+
sockname: Address,
|
|
513
|
+
request_id: str,
|
|
514
|
+
) -> tuple[Request, bytes]:
|
|
515
|
+
loop = self._owned_loop()
|
|
516
|
+
|
|
517
|
+
if first_request:
|
|
518
|
+
header_deadline = accepted_at + HEADER_TIMEOUT_SECONDS
|
|
519
|
+
elif pending:
|
|
520
|
+
header_deadline = loop.time() + HEADER_TIMEOUT_SECONDS
|
|
521
|
+
else:
|
|
522
|
+
try:
|
|
523
|
+
first_byte = await asyncio.wait_for(
|
|
524
|
+
reader.read(1), timeout=KEEPALIVE_TIMEOUT_SECONDS
|
|
525
|
+
)
|
|
526
|
+
except asyncio.TimeoutError as exc:
|
|
527
|
+
raise _ClientDisconnected from exc
|
|
528
|
+
if not first_byte:
|
|
529
|
+
raise _ClientDisconnected
|
|
530
|
+
pending = first_byte
|
|
531
|
+
header_deadline = loop.time() + HEADER_TIMEOUT_SECONDS
|
|
532
|
+
|
|
533
|
+
header, pending = await self._read_header_section(
|
|
534
|
+
reader, pending, header_deadline
|
|
535
|
+
)
|
|
536
|
+
_validate_raw_header_section(header)
|
|
537
|
+
|
|
538
|
+
parser.receive_data(header)
|
|
539
|
+
event = parser.next_event()
|
|
540
|
+
if not isinstance(event, h11.Request):
|
|
541
|
+
raise _RequestRejected(400, "missing_request")
|
|
542
|
+
_validate_request_event(event)
|
|
543
|
+
|
|
544
|
+
content_length = _content_length(event)
|
|
545
|
+
if content_length > MAX_BODY_BYTES:
|
|
546
|
+
raise _RequestRejected(413, "body_too_large")
|
|
547
|
+
if event.method != b"POST" and content_length:
|
|
548
|
+
raise _RequestRejected(400, "body_not_allowed")
|
|
549
|
+
|
|
550
|
+
body = bytearray()
|
|
551
|
+
body_deadline = loop.time() + BODY_TIMEOUT_SECONDS
|
|
552
|
+
while True:
|
|
553
|
+
next_event = parser.next_event()
|
|
554
|
+
if isinstance(next_event, h11.Data):
|
|
555
|
+
body.extend(next_event.data)
|
|
556
|
+
if len(body) > MAX_BODY_BYTES:
|
|
557
|
+
raise _RequestRejected(413, "body_too_large")
|
|
558
|
+
continue
|
|
559
|
+
if isinstance(next_event, h11.EndOfMessage):
|
|
560
|
+
break
|
|
561
|
+
if next_event is h11.PAUSED:
|
|
562
|
+
raise _RequestRejected(400, "pipelined_request")
|
|
563
|
+
if next_event is not h11.NEED_DATA:
|
|
564
|
+
raise _RequestRejected(400, "invalid_request_event")
|
|
565
|
+
|
|
566
|
+
if pending:
|
|
567
|
+
data, pending = pending, b""
|
|
568
|
+
else:
|
|
569
|
+
data = await _read_before(reader, body_deadline)
|
|
570
|
+
if not data:
|
|
571
|
+
raise _ClientDisconnected
|
|
572
|
+
parser.receive_data(data)
|
|
573
|
+
|
|
574
|
+
parser_pending, _closed = parser.trailing_data
|
|
575
|
+
pending = parser_pending + pending
|
|
576
|
+
if pending:
|
|
577
|
+
# Any bytes following one complete request before its handler is
|
|
578
|
+
# entered are pipelining, including a same-write second request.
|
|
579
|
+
raise _RequestRejected(400, "pipelined_request")
|
|
580
|
+
|
|
581
|
+
request = Request(
|
|
582
|
+
method=event.method,
|
|
583
|
+
target=event.target,
|
|
584
|
+
http_version=event.http_version,
|
|
585
|
+
raw_headers=tuple(event.headers.raw_items()),
|
|
586
|
+
body=bytes(body),
|
|
587
|
+
peer=peer,
|
|
588
|
+
sockname=sockname,
|
|
589
|
+
request_id=request_id,
|
|
590
|
+
)
|
|
591
|
+
return request, b""
|
|
592
|
+
|
|
593
|
+
async def _read_header_section(
|
|
594
|
+
self,
|
|
595
|
+
reader: asyncio.StreamReader,
|
|
596
|
+
pending: bytes,
|
|
597
|
+
deadline: float,
|
|
598
|
+
) -> tuple[bytes, bytes]:
|
|
599
|
+
collected = bytearray(pending)
|
|
600
|
+
while True:
|
|
601
|
+
terminator_at = collected.find(_HEADER_TERMINATOR)
|
|
602
|
+
if terminator_at >= 0:
|
|
603
|
+
end = terminator_at + len(_HEADER_TERMINATOR)
|
|
604
|
+
if end > MAX_HEADER_SECTION_BYTES:
|
|
605
|
+
raise _RequestRejected(431, "headers_too_large")
|
|
606
|
+
return bytes(collected[:end]), bytes(collected[end:])
|
|
607
|
+
if len(collected) > MAX_HEADER_SECTION_BYTES:
|
|
608
|
+
raise _RequestRejected(431, "headers_too_large")
|
|
609
|
+
_validate_incomplete_header_lines(collected)
|
|
610
|
+
data = await _read_before(reader, deadline)
|
|
611
|
+
if not data:
|
|
612
|
+
raise _ClientDisconnected
|
|
613
|
+
collected.extend(data)
|
|
614
|
+
|
|
615
|
+
async def _write_response(
|
|
616
|
+
self,
|
|
617
|
+
parser: h11.Connection,
|
|
618
|
+
writer: asyncio.StreamWriter,
|
|
619
|
+
response: Response,
|
|
620
|
+
*,
|
|
621
|
+
force_close: bool,
|
|
622
|
+
) -> None:
|
|
623
|
+
if not isinstance(response, Response):
|
|
624
|
+
raise TypeError("handler must return Response")
|
|
625
|
+
if not 200 <= response.status <= 599:
|
|
626
|
+
raise ValueError("response status is invalid")
|
|
627
|
+
if response.status in {204, 304} and response.body:
|
|
628
|
+
raise ValueError("response status does not permit a body")
|
|
629
|
+
|
|
630
|
+
headers: list[tuple[bytes, bytes]] = []
|
|
631
|
+
for name, value in response.headers:
|
|
632
|
+
if not isinstance(name, bytes) or not isinstance(value, bytes):
|
|
633
|
+
raise TypeError("response headers must be bytes pairs")
|
|
634
|
+
if name.lower() in _RESERVED_RESPONSE_HEADERS:
|
|
635
|
+
raise ValueError("response contains a transport-owned header")
|
|
636
|
+
headers.append((name, value))
|
|
637
|
+
headers.append((b"Content-Length", str(len(response.body)).encode("ascii")))
|
|
638
|
+
if force_close:
|
|
639
|
+
headers.append((b"Connection", b"close"))
|
|
640
|
+
|
|
641
|
+
head = parser.send(
|
|
642
|
+
h11.Response(status_code=response.status, headers=headers)
|
|
643
|
+
)
|
|
644
|
+
data = parser.send(h11.Data(data=response.body))
|
|
645
|
+
end = parser.send(h11.EndOfMessage())
|
|
646
|
+
writer.write(head + data + end)
|
|
647
|
+
await writer.drain()
|
|
648
|
+
|
|
649
|
+
async def _safe_error(
|
|
650
|
+
self,
|
|
651
|
+
parser: h11.Connection,
|
|
652
|
+
writer: asyncio.StreamWriter,
|
|
653
|
+
status: int,
|
|
654
|
+
body: bytes,
|
|
655
|
+
request_id: str,
|
|
656
|
+
) -> None:
|
|
657
|
+
try:
|
|
658
|
+
if parser.our_state is h11.IDLE:
|
|
659
|
+
await self._write_response(
|
|
660
|
+
parser,
|
|
661
|
+
writer,
|
|
662
|
+
Response(status=status, body=body),
|
|
663
|
+
force_close=True,
|
|
664
|
+
)
|
|
665
|
+
return
|
|
666
|
+
except (h11.ProtocolError, ConnectionError, OSError, RuntimeError):
|
|
667
|
+
pass
|
|
668
|
+
try:
|
|
669
|
+
await self._write_minimal_response(writer, status, body)
|
|
670
|
+
except (ConnectionError, OSError, RuntimeError):
|
|
671
|
+
logger.info(
|
|
672
|
+
"admin HTTP error response unavailable request_id=%s",
|
|
673
|
+
request_id,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
async def _write_minimal_response(
|
|
677
|
+
self,
|
|
678
|
+
writer: asyncio.StreamWriter,
|
|
679
|
+
status: int,
|
|
680
|
+
body: bytes,
|
|
681
|
+
) -> None:
|
|
682
|
+
writer.write(_minimal_response_bytes(status, body))
|
|
683
|
+
await writer.drain()
|
|
684
|
+
|
|
685
|
+
def _log_rejection(self, request_id: str, code: str) -> None:
|
|
686
|
+
logger.info(
|
|
687
|
+
"admin HTTP request rejected request_id=%s code=%s",
|
|
688
|
+
request_id,
|
|
689
|
+
code,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
def _track_task(self, task: asyncio.Task, bucket: set) -> None:
|
|
693
|
+
bucket.add(task)
|
|
694
|
+
|
|
695
|
+
def finished(done: asyncio.Task) -> None:
|
|
696
|
+
bucket.discard(done)
|
|
697
|
+
if not done.cancelled():
|
|
698
|
+
# Retrieve failures even when shutdown races with the normal
|
|
699
|
+
# owner await, preventing unobserved-task diagnostics.
|
|
700
|
+
done.exception()
|
|
701
|
+
|
|
702
|
+
task.add_done_callback(finished)
|
|
703
|
+
|
|
704
|
+
def _all_tasks(self) -> set[asyncio.Task]:
|
|
705
|
+
return {
|
|
706
|
+
*self._connection_tasks,
|
|
707
|
+
*self._handler_tasks,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async def _wait_writers_closed(self) -> None:
|
|
711
|
+
writers = [connection.writer for connection in tuple(self._connections)]
|
|
712
|
+
if writers:
|
|
713
|
+
await asyncio.gather(
|
|
714
|
+
*(_wait_writer_closed(writer) for writer in writers),
|
|
715
|
+
return_exceptions=True,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
async def _read_before(
|
|
720
|
+
reader: asyncio.StreamReader,
|
|
721
|
+
deadline: float,
|
|
722
|
+
) -> bytes:
|
|
723
|
+
loop = asyncio.get_running_loop()
|
|
724
|
+
remaining = deadline - loop.time()
|
|
725
|
+
if remaining <= 0:
|
|
726
|
+
raise asyncio.TimeoutError
|
|
727
|
+
return await asyncio.wait_for(reader.read(_READ_CHUNK_BYTES), timeout=remaining)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
async def _reject_immediately_buffered_pipeline(
|
|
731
|
+
reader: asyncio.StreamReader,
|
|
732
|
+
) -> None:
|
|
733
|
+
"""Reject bytes already queued behind a complete request.
|
|
734
|
+
|
|
735
|
+
h11 exposes bytes delivered to it through ``trailing_data``. A public
|
|
736
|
+
StreamReader probe covers bytes from the same socket write which asyncio
|
|
737
|
+
retained in its own buffer, without reaching into StreamReader internals.
|
|
738
|
+
"""
|
|
739
|
+
|
|
740
|
+
probe = asyncio.create_task(reader.read(1))
|
|
741
|
+
await asyncio.sleep(0)
|
|
742
|
+
if probe.done():
|
|
743
|
+
try:
|
|
744
|
+
data = probe.result()
|
|
745
|
+
except asyncio.CancelledError as exc:
|
|
746
|
+
raise _ClientDisconnected from exc
|
|
747
|
+
if data:
|
|
748
|
+
raise _RequestRejected(400, "pipelined_request")
|
|
749
|
+
raise _ClientDisconnected
|
|
750
|
+
probe.cancel()
|
|
751
|
+
await asyncio.gather(probe, return_exceptions=True)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _validate_incomplete_header_lines(data: bytearray) -> None:
|
|
755
|
+
if b"\x00" in data:
|
|
756
|
+
raise _RequestRejected(400, "nul_byte")
|
|
757
|
+
previous = -1
|
|
758
|
+
for index, byte in enumerate(data):
|
|
759
|
+
if byte == 0x0A and (index == 0 or data[index - 1] != 0x0D):
|
|
760
|
+
raise _RequestRejected(400, "bare_lf")
|
|
761
|
+
if byte == 0x0A:
|
|
762
|
+
if index - previous > MAX_REQUEST_OR_HEADER_LINE_BYTES:
|
|
763
|
+
raise _RequestRejected(431, "header_line_too_large")
|
|
764
|
+
previous = index
|
|
765
|
+
if len(data) - (previous + 1) > MAX_REQUEST_OR_HEADER_LINE_BYTES:
|
|
766
|
+
raise _RequestRejected(431, "header_line_too_large")
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _validate_raw_header_section(header: bytes) -> None:
|
|
770
|
+
data = bytearray(header)
|
|
771
|
+
_validate_incomplete_header_lines(data)
|
|
772
|
+
if not header.endswith(_HEADER_TERMINATOR):
|
|
773
|
+
raise _RequestRejected(400, "incomplete_headers")
|
|
774
|
+
|
|
775
|
+
lines = header[:-4].split(b"\r\n")
|
|
776
|
+
if not lines or not lines[0]:
|
|
777
|
+
raise _RequestRejected(400, "missing_request_line")
|
|
778
|
+
names: list[bytes] = []
|
|
779
|
+
for line in lines[1:]:
|
|
780
|
+
if b":" not in line:
|
|
781
|
+
raise _RequestRejected(400, "invalid_header")
|
|
782
|
+
name, _value = line.split(b":", 1)
|
|
783
|
+
names.append(name.lower())
|
|
784
|
+
if names.count(b"host") != 1:
|
|
785
|
+
raise _RequestRejected(400, "invalid_host_count")
|
|
786
|
+
if names.count(b"content-length") > 1:
|
|
787
|
+
raise _RequestRejected(400, "duplicate_content_length")
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _validate_request_event(event: h11.Request) -> None:
|
|
791
|
+
if event.http_version != b"1.1":
|
|
792
|
+
raise _RequestRejected(505, "unsupported_http_version")
|
|
793
|
+
if event.method == b"CONNECT":
|
|
794
|
+
raise _RequestRejected(405, "connect_not_allowed")
|
|
795
|
+
if event.method not in {b"GET", b"POST"}:
|
|
796
|
+
raise _RequestRejected(405, "method_not_allowed")
|
|
797
|
+
|
|
798
|
+
grouped: dict[bytes, list[bytes]] = {}
|
|
799
|
+
for name, value in event.headers:
|
|
800
|
+
grouped.setdefault(name.lower(), []).append(value)
|
|
801
|
+
host_values = grouped.get(b"host", [])
|
|
802
|
+
if len(host_values) != 1 or not host_values[0]:
|
|
803
|
+
raise _RequestRejected(400, "invalid_host")
|
|
804
|
+
if b"transfer-encoding" in grouped or b"te" in grouped:
|
|
805
|
+
raise _RequestRejected(400, "transfer_encoding_not_allowed")
|
|
806
|
+
if b"content-encoding" in grouped:
|
|
807
|
+
raise _RequestRejected(400, "content_encoding_not_allowed")
|
|
808
|
+
if b"expect" in grouped:
|
|
809
|
+
raise _RequestRejected(417, "expect_not_allowed")
|
|
810
|
+
if b"upgrade" in grouped:
|
|
811
|
+
raise _RequestRejected(400, "upgrade_not_allowed")
|
|
812
|
+
for value in grouped.get(b"connection", []):
|
|
813
|
+
if b"upgrade" in {token.strip().lower() for token in value.split(b",")}:
|
|
814
|
+
raise _RequestRejected(400, "upgrade_not_allowed")
|
|
815
|
+
for value in grouped.get(b"content-type", []):
|
|
816
|
+
media_type = value.split(b";", 1)[0].strip().lower()
|
|
817
|
+
if media_type.startswith(b"multipart/"):
|
|
818
|
+
raise _RequestRejected(415, "multipart_not_allowed")
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _content_length(event: h11.Request) -> int:
|
|
822
|
+
values = [value for name, value in event.headers if name == b"content-length"]
|
|
823
|
+
if not values:
|
|
824
|
+
return 0
|
|
825
|
+
if len(values) != 1:
|
|
826
|
+
raise _RequestRejected(400, "duplicate_content_length")
|
|
827
|
+
value = values[0]
|
|
828
|
+
if not value or not value.isdigit():
|
|
829
|
+
raise _RequestRejected(400, "invalid_content_length")
|
|
830
|
+
try:
|
|
831
|
+
return int(value)
|
|
832
|
+
except ValueError as exc:
|
|
833
|
+
raise _RequestRejected(400, "invalid_content_length") from exc
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _must_close_request(request: Request) -> bool:
|
|
837
|
+
if request.method == b"POST":
|
|
838
|
+
return True
|
|
839
|
+
for value in request.header_values(b"connection"):
|
|
840
|
+
if b"close" in {token.strip().lower() for token in value.split(b",")}:
|
|
841
|
+
return True
|
|
842
|
+
return False
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _freeze_headers(headers: RawHeaders) -> RawHeaders:
|
|
846
|
+
try:
|
|
847
|
+
iterator = iter(headers)
|
|
848
|
+
except TypeError as exc:
|
|
849
|
+
raise TypeError("headers must contain name/value pairs") from exc
|
|
850
|
+
frozen: list[tuple[bytes, bytes]] = []
|
|
851
|
+
for item in iterator:
|
|
852
|
+
try:
|
|
853
|
+
name, value = item
|
|
854
|
+
except (TypeError, ValueError) as exc:
|
|
855
|
+
raise TypeError("headers must contain name/value pairs") from exc
|
|
856
|
+
if not isinstance(name, bytes) or not isinstance(value, bytes):
|
|
857
|
+
raise TypeError("header names and values must be bytes")
|
|
858
|
+
frozen.append((name, value))
|
|
859
|
+
return tuple(frozen)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _immutable_address(value: object) -> Address:
|
|
863
|
+
if isinstance(value, tuple):
|
|
864
|
+
return tuple(value)
|
|
865
|
+
if isinstance(value, str) or value is None:
|
|
866
|
+
return value
|
|
867
|
+
return repr(type(value).__name__)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
async def _close_writer(writer: asyncio.StreamWriter) -> None:
|
|
871
|
+
writer.close()
|
|
872
|
+
await _wait_writer_closed(writer)
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
async def _wait_writer_closed(writer: asyncio.StreamWriter) -> None:
|
|
876
|
+
with contextlib.suppress(ConnectionError, OSError, RuntimeError):
|
|
877
|
+
await writer.wait_closed()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _status_body(status: int) -> bytes:
|
|
881
|
+
return {
|
|
882
|
+
400: b"bad request",
|
|
883
|
+
405: b"method not allowed",
|
|
884
|
+
413: b"payload too large",
|
|
885
|
+
415: b"unsupported media type",
|
|
886
|
+
417: b"expectation failed",
|
|
887
|
+
431: b"request header fields too large",
|
|
888
|
+
505: b"http version not supported",
|
|
889
|
+
}.get(status, b"bad request")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _reason_phrase(status: int) -> bytes:
|
|
893
|
+
return {
|
|
894
|
+
200: b"OK",
|
|
895
|
+
204: b"No Content",
|
|
896
|
+
400: b"Bad Request",
|
|
897
|
+
405: b"Method Not Allowed",
|
|
898
|
+
408: b"Request Timeout",
|
|
899
|
+
413: b"Payload Too Large",
|
|
900
|
+
415: b"Unsupported Media Type",
|
|
901
|
+
417: b"Expectation Failed",
|
|
902
|
+
431: b"Request Header Fields Too Large",
|
|
903
|
+
500: b"Internal Server Error",
|
|
904
|
+
503: b"Service Unavailable",
|
|
905
|
+
505: b"HTTP Version Not Supported",
|
|
906
|
+
}.get(status, b"Error")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _minimal_response_bytes(status: int, body: bytes) -> bytes:
|
|
910
|
+
return (
|
|
911
|
+
b"HTTP/1.1 "
|
|
912
|
+
+ str(status).encode("ascii")
|
|
913
|
+
+ b" "
|
|
914
|
+
+ _reason_phrase(status)
|
|
915
|
+
+ b"\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
|
916
|
+
+ b"Content-Length: "
|
|
917
|
+
+ str(len(body)).encode("ascii")
|
|
918
|
+
+ b"\r\n\r\n"
|
|
919
|
+
+ body
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
__all__ = [
|
|
924
|
+
"AdminHttpState",
|
|
925
|
+
"AdminHttpTransport",
|
|
926
|
+
"BODY_TIMEOUT_SECONDS",
|
|
927
|
+
"HEADER_TIMEOUT_SECONDS",
|
|
928
|
+
"KEEPALIVE_TIMEOUT_SECONDS",
|
|
929
|
+
"MAX_ACTIVE_CONNECTIONS",
|
|
930
|
+
"MAX_BODY_BYTES",
|
|
931
|
+
"MAX_HEADER_SECTION_BYTES",
|
|
932
|
+
"MAX_REQUEST_OR_HEADER_LINE_BYTES",
|
|
933
|
+
"Request",
|
|
934
|
+
"Response",
|
|
935
|
+
]
|