rolo 0.1.0.dev0__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.
rolo/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .request import Request
2
+ from .resource import Resource, resource
3
+ from .response import Response
4
+ from .router import Router, route
5
+
6
+ __all__ = ["route", "resource", "Resource", "Router", "Response", "Request"]
rolo/asgi.py ADDED
@@ -0,0 +1,628 @@
1
+ """This module contains code to make ASGI play nice with WSGI."""
2
+ import asyncio
3
+ import io
4
+ import logging
5
+ import math
6
+ import typing as t
7
+ from asyncio import AbstractEventLoop
8
+ from concurrent.futures import Executor
9
+ from io import BufferedReader, RawIOBase
10
+ from urllib.parse import quote, unquote, urlparse
11
+
12
+ if t.TYPE_CHECKING:
13
+ from _typeshed import WSGIApplication, WSGIEnvironment
14
+ from hypercorn.typing import (
15
+ ASGIReceiveCallable,
16
+ ASGISendCallable,
17
+ HTTPScope,
18
+ Scope,
19
+ WebsocketAcceptEvent,
20
+ WebsocketCloseEvent,
21
+ WebsocketConnectEvent,
22
+ WebsocketDisconnectEvent,
23
+ WebsocketReceiveEvent,
24
+ WebsocketResponseBodyEvent,
25
+ WebsocketResponseStartEvent,
26
+ WebsocketScope,
27
+ WebsocketSendEvent,
28
+ )
29
+
30
+ _WebsocketResponse = t.Union[
31
+ WebsocketAcceptEvent,
32
+ WebsocketSendEvent,
33
+ WebsocketResponseStartEvent,
34
+ WebsocketResponseBodyEvent,
35
+ WebsocketCloseEvent,
36
+ ]
37
+
38
+ _WebsocketRequest = t.Union[
39
+ WebsocketConnectEvent,
40
+ WebsocketReceiveEvent,
41
+ WebsocketDisconnectEvent,
42
+ ]
43
+
44
+ LOG = logging.getLogger(__name__)
45
+
46
+ WebSocketEnvironment: t.TypeAlias = t.Dict[str, t.Any]
47
+ """Special WSGIEnvironment that has an `asgi.websocket` key that stores a `Websocket` instance."""
48
+
49
+
50
+ def populate_wsgi_environment(
51
+ environ: t.Union["WSGIEnvironment", WebSocketEnvironment],
52
+ scope: t.Union["HTTPScope", "WebsocketScope"],
53
+ ):
54
+ """
55
+ Adds the non-IO parts (e.g., excluding wsgi.input) from the ASGI HTTPScope to the WSGI Environment. See
56
+ WSGI Compatibility for more information on why this works:
57
+ https://asgi.readthedocs.io/en/latest/specs/www.html#wsgi-compatibility
58
+
59
+ :param environ: the WSGI environment to populate
60
+ :param scope: the ASGI scope as source
61
+ """
62
+ environ["REQUEST_METHOD"] = scope.get("method", "GET")
63
+ # path/uri info
64
+ # prepare the paths for the "WSGI decoding dance" done by werkzeug
65
+ environ["SCRIPT_NAME"] = unquote(quote(scope.get("root_path", "").rstrip("/")), "latin-1")
66
+
67
+ path = scope["path"]
68
+ path = path if path[0] == "/" else urlparse(path).path
69
+ environ["PATH_INFO"] = unquote(quote(path), "latin-1")
70
+
71
+ query_string = scope.get("query_string")
72
+ if query_string:
73
+ raw_uri = scope["raw_path"] + b"?" + query_string
74
+ environ["QUERY_STRING"] = query_string.decode("latin1")
75
+ else:
76
+ raw_uri = scope["raw_path"]
77
+ environ["QUERY_STRING"] = ""
78
+
79
+ environ["RAW_URI"] = environ["REQUEST_URI"] = raw_uri.decode("utf-8")
80
+
81
+ # server address / host
82
+ server = scope.get("server") or ("localhost", 80)
83
+ environ["SERVER_NAME"] = server[0]
84
+ environ["SERVER_PORT"] = str(server[1]) if server[1] else "80"
85
+
86
+ # http version
87
+ environ["SERVER_PROTOCOL"] = "HTTP/" + scope["http_version"]
88
+
89
+ # client (remote) address
90
+ client = scope.get("client")
91
+ if client:
92
+ environ["REMOTE_ADDR"] = client[0]
93
+ environ["REMOTE_PORT"] = str(client[1])
94
+
95
+ # headers
96
+ for name, value in scope["headers"]:
97
+ key = name.decode("latin1").upper().replace("-", "_")
98
+
99
+ if key not in ["CONTENT_TYPE", "CONTENT_LENGTH"]:
100
+ key = f"HTTP_{key}"
101
+
102
+ environ[key] = value.decode("latin1")
103
+
104
+ # wsgi specific keys
105
+ environ["wsgi.version"] = (1, 0)
106
+ environ["wsgi.url_scheme"] = scope.get("scheme", "http")
107
+ environ["wsgi.errors"] = io.BytesIO()
108
+ environ["wsgi.multithread"] = True
109
+ environ["wsgi.multiprocess"] = False
110
+ environ["wsgi.run_once"] = False
111
+
112
+ # asgi.headers: a custom key to allow downstream applications to circumvent WSGI header processing. these headers
113
+ # should preserve the original casing as the client sends them.
114
+ headers = scope.get("headers")
115
+ environ["asgi.headers"] = headers
116
+
117
+
118
+ class _AsyncGeneratorWrapper:
119
+ def __init__(
120
+ self,
121
+ it: t.Iterator,
122
+ loop: t.Optional[AbstractEventLoop] = None,
123
+ executor: t.Optional[Executor] = None,
124
+ ):
125
+ """
126
+ Wraps a given synchronous Iterator as an async generator, where each invocation to ``next(it)``
127
+ will be wrapped in a coroutine execution.
128
+
129
+ :param it: the iterator to wrap
130
+ :param loop: the event loop to run the next invocations
131
+ :param executor: the executor to run the synchronous code
132
+ """
133
+ self.it = it
134
+ self.loop = loop or asyncio.get_event_loop()
135
+ self.executor = executor
136
+
137
+ def _next_sync(self):
138
+ try:
139
+ return next(self.it)
140
+ except StopIteration:
141
+ raise StopAsyncIteration
142
+
143
+ def __aiter__(self):
144
+ return self
145
+
146
+ async def __anext__(self):
147
+ val = await self.loop.run_in_executor(self.executor, self._next_sync)
148
+ return val
149
+
150
+ async def aclose(self):
151
+ if close := getattr(self.it, "close", None):
152
+ return await self.loop.run_in_executor(self.executor, close)
153
+
154
+
155
+ def create_wsgi_input(
156
+ receive: "ASGIReceiveCallable", event_loop: t.Optional[AbstractEventLoop] = None
157
+ ) -> t.IO[bytes]:
158
+ """
159
+ Factory for exposing an ASGIReceiveCallable as an IO stream.
160
+
161
+ :param receive: the receive callable
162
+ :param event_loop: the event loop used by the event stream adapter
163
+ :return: a new IO stream that wraps the given receive callable.
164
+ """
165
+ return BufferedReader(RawHTTPRequestEventStreamAdapter(receive, event_loop))
166
+
167
+
168
+ class RawHTTPRequestEventStreamAdapter(RawIOBase):
169
+ """
170
+ An adapter to expose an ASGIReceiveCallable coroutine that returns HTTPRequestEvent instances as an IO
171
+ stream for synchronous WSGI/Werkzeug code. The adapter is a Raw IO stream, meaning it does not have
172
+ optimized ``read``, ``readline``, or ``readlines`` methods. Make sure to use a ``BufferedReader`` around
173
+ the stream adapter.
174
+ """
175
+
176
+ def __init__(
177
+ self, receive: "ASGIReceiveCallable", event_loop: t.Optional[AbstractEventLoop] = None
178
+ ) -> None:
179
+ super().__init__()
180
+ self.receive = receive
181
+ self.event_loop = event_loop or asyncio.get_event_loop()
182
+
183
+ # internal state
184
+ self._more_body = True
185
+ self._buffered_body = None
186
+ self._buffered_body_pos = 0
187
+
188
+ def readable(self) -> bool:
189
+ return True
190
+
191
+ def readinto(self, buf: bytearray | memoryview) -> int:
192
+ if not self._more_body:
193
+ return 0
194
+
195
+ # max bytes we can write into the buffer
196
+ buf_size = len(buf)
197
+
198
+ # _buffered_body holds the carry-over of what we didn't read in the last iteration
199
+ if self._buffered_body is None:
200
+ # read from the underlying socket stream
201
+ recv_future = asyncio.run_coroutine_threadsafe(self.receive(), self.event_loop)
202
+ event = recv_future.result()
203
+ # TODO: disconnect events
204
+ more = event.get("more_body", False)
205
+
206
+ if not more:
207
+ self._more_body = False
208
+ return 0
209
+
210
+ body = self._buffered_body = event["body"]
211
+ pos = self._buffered_body_pos = 0
212
+ else:
213
+ body = self._buffered_body
214
+ pos = self._buffered_body_pos
215
+
216
+ remaining = len(body) - pos
217
+
218
+ if remaining <= buf_size:
219
+ # the easiest case, where we write the entire remaining event body into the buffer. we may return
220
+ # less than the buffer size allows, but that's ok for raw IO streams.
221
+ buf[:remaining] = body[pos:]
222
+ self._buffered_body = None
223
+ return remaining
224
+
225
+ # in this case, we can read at max buf_size from the body into the buffer, and need to save the
226
+ # rest for the next call
227
+ buf[:buf_size] = body[pos : pos + buf_size]
228
+ self._buffered_body_pos = pos + buf_size
229
+
230
+ return buf_size
231
+
232
+
233
+ class WsgiStartResponse:
234
+ """
235
+ A wrapper that exposes an async ``ASGISendCallable`` as synchronous a WSGI ``StartResponse`` protocol callable.
236
+ See this stackoverflow post for a good explanation: https://stackoverflow.com/a/16775731/804840.
237
+ """
238
+
239
+ def __init__(
240
+ self,
241
+ send: "ASGISendCallable",
242
+ event_loop: AbstractEventLoop = None,
243
+ ):
244
+ self.send = send
245
+ self.event_loop = event_loop or asyncio.get_event_loop()
246
+ self.sent = 0
247
+ self.content_length = math.inf
248
+ self.finalized = False
249
+ self.started = False
250
+
251
+ def __call__(
252
+ self, status: str, headers: t.List[t.Tuple[str, str]], exec_info=None
253
+ ) -> t.Callable[[bytes], t.Any]:
254
+ return self.start_response_sync(status, headers, exec_info)
255
+
256
+ def start_response_sync(
257
+ self, status: str, headers: t.List[t.Tuple[str, str]], exec_info=None
258
+ ) -> t.Callable[[bytes], t.Any]:
259
+ """
260
+ The WSGI start_response protocol.
261
+
262
+ :param status: the HTTP status (e.g., ``200 OK``) to write
263
+ :param headers: the HTTP headers to write
264
+ :param exec_info: ignored
265
+ :return: a callable that lets you write bytes to the response body
266
+ """
267
+ send = self.send
268
+ loop = self.event_loop
269
+
270
+ # start sending response
271
+ asyncio.run_coroutine_threadsafe(
272
+ send(
273
+ {
274
+ "type": "http.response.start",
275
+ "status": int(status[:3]),
276
+ "headers": [(h[0].encode("latin1"), h[1].encode("latin1")) for h in headers],
277
+ }
278
+ ),
279
+ loop,
280
+ ).result()
281
+
282
+ self.started = True
283
+ # find out content length if set
284
+ self.content_length = math.inf # unknown content-length
285
+ for k, v in headers:
286
+ if k.lower() == "content-length":
287
+ self.content_length = int(v)
288
+ break
289
+
290
+ return self.write_sync
291
+
292
+ def write_sync(self, data: bytes) -> None:
293
+ return asyncio.run_coroutine_threadsafe(self.write(data), self.event_loop).result()
294
+
295
+ async def write(self, data: bytes) -> None:
296
+ if not self.started:
297
+ raise ValueError("not started the response yet")
298
+ if getattr(self.send.__self__, "closed", None):
299
+ # the connection has been closed from the client side, set finalized=True to avoid sending more responses
300
+ self.finalized = True
301
+ raise BrokenPipeError("Connection closed")
302
+ await self.send({"type": "http.response.body", "body": data, "more_body": True})
303
+ self.sent += len(data)
304
+ if self.sent >= self.content_length:
305
+ await self.close()
306
+
307
+ async def close(self):
308
+ if not self.started:
309
+ raise ValueError("not started the response yet")
310
+
311
+ if not self.finalized:
312
+ self.finalized = True
313
+ await self.send({"type": "http.response.body", "body": b"", "more_body": False})
314
+
315
+
316
+ class ASGILifespanListener:
317
+ """
318
+ Simple event handler that is attached to the ASGIAdapter and called on ASGI lifespan events. See
319
+ https://asgi.readthedocs.io/en/latest/specs/lifespan.html.
320
+ """
321
+
322
+ def on_startup(self):
323
+ pass
324
+
325
+ def on_shutdown(self):
326
+ pass
327
+
328
+
329
+ class ASGIWebSocket:
330
+ """
331
+ A wrapper around an ASGI ``WebsocketScope`` and relevant IO objects that can be used to interact with the websocket
332
+ in synchronous code.
333
+
334
+ For send and receive event formats, see https://asgi.readthedocs.io/en/latest/specs/www.html#websocket.
335
+ """
336
+
337
+ _scope: "WebsocketScope"
338
+ _receive: "ASGIReceiveCallable"
339
+ _send: "ASGISendCallable"
340
+
341
+ def __init__(
342
+ self,
343
+ scope: "WebsocketScope",
344
+ receive: "ASGIReceiveCallable",
345
+ send: "ASGISendCallable",
346
+ loop: AbstractEventLoop,
347
+ ):
348
+ self._scope = scope
349
+ self._receive = receive
350
+ self._send = send
351
+ self._loop = loop
352
+
353
+ async def send_async(self, event: "_WebsocketResponse"):
354
+ await self._send(event)
355
+
356
+ async def receive_async(self) -> "_WebsocketRequest":
357
+ return await self._receive()
358
+
359
+ def send(self, event: "_WebsocketResponse", timeout: float = None) -> None:
360
+ """
361
+ Sends an event to the Websocket. Events can be:
362
+
363
+ - websocket.accept: https://asgi.readthedocs.io/en/latest/specs/www.html#accept-send-event
364
+ - websocket.send: https://asgi.readthedocs.io/en/latest/specs/www.html#send-send-event
365
+ - websocket.close: https://asgi.readthedocs.io/en/latest/specs/www.html#close-send-event
366
+
367
+ :param event: The event to send
368
+ :param timeout: The number of seconds to wait for the result of the async call
369
+ """
370
+ return asyncio.run_coroutine_threadsafe(self.send_async(event), self._loop).result(
371
+ timeout=timeout
372
+ )
373
+
374
+ def receive(self, timeout: float = None) -> "_WebsocketRequest":
375
+ """
376
+ Listens on the websocket and returns the next event. Events can be:
377
+
378
+ - websocket.connect: https://asgi.readthedocs.io/en/latest/specs/www.html#connect-receive-event
379
+ - websocket.receive: https://asgi.readthedocs.io/en/latest/specs/www.html#receive-receive-event
380
+ - websocket.disconnect: https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event-ws
381
+
382
+ :param timeout: The number of seconds to wait for the event
383
+ :return: The received event
384
+ """
385
+ return asyncio.run_coroutine_threadsafe(self.receive_async(), self._loop).result(timeout)
386
+
387
+ def respond(
388
+ self, status: int, headers: list[tuple[str, str]] = None, body: t.Iterable[bytes] = None
389
+ ):
390
+ self.send(
391
+ {
392
+ "type": "websocket.http.response.start",
393
+ "status": status,
394
+ "headers": [(h[0].encode("latin1"), h[1].encode("latin1")) for h in headers],
395
+ }
396
+ )
397
+ if body:
398
+ for chunk in body:
399
+ self.send(
400
+ {
401
+ "type": "websocket.http.response.body",
402
+ "body": chunk,
403
+ "more_body": True,
404
+ }
405
+ )
406
+ self.send(
407
+ {
408
+ "type": "websocket.http.response.body",
409
+ "body": b"",
410
+ "more_body": False,
411
+ }
412
+ )
413
+
414
+
415
+ class WebSocketListener(t.Protocol):
416
+ """
417
+ Similar protocol to a WSGIApplication, only it expects a Websocket instead of a WSGIEnvironment.
418
+ """
419
+
420
+ def __call__(self, environ: WebSocketEnvironment):
421
+ """
422
+ Called when a new Websocket connection is established. To initiate the connection, you need to perform the
423
+ connect handshake yourself. First, receive the ``websocket.connect`` event, and then send the
424
+ ``websocket.accept`` event. Here's a minimal example::
425
+
426
+ def accept(self, environ: WebsocketEnvironment):
427
+ websocket = environ['asgi.websocket']
428
+ event = websocket.receive()
429
+ if event['type'] == "websocket.connect":
430
+ websocket.send({
431
+ "type": "websocket.accept",
432
+ "subprotocol": None,
433
+ "headers": [],
434
+ })
435
+ else:
436
+ websocket.send({
437
+ "type": "websocket.close",
438
+ "code": 1002, # protocol error
439
+ "reason": None,
440
+ })
441
+ return
442
+
443
+ while True:
444
+ event = websocket.receive()
445
+ if event["type"] == "websocket.disconnect":
446
+ return
447
+ print(event)
448
+
449
+ :param environ: The new Websocket environment
450
+ """
451
+ raise NotImplementedError
452
+
453
+
454
+ class ASGIAdapter:
455
+ """
456
+ Adapter to expose a WSGIApplication as an ASGI3Application. This allows you to serve synchronous WSGI applications
457
+ through ASGI servers (e.g., Hypercorn).
458
+
459
+ IMPORTANT: The ASGIAdapter needs to use the same event loop as the underlying server. If you pass a new event
460
+ loop to the server, you need to also pass it to the ASGIAdapter.
461
+
462
+ https://asgi.readthedocs.io/en/latest/specs/main.html
463
+ """
464
+
465
+ def __init__(
466
+ self,
467
+ wsgi_app: "WSGIApplication",
468
+ event_loop: AbstractEventLoop = None,
469
+ executor: Executor = None,
470
+ lifespan_listener: ASGILifespanListener = None,
471
+ websocket_listener: WebSocketListener = None,
472
+ ):
473
+ self.wsgi_app = wsgi_app
474
+ self.event_loop = event_loop or asyncio.get_event_loop()
475
+ self.executor = executor
476
+ self.lifespan_listener = lifespan_listener or ASGILifespanListener()
477
+ self.websocket_listener = websocket_listener
478
+
479
+ async def __call__(
480
+ self, scope: "Scope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
481
+ ):
482
+ """
483
+ The ASGI 3 interface. Can only handle HTTP calls.
484
+
485
+ :param scope: the connection scope
486
+ :param receive: the receive callable
487
+ :param send: the send callable
488
+ """
489
+ if scope["type"] == "http":
490
+ return await self.handle_http(scope, receive, send)
491
+
492
+ if scope["type"] == "lifespan":
493
+ return await self.handle_lifespan(scope, receive, send)
494
+
495
+ if scope["type"] == "websocket":
496
+ return await self.handle_websocket(scope, receive, send)
497
+
498
+ raise NotImplementedError("Unhandled protocol %s" % scope["type"])
499
+
500
+ def to_wsgi_environment(
501
+ self,
502
+ scope: "HTTPScope",
503
+ receive: "ASGIReceiveCallable",
504
+ ) -> "WSGIEnvironment":
505
+ """
506
+ Creates an IO-ready WSGIEnvironment from the given ASGI HTTP call.
507
+
508
+ :param scope: the ASGI HTTP Scope
509
+ :param receive: the ASGI callable to receive the HTTP request
510
+ :return: a WSGIEnvironment
511
+ """
512
+ environ: "WSGIEnvironment" = {}
513
+ populate_wsgi_environment(environ, scope)
514
+ # add IO wrappers
515
+ environ["wsgi.input"] = create_wsgi_input(receive, event_loop=self.event_loop)
516
+ # indicate that the stream is EOF terminated per request
517
+ environ["wsgi.input_terminated"] = True
518
+ return environ
519
+
520
+ async def handle_http(
521
+ self, scope: "HTTPScope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
522
+ ):
523
+ env = self.to_wsgi_environment(scope, receive)
524
+
525
+ try:
526
+ response = WsgiStartResponse(send, self.event_loop)
527
+
528
+ iterable = await self.event_loop.run_in_executor(
529
+ self.executor, self.wsgi_app, env, response
530
+ )
531
+ except Exception as e:
532
+ LOG.error(
533
+ "Error while trying to schedule execution: %s with environment %s",
534
+ e,
535
+ env,
536
+ exc_info=LOG.isEnabledFor(logging.DEBUG),
537
+ )
538
+ raise
539
+
540
+ try:
541
+ if iterable:
542
+ # Generators are also Iterators
543
+ if isinstance(iterable, t.Iterator):
544
+ iterable = _AsyncGeneratorWrapper(iterable)
545
+
546
+ if isinstance(iterable, (t.AsyncIterator, t.AsyncIterable)):
547
+ async for packet in iterable:
548
+ await response.write(packet)
549
+ else:
550
+ for packet in iterable:
551
+ await response.write(packet)
552
+ except ConnectionError as e:
553
+ client_info = "unknown"
554
+ if client := scope.get("client"):
555
+ address, port = client
556
+ client_info = f"{address}:{port}"
557
+ LOG.debug("Error while writing responses: %s (client_info: %s)", e, client_info)
558
+ finally:
559
+ if iterable and hasattr(iterable, "aclose"):
560
+ await iterable.aclose()
561
+ await response.close()
562
+
563
+ def to_websocket_environment(
564
+ self,
565
+ scope: "WebsocketScope",
566
+ receive: "ASGIReceiveCallable",
567
+ send: "ASGISendCallable",
568
+ ) -> WebSocketEnvironment:
569
+ """
570
+ Creates an IO-ready pseudo-WSGI environment from the given ASGI Websocket scope.
571
+
572
+ :param scope: the websocket scope
573
+ :param receive: receive callable
574
+ :param send: send callable
575
+ :return: a new websocket environment
576
+ """
577
+ environ: WebSocketEnvironment = {}
578
+ populate_wsgi_environment(environ, scope)
579
+ environ["REQUEST_METHOD"] = "WEBSOCKET"
580
+ environ["asgi.websocket"] = ASGIWebSocket(scope, receive, send, self.event_loop)
581
+ return environ
582
+
583
+ async def handle_websocket(
584
+ self, scope: "WebsocketScope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
585
+ ):
586
+ if not self.websocket_listener:
587
+ raise NotImplementedError("No websocket listener attached")
588
+
589
+ # populate a pseudo-WSGI environment with "WEBSOCKET" as method
590
+ # this can later be used to construct a sans-IO Werkzeug request
591
+ environ = self.to_websocket_environment(scope, receive, send)
592
+
593
+ try:
594
+ await self.event_loop.run_in_executor(self.executor, self.websocket_listener, environ)
595
+ except Exception as e:
596
+ LOG.error(
597
+ "Error while trying to schedule execution: %s with environment %s",
598
+ e,
599
+ environ,
600
+ exc_info=LOG.isEnabledFor(logging.DEBUG),
601
+ )
602
+ raise
603
+
604
+ async def handle_lifespan(
605
+ self, scope: "HTTPScope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
606
+ ):
607
+ while True:
608
+ message = await receive()
609
+ if message["type"] == "lifespan.startup":
610
+ try:
611
+ await self.event_loop.run_in_executor(
612
+ self.executor, self.lifespan_listener.on_startup
613
+ )
614
+ await send({"type": "lifespan.startup.complete"})
615
+ except Exception as e:
616
+ await send({"type": "lifespan.startup.failed", "message": f"{e}"})
617
+
618
+ elif message["type"] == "lifespan.shutdown":
619
+ try:
620
+ await self.event_loop.run_in_executor(
621
+ self.executor, self.lifespan_listener.on_shutdown
622
+ )
623
+ await send({"type": "lifespan.shutdown.complete"})
624
+ except Exception as e:
625
+ await send({"type": "lifespan.shutdown.failed", "message": f"{e}"})
626
+ return
627
+ else:
628
+ return