mrok 0.5.0__py3-none-any.whl → 0.6.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.
@@ -27,7 +27,7 @@ from textual.widgets.data_table import ColumnKey
27
27
  from textual.worker import get_current_worker
28
28
 
29
29
  from mrok import __version__
30
- from mrok.proxy.datastructures import Event, HTTPHeaders, HTTPResponse, WorkerMetrics, ZitiMrokMeta
30
+ from mrok.proxy.models import Event, HTTPHeaders, HTTPResponse, ServiceMetadata, WorkerMetrics
31
31
 
32
32
 
33
33
  def build_tree(node, data):
@@ -185,7 +185,7 @@ class InfoPanel(Static):
185
185
  # mem=int(mean([m.process.mem for m in self.workers_metrics.values()])),
186
186
  # )
187
187
 
188
- def update_meta(self, meta: ZitiMrokMeta) -> None:
188
+ def update_meta(self, meta: ServiceMetadata) -> None:
189
189
  table = self.query_one(DataTable)
190
190
  if len(table.rows) == 0:
191
191
  table.add_row("URL", f"https://{meta.extension}.{meta.domain}")
mrok/agent/sidecar/app.py CHANGED
@@ -5,7 +5,7 @@ from typing import Literal
5
5
  from httpcore import AsyncConnectionPool
6
6
 
7
7
  from mrok.proxy.app import ProxyAppBase
8
- from mrok.proxy.types import Scope
8
+ from mrok.types.proxy import Scope
9
9
 
10
10
  logger = logging.getLogger("mrok.agent")
11
11
 
@@ -18,10 +18,10 @@ class SidecarProxyApp(ProxyAppBase):
18
18
  self,
19
19
  target: str | Path | tuple[str, int],
20
20
  *,
21
- max_connections=1000,
22
- max_keepalive_connections=10,
23
- keepalive_expiry=120,
24
- retries=0,
21
+ max_connections: int | None = 10,
22
+ max_keepalive_connections: int | None = None,
23
+ keepalive_expiry: float | None = None,
24
+ retries: int = 0,
25
25
  ):
26
26
  self._target = target
27
27
  self._target_type, self._target_address = self._parse_target()
@@ -34,10 +34,10 @@ class SidecarProxyApp(ProxyAppBase):
34
34
 
35
35
  def setup_connection_pool(
36
36
  self,
37
- max_connections: int | None = 1000,
38
- max_keepalive_connections: int | None = 10,
39
- keepalive_expiry: float | None = 120.0,
40
- retries: int = 0,
37
+ max_connections: int | None,
38
+ max_keepalive_connections: int | None,
39
+ keepalive_expiry: float | None,
40
+ retries: int,
41
41
  ) -> AsyncConnectionPool:
42
42
  if self._target_type == "unix":
43
43
  return AsyncConnectionPool(
@@ -13,26 +13,47 @@ class SidecarAgent(MasterBase):
13
13
  identity_file: str,
14
14
  target: str | Path | tuple[str, int],
15
15
  workers: int = 4,
16
+ events_enabled: bool = True,
17
+ max_connections: int | None = 10,
18
+ max_keepalive_connections: int | None = None,
19
+ keepalive_expiry: float | None = None,
20
+ retries: int = 0,
16
21
  publishers_port: int = 50000,
17
22
  subscribers_port: int = 50001,
18
23
  ):
19
24
  super().__init__(
20
25
  identity_file,
21
- workers,
22
- False,
23
- publishers_port,
24
- subscribers_port,
26
+ workers=workers,
27
+ reload=False,
28
+ events_enabled=events_enabled,
29
+ events_pub_port=publishers_port,
30
+ events_sub_port=subscribers_port,
25
31
  )
26
32
  self._target = target
33
+ self._max_connections = max_connections
34
+ self._max_keepalive_connections = max_keepalive_connections
35
+ self._keepalive_expiry = keepalive_expiry
36
+ self._retries = retries
27
37
 
28
38
  def get_asgi_app(self):
29
- return SidecarProxyApp(self._target)
39
+ return SidecarProxyApp(
40
+ self._target,
41
+ max_connections=self._max_connections,
42
+ max_keepalive_connections=self._max_keepalive_connections,
43
+ keepalive_expiry=self._keepalive_expiry,
44
+ retries=self._retries,
45
+ )
30
46
 
31
47
 
32
48
  def run(
33
49
  identity_file: str,
34
50
  target_addr: str | Path | tuple[str, int],
35
51
  workers: int = 4,
52
+ events_enabled: bool = True,
53
+ max_connections: int | None = 10,
54
+ max_keepalive_connections: int | None = None,
55
+ keepalive_expiry: float | None = None,
56
+ retries: int = 0,
36
57
  publishers_port: int = 50000,
37
58
  subscribers_port: int = 50001,
38
59
  ):
@@ -40,6 +61,11 @@ def run(
40
61
  identity_file,
41
62
  target_addr,
42
63
  workers=workers,
64
+ events_enabled=events_enabled,
65
+ max_connections=max_connections,
66
+ max_keepalive_connections=max_keepalive_connections,
67
+ keepalive_expiry=keepalive_expiry,
68
+ retries=retries,
43
69
  publishers_port=publishers_port,
44
70
  subscribers_port=subscribers_port,
45
71
  )
mrok/agent/ziticorn.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from mrok.proxy.master import MasterBase
2
- from mrok.proxy.types import ASGIApp
2
+ from mrok.types.proxy import ASGIApp
3
3
 
4
4
 
5
5
  class ZiticornAgent(MasterBase):
@@ -12,7 +12,13 @@ class ZiticornAgent(MasterBase):
12
12
  publishers_port: int = 50000,
13
13
  subscribers_port: int = 50001,
14
14
  ):
15
- super().__init__(identity_file, workers, reload, publishers_port, subscribers_port)
15
+ super().__init__(
16
+ identity_file,
17
+ workers=workers,
18
+ reload=reload,
19
+ events_pub_port=publishers_port,
20
+ events_sub_port=subscribers_port,
21
+ )
16
22
  self.app = app
17
23
 
18
24
  def get_asgi_app(self):
@@ -8,14 +8,15 @@ import typer
8
8
 
9
9
  from mrok.cli.commands.admin.utils import parse_tags
10
10
  from mrok.conf import Settings
11
- from mrok.ziti.api import TagsType, ZitiClientAPI, ZitiManagementAPI
11
+ from mrok.types.ziti import Tags
12
+ from mrok.ziti.api import ZitiClientAPI, ZitiManagementAPI
12
13
  from mrok.ziti.bootstrap import bootstrap_identity
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
16
17
 
17
18
  async def bootstrap(
18
- settings: Settings, forced: bool, tags: TagsType | None
19
+ settings: Settings, forced: bool, tags: Tags | None
19
20
  ) -> tuple[str, dict[str, Any] | None]:
20
21
  async with ZitiManagementAPI(settings) as mgmt_api, ZitiClientAPI(settings) as client_api:
21
22
  return await bootstrap_identity(
@@ -2,10 +2,10 @@ from datetime import datetime
2
2
 
3
3
  import typer
4
4
 
5
- from mrok.ziti.api import TagsType
5
+ from mrok.types.ziti import Tags
6
6
 
7
7
 
8
- def parse_tags(pairs: list[str] | None) -> TagsType | None:
8
+ def parse_tags(pairs: list[str] | None) -> Tags | None:
9
9
  if not pairs:
10
10
  return None
11
11
 
@@ -25,10 +25,55 @@ def register(app: typer.Typer) -> None:
25
25
  typer.Option(
26
26
  "--workers",
27
27
  "-w",
28
- help=f"Number of workers. Default: {default_workers}",
28
+ help="Number of workers.",
29
29
  show_default=True,
30
30
  ),
31
31
  ] = default_workers,
32
+ max_connections: Annotated[
33
+ int,
34
+ typer.Option(
35
+ "--max-pool-connections",
36
+ help=(
37
+ "The maximum number of concurrent HTTP connections that "
38
+ "the pool should allow. Any attempt to send a request on a pool that "
39
+ "would exceed this amount will block until a connection is available."
40
+ ),
41
+ show_default=True,
42
+ ),
43
+ ] = 10,
44
+ max_keepalive_connections: Annotated[
45
+ int | None,
46
+ typer.Option(
47
+ "--max-pool-keepalive-connections",
48
+ help=(
49
+ "The maximum number of idle HTTP connections "
50
+ "that will be maintained in the pool."
51
+ ),
52
+ show_default=True,
53
+ ),
54
+ ] = None,
55
+ keepalive_expiry: Annotated[
56
+ float | None,
57
+ typer.Option(
58
+ "--max-pool-keepalive-expiry",
59
+ help=(
60
+ "The duration in seconds that an idle HTTP connection "
61
+ "may be maintained for before being expired from the pool."
62
+ ),
63
+ show_default=True,
64
+ ),
65
+ ] = None,
66
+ retries: Annotated[
67
+ int,
68
+ typer.Option(
69
+ "--max-pool-connect-retries",
70
+ help=(
71
+ "The duration in seconds that an idle HTTP connection "
72
+ "may be maintained for before being expired from the pool."
73
+ ),
74
+ show_default=True,
75
+ ),
76
+ ] = 0,
32
77
  publishers_port: Annotated[
33
78
  int,
34
79
  typer.Option(
@@ -53,6 +98,14 @@ def register(app: typer.Typer) -> None:
53
98
  show_default=True,
54
99
  ),
55
100
  ] = 50001,
101
+ no_events: Annotated[
102
+ bool,
103
+ typer.Option(
104
+ "--no-events",
105
+ help="Disable events. Default: False",
106
+ show_default=True,
107
+ ),
108
+ ] = False,
56
109
  ):
57
110
  """Run a Sidecar Proxy to expose a web application through OpenZiti."""
58
111
  if ":" in str(target):
@@ -65,6 +118,11 @@ def register(app: typer.Typer) -> None:
65
118
  str(identity_file),
66
119
  target_addr,
67
120
  workers=workers,
121
+ events_enabled=not no_events,
122
+ max_connections=max_connections,
123
+ max_keepalive_connections=max_keepalive_connections,
124
+ keepalive_expiry=keepalive_expiry,
125
+ retries=retries,
68
126
  publishers_port=publishers_port,
69
127
  subscribers_port=subscribers_port,
70
128
  )
@@ -44,6 +44,48 @@ def register(app: typer.Typer) -> None:
44
44
  show_default=True,
45
45
  ),
46
46
  ] = default_workers,
47
+ max_connections: Annotated[
48
+ int,
49
+ typer.Option(
50
+ "--max-pool-connections",
51
+ help=(
52
+ "The maximum number of concurrent HTTP connections that "
53
+ "the pool should allow. Any attempt to send a request on a pool that "
54
+ "would exceed this amount will block until a connection is available."
55
+ ),
56
+ show_default=True,
57
+ ),
58
+ ] = 1000,
59
+ max_keepalive_connections: Annotated[
60
+ int | None,
61
+ typer.Option(
62
+ "--max-pool-keepalive-connections",
63
+ help=(
64
+ "The maximum number of idle HTTP connections "
65
+ "that will be maintained in the pool."
66
+ ),
67
+ show_default=True,
68
+ ),
69
+ ] = 100,
70
+ keepalive_expiry: Annotated[
71
+ float | None,
72
+ typer.Option(
73
+ "--max-pool-keepalive-expiry",
74
+ help=(
75
+ "The duration in seconds that an idle HTTP connection "
76
+ "may be maintained for before being expired from the pool."
77
+ ),
78
+ show_default=True,
79
+ ),
80
+ ] = 300,
47
81
  ):
48
82
  """Run the mrok frontend with Gunicorn and Uvicorn workers."""
49
- frontend.run(identity_file, host, port, workers)
83
+ frontend.run(
84
+ identity_file,
85
+ host,
86
+ port,
87
+ workers,
88
+ max_connections=max_connections,
89
+ max_keepalive_connections=max_keepalive_connections,
90
+ keepalive_expiry=keepalive_expiry,
91
+ )
@@ -9,12 +9,12 @@ from pydantic import (
9
9
  computed_field,
10
10
  )
11
11
 
12
- from mrok.ziti.api import TagsType
12
+ from mrok.types.ziti import Tags
13
13
 
14
14
 
15
15
  class BaseSchema(BaseModel):
16
16
  model_config = ConfigDict(from_attributes=True, extra="ignore")
17
- tags: TagsType | None = None
17
+ tags: Tags | None = None
18
18
 
19
19
 
20
20
  class IdSchema(BaseModel):
mrok/frontend/app.py CHANGED
@@ -6,7 +6,7 @@ from mrok.conf import get_settings
6
6
  from mrok.proxy.app import ProxyAppBase
7
7
  from mrok.proxy.backend import AIOZitiNetworkBackend
8
8
  from mrok.proxy.exceptions import InvalidTargetError
9
- from mrok.proxy.types import Scope
9
+ from mrok.types.proxy import Scope
10
10
 
11
11
  RE_SUBDOMAIN = re.compile(r"(?i)^(?:EXT-\d{4}-\d{4}|INS-\d{4}-\d{4}-\d{4})$")
12
12
 
@@ -16,9 +16,9 @@ class FrontendProxyApp(ProxyAppBase):
16
16
  self,
17
17
  identity_file: str,
18
18
  *,
19
- max_connections: int = 1000,
20
- max_keepalive_connections: int = 10,
21
- keepalive_expiry: float = 120.0,
19
+ max_connections: int | None = 10,
20
+ max_keepalive_connections: int | None = None,
21
+ keepalive_expiry: float | None = None,
22
22
  retries=0,
23
23
  ):
24
24
  self._identity_file = identity_file
@@ -32,10 +32,10 @@ class FrontendProxyApp(ProxyAppBase):
32
32
 
33
33
  def setup_connection_pool(
34
34
  self,
35
- max_connections: int | None = 1000,
36
- max_keepalive_connections: int | None = 100,
37
- keepalive_expiry: float | None = 120.0,
38
- retries: int = 0,
35
+ max_connections: int | None,
36
+ max_keepalive_connections: int | None,
37
+ keepalive_expiry: float | None,
38
+ retries: int,
39
39
  ) -> AsyncConnectionPool:
40
40
  return AsyncConnectionPool(
41
41
  max_connections=max_connections,
mrok/frontend/main.py CHANGED
@@ -38,8 +38,16 @@ def run(
38
38
  host: str,
39
39
  port: int,
40
40
  workers: int,
41
+ max_connections: int | None,
42
+ max_keepalive_connections: int | None,
43
+ keepalive_expiry: float | None,
41
44
  ):
42
- app = FrontendProxyApp(str(identity_file))
45
+ app = FrontendProxyApp(
46
+ str(identity_file),
47
+ max_connections=max_connections,
48
+ max_keepalive_connections=max_keepalive_connections,
49
+ keepalive_expiry=keepalive_expiry,
50
+ )
43
51
 
44
52
  options = {
45
53
  "bind": f"{host}:{port}",
mrok/proxy/app.py CHANGED
@@ -4,8 +4,8 @@ import logging
4
4
  from httpcore import AsyncConnectionPool, Request
5
5
 
6
6
  from mrok.proxy.exceptions import ProxyError
7
- from mrok.proxy.streams import ASGIRequestBodyStream
8
- from mrok.proxy.types import ASGIReceive, ASGISend, Scope
7
+ from mrok.proxy.stream import ASGIRequestBodyStream
8
+ from mrok.types.proxy import ASGIReceive, ASGISend, Scope
9
9
 
10
10
  logger = logging.getLogger("mrok.proxy")
11
11
 
@@ -26,9 +26,9 @@ class ProxyAppBase(abc.ABC):
26
26
  def __init__(
27
27
  self,
28
28
  *,
29
- max_connections: int | None = 1000,
30
- max_keepalive_connections: int | None = 10,
31
- keepalive_expiry: float | None = 120.0,
29
+ max_connections: int | None = 10,
30
+ max_keepalive_connections: int | None = None,
31
+ keepalive_expiry: float | None = None,
32
32
  retries: int = 0,
33
33
  ) -> None:
34
34
  self._pool = self.setup_connection_pool(
@@ -41,10 +41,10 @@ class ProxyAppBase(abc.ABC):
41
41
  @abc.abstractmethod
42
42
  def setup_connection_pool(
43
43
  self,
44
- max_connections: int | None = 1000,
45
- max_keepalive_connections: int | None = 10,
46
- keepalive_expiry: float | None = 120.0,
47
- retries: int = 0,
44
+ max_connections: int | None,
45
+ max_keepalive_connections: int | None,
46
+ keepalive_expiry: float | None,
47
+ retries: int,
48
48
  ) -> AsyncConnectionPool:
49
49
  raise NotImplementedError()
50
50
 
@@ -78,6 +78,7 @@ class ProxyAppBase(abc.ABC):
78
78
  content=body_stream,
79
79
  )
80
80
  response = await self._pool.handle_async_request(request)
81
+ logger.debug(f"connection pool status: {self._pool}")
81
82
  response_headers = []
82
83
  for k, v in response.headers:
83
84
  if k.lower() not in HOP_BY_HOP_HEADERS:
mrok/proxy/asgi.py ADDED
@@ -0,0 +1,96 @@
1
+ from collections.abc import Iterator
2
+ from contextlib import AsyncExitStack, asynccontextmanager
3
+ from typing import Any, ParamSpec, Protocol
4
+
5
+ from mrok.types.proxy import ASGIApp, ASGIReceive, ASGISend, Lifespan, Scope
6
+
7
+ P = ParamSpec("P")
8
+
9
+
10
+ class ASGIMiddleware(Protocol[P]):
11
+ def __call__(
12
+ self, app: ASGIApp, /, *args: P.args, **kwargs: P.kwargs
13
+ ) -> ASGIApp: ... # pragma: no cover
14
+
15
+
16
+ class Middleware:
17
+ def __init__(self, cls: ASGIMiddleware[P], *args: P.args, **kwargs: P.kwargs) -> None:
18
+ self.cls = cls
19
+ self.args = args
20
+ self.kwargs = kwargs
21
+
22
+ def __iter__(self) -> Iterator[Any]:
23
+ as_tuple = (self.cls, self.args, self.kwargs)
24
+ return iter(as_tuple)
25
+
26
+
27
+ class ASGIAppWrapper:
28
+ def __init__(
29
+ self,
30
+ app: ASGIApp,
31
+ lifespan: Lifespan | None = None,
32
+ ) -> None:
33
+ self.app = app
34
+ self.lifespan = lifespan
35
+ self.middlware: list[Middleware] = []
36
+ self.middleare_stack: ASGIApp | None = None
37
+
38
+ def add_middleware(self, cls: ASGIMiddleware[P], *args: P.args, **kwargs: P.kwargs):
39
+ self.middlware.insert(0, Middleware(cls, *args, **kwargs))
40
+
41
+ def build_middleware_stack(self):
42
+ app = self.app
43
+ for cls, args, kwargs in reversed(self.middlware):
44
+ app = cls(app, *args, **kwargs)
45
+ return app
46
+
47
+ def get_starlette_lifespan(self):
48
+ router = getattr(self.app, "router", None)
49
+ if router is None:
50
+ return None
51
+ return getattr(router, "lifespan_context", None)
52
+
53
+ @asynccontextmanager
54
+ async def merge_lifespan(self, app: ASGIApp):
55
+ async with AsyncExitStack() as stack:
56
+ state: dict[Any, Any] = {}
57
+ if self.lifespan is not None:
58
+ outer_state = await stack.enter_async_context(self.lifespan(app))
59
+ state.update(outer_state or {})
60
+ starlette_lifespan = self.get_starlette_lifespan()
61
+ if starlette_lifespan is not None:
62
+ inner_state = await stack.enter_async_context(starlette_lifespan(app))
63
+ state.update(inner_state or {})
64
+ yield state
65
+
66
+ async def handle_lifespan(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> None:
67
+ started = False
68
+ app: Any = scope.get("app")
69
+ await receive()
70
+ try:
71
+ async with self.merge_lifespan(app) as state:
72
+ if state:
73
+ if "state" not in scope:
74
+ raise RuntimeError('"state" is unsupported by the current ASGI Server.')
75
+ scope["state"].update(state)
76
+ await send({"type": "lifespan.startup.complete"})
77
+ started = True
78
+ await receive()
79
+ except Exception as e: # pragma: no cover
80
+ if started:
81
+ await send({"type": "lifespan.shutdown.failed", "message": str(e)})
82
+ else:
83
+ await send({"type": "lifespan.startup.failed", "message": str(e)})
84
+ raise
85
+ else:
86
+ await send({"type": "lifespan.shutdown.complete"})
87
+
88
+ async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> None:
89
+ if self.middleare_stack is None: # pragma: no branch
90
+ self.middleware_stack = self.build_middleware_stack()
91
+ if scope["type"] == "lifespan":
92
+ scope["app"] = self
93
+ await self.handle_lifespan(scope, receive, send)
94
+ return
95
+
96
+ await self.middleware_stack(scope, receive, send)
mrok/proxy/backend.py CHANGED
@@ -6,8 +6,8 @@ import openziti
6
6
  from httpcore import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream
7
7
  from openziti.context import ZitiContext
8
8
 
9
- from mrok.proxy.exceptions import TargetUnavailableError
10
- from mrok.proxy.streams import AIONetworkStream
9
+ from mrok.proxy.exceptions import InvalidTargetError, TargetUnavailableError
10
+ from mrok.proxy.stream import AIONetworkStream
11
11
 
12
12
 
13
13
  class AIOZitiNetworkBackend(AsyncNetworkBackend):
@@ -37,7 +37,9 @@ class AIOZitiNetworkBackend(AsyncNetworkBackend):
37
37
  reader, writer = await asyncio.open_connection(sock=sock)
38
38
  return AIONetworkStream(reader, writer)
39
39
  except Exception as e:
40
- raise TargetUnavailableError() from e
40
+ if e.args and e.args[0] == -24: # the service exists but is not available
41
+ raise TargetUnavailableError() from e
42
+ raise InvalidTargetError() from e
41
43
 
42
44
  async def sleep(self, seconds: float) -> None:
43
45
  await asyncio.sleep(seconds)
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+
5
+ import zmq
6
+ import zmq.asyncio
7
+
8
+ from mrok.proxy.asgi import ASGIAppWrapper
9
+ from mrok.proxy.metrics import MetricsCollector
10
+ from mrok.proxy.middleware import CaptureMiddleware, MetricsMiddleware
11
+ from mrok.proxy.models import Event, HTTPResponse, ServiceMetadata, Status
12
+ from mrok.types.proxy import ASGIApp
13
+
14
+ logger = logging.getLogger("mrok.proxy")
15
+
16
+
17
+ class EventPublisher:
18
+ def __init__(
19
+ self,
20
+ worker_id: str,
21
+ meta: ServiceMetadata | None = None,
22
+ event_publisher_port: int = 50000,
23
+ metrics_interval: float = 5.0,
24
+ ):
25
+ self._worker_id = worker_id
26
+ self._meta = meta
27
+ self._metrics_interval = metrics_interval
28
+ self.publisher_port = event_publisher_port
29
+ self._zmq_ctx = zmq.asyncio.Context()
30
+ self._publisher = self._zmq_ctx.socket(zmq.PUB)
31
+ self._metrics_collector = MetricsCollector(self._worker_id)
32
+ self._publish_task = None
33
+
34
+ async def on_startup(self):
35
+ self._publisher.connect(f"tcp://localhost:{self.publisher_port}")
36
+ self._publish_task = asyncio.create_task(self.publish_metrics_event())
37
+ logger.info(f"Events publishing for worker {self._worker_id} started")
38
+
39
+ async def on_shutdown(self):
40
+ self._publish_task.cancel()
41
+ with contextlib.suppress(asyncio.CancelledError):
42
+ await self._publish_task
43
+ self._publisher.close()
44
+ self._zmq_ctx.term()
45
+ logger.info(f"Events publishing for worker {self._worker_id} stopped")
46
+
47
+ async def publish_metrics_event(self):
48
+ while True:
49
+ snap = await self._metrics_collector.snapshot()
50
+ event = Event(type="status", data=Status(meta=self._meta, metrics=snap))
51
+ await self._publisher.send_string(event.model_dump_json())
52
+ await asyncio.sleep(self._metrics_interval)
53
+
54
+ async def publish_response_event(self, response: HTTPResponse):
55
+ event = Event(type="response", data=response)
56
+ await self._publisher.send_string(event.model_dump_json()) # type: ignore[attr-defined]
57
+
58
+ def setup_middleware(self, app: ASGIAppWrapper):
59
+ app.add_middleware(CaptureMiddleware, self.publish_response_event)
60
+ app.add_middleware(MetricsMiddleware, self._metrics_collector) # type: ignore
61
+
62
+ @contextlib.asynccontextmanager
63
+ async def lifespan(self, app: ASGIApp):
64
+ await self.on_startup() # type: ignore
65
+ yield
66
+ await self.on_shutdown() # type: ignore