diff --git a/framework/FASTAPI.md b/framework/FASTAPI.md index 0415545f8722..ae1a93e28e72 100644 --- a/framework/FASTAPI.md +++ b/framework/FASTAPI.md @@ -2,21 +2,51 @@ ## Install +To run FastAPI, install `flwr` with all extras to ensure the `rest` extra is included: + ```bash -uv sync --all-extras +uv sync --locked --all-extras ``` -## Run SuperLink +## SuperLink Run Modes + +With the new HTTP API, there are now four different options to start and run the SuperLink: + +1. **Legacy Mode** `flower-superlink` (without `--enable-http-api`): This starts the SuperLink in "legacy mode" with only gRPC APIs, but no HTTP API. + + ```bash + uv run flower-superlink --insecure + ``` + +1. **Compatibility Mode** `flower-superlink --enable-http-api`: This starts the SuperLink in Compatibility Mode with both the HTTP API and the legacy gRPC APIs. **This is what we're running in prod until the gRPC-to-HTTP conversion is complete.** Note that in Compatibility Mode, FastAPI is limited to only 1 worker, which is a serious limitation during this transition. + + ```bash + uv run flower-superlink --insecure --enable-http-api + ``` + +1. **Next Mode** `flower-superlink --enable-http-api --disable-grpc-api`: This starts the SuperLink in "HTTP mode" with only the HTTP API, but not the legacy gRPC APIs. + + ```bash + uv run flower-superlink --insecure --enable-http-api --disable-grpc-api + ``` + +1. **Experimental Mode** `uvicorn flwr.superlink.main:app`: This starts the SuperLink in "experimental mode" via uvicorn, skipping the `flower-superlink` argument parsing. This mode is experimental because it needs to reach parity with `flower-superlink --enable-http-api --disable-grpc-api`. + + ```bash + uv run uvicorn flwr.superlink.main:app + ``` + +## Run SuperLink in Experimental Mode -Start the SuperLink FastAPI server using uvicorn: +Start the SuperLink's FastAPI server using uvicorn: ```bash uv run uvicorn flwr.superlink.main:app ``` -## Run SuperNode +## Run SuperNode in Experimental Mode -Start the SuperNode FastAPI server using uvicorn: +Start the SuperNode's FastAPI server using uvicorn: ```bash uv run uvicorn flwr.supernode.main:app diff --git a/framework/py/flwr/supercore/constant.py b/framework/py/flwr/supercore/constant.py index 9ac10122cfd1..12fc40db07a2 100644 --- a/framework/py/flwr/supercore/constant.py +++ b/framework/py/flwr/supercore/constant.py @@ -67,6 +67,10 @@ FLWR_UPDATE_CHECK_CACHE_FILENAME = "update-check.json" FLWR_UPDATE_CHECK_SHOW_INTERVAL_SECONDS = 12 * 60 * 60 +# Constants for Uvicorn-backed API servers +UVICORN_DEFAULT_HOST = "127.0.0.1" +UVICORN_DEFAULT_PORT = 8000 + # SuperGrid constants SUPERGRID_ADDRESS = "supergrid.flower.ai" diff --git a/framework/py/flwr/superlink/cli/flower_superlink.py b/framework/py/flwr/superlink/cli/flower_superlink.py index 5eb79fe51c97..23c7e681ef44 100644 --- a/framework/py/flwr/superlink/cli/flower_superlink.py +++ b/framework/py/flwr/superlink/cli/flower_superlink.py @@ -30,6 +30,7 @@ from typing import TypeVar, cast import grpc +import uvicorn import yaml from flwr.common.args import ( @@ -74,7 +75,11 @@ add_superexec_auth_secret_args, load_superexec_auth_secret, ) -from flwr.supercore.constant import FLWR_IN_MEMORY_DB_NAME +from flwr.supercore.constant import ( + FLWR_IN_MEMORY_DB_NAME, + UVICORN_DEFAULT_HOST, + UVICORN_DEFAULT_PORT, +) from flwr.supercore.exit import ExitCode, flwr_exit, register_signal_handlers from flwr.supercore.grpc import GRPC_MAX_MESSAGE_LENGTH, generic_create_grpc_server from flwr.supercore.grpc_health import add_args_health, run_health_server_grpc_no_tls @@ -95,6 +100,7 @@ NoOpControlAuthzPlugin, ) from flwr.superlink.federation import FederationManager, NoOpFederationManager +from flwr.superlink.main import create_app from flwr.superlink.servicer.control import run_control_api_grpc from flwr.superlink.servicer.serverappio import run_serverappio_api_grpc @@ -218,6 +224,11 @@ class SuperLinkLifespanConfig: # pylint: disable=too-many-instance-attributes serverappio_address: str control_address: str health_server_address: str | None + enable_http_api: bool + disable_grpc_api: bool + host: str + port: int + insecure: bool certificates: tuple[bytes, bytes, bytes] | None appio_certificates: tuple[bytes, bytes, bytes] | None superexec_auth_secret: bytes | None @@ -240,7 +251,13 @@ class SuperLinkLifespanConfig: # pylint: disable=too-many-instance-attributes class SuperLinkLifespan: # pylint: disable=too-many-instance-attributes - """Own SuperLink startup resources for the `flower-superlink` process.""" + """Own the shared SuperLink lifespan state and legacy network servers. + + Long-term, the gRPC-specific parts of this class should shrink until it only + initializes shared services used by FastAPI routers. During the migration, + FastAPI lifespan can use this object to start the existing gRPC APIs as + compatibility adapters. + """ def __init__(self, config: SuperLinkLifespanConfig) -> None: self.config = config @@ -253,7 +270,7 @@ def __init__(self, config: SuperLinkLifespanConfig) -> None: self._started = False def startup(self) -> None: - """Start SuperLink services.""" + """Start shared lifespan and legacy SuperLink gRPC servers.""" log(INFO, "SuperLinkLifespan: start") if self._started: return @@ -276,7 +293,7 @@ def startup(self) -> None: self._started = True def shutdown(self) -> None: - """Stop resources started by this lifespan.""" + """Stop legacy gRPC servers started by this lifespan.""" log(INFO, "SuperLinkLifespan: stop") if ( not self._started @@ -286,15 +303,22 @@ def shutdown(self) -> None: ): return + # Stop in reverse startup order so dependent services disappear before + # their backing state is considered unavailable. for grpc_server in reversed(self.grpc_servers): grpc_server.stop(grace=1) + # The old REST Fleet server uses `uvicorn.run` in a daemon thread and + # does not expose a clean stop handle. Keep the join bounded so FastAPI + # shutdown cannot hang forever while this temporary compatibility path + # still exists. for thread in self.bckg_threads: thread.join(timeout=1.0) if thread.is_alive(): log( WARN, - "Background thread %s is still running during SuperLink shutdown.", + "Background thread %s is still running during SuperLink " + "runtime shutdown.", thread.name, ) @@ -312,7 +336,12 @@ def shutdown(self) -> None: self._started = False def wait_until_background_thread_exits(self) -> None: - """Block like the historical `flower-superlink` command.""" + """Block like the historical `flower-superlink` command. + + With only gRPC servers, `self.bckg_threads` is empty and `all([])` is + intentionally true, so this loop blocks until a signal handler exits the + process. This preserves the current CLI behavior. + """ while all(thread.is_alive() for thread in self.bckg_threads): sleep(0.1) @@ -380,15 +409,22 @@ def _start_fleet_api(self) -> None: num_workers = 1 if config.fleet_api_type == TRANSPORT_TYPE_REST: - self._start_fleet_rest_api(host, port, num_workers) + self._start_legacy_fleet_rest_api(host, port, num_workers) elif config.fleet_api_type == TRANSPORT_TYPE_GRPC_RERE: - self._start_fleet_grpc_rere(fleet_address) + self._start_legacy_fleet_grpc_rere(fleet_address) elif config.fleet_api_type == TRANSPORT_TYPE_GRPC_ADAPTER: - self._start_fleet_grpc_adapter(fleet_address) + self._start_legacy_fleet_grpc_adapter(fleet_address) else: raise ValueError(f"Unknown fleet_api_type: {config.fleet_api_type}") - def _start_fleet_rest_api(self, host: str, port: int, num_workers: int) -> None: + def _start_legacy_fleet_rest_api( + self, host: str, port: int, num_workers: int + ) -> None: + """Start the old Fleet REST API compatibility server. + + TODO: Replace this separate uvicorn server with `flwr.superlink.routers.fleet` + routes mounted in the main FastAPI app. + """ if self.state_factory is None or self.objectstore_factory is None: raise RuntimeError("SuperLink lifespan state has not been initialized.") if ( @@ -414,7 +450,8 @@ def _start_fleet_rest_api(self, host: str, port: int, num_workers: int) -> None: fleet_thread.start() self.bckg_threads.append(fleet_thread) - def _start_fleet_grpc_rere(self, fleet_address: str) -> None: + def _start_legacy_fleet_grpc_rere(self, fleet_address: str) -> None: + """Start the current Fleet gRPC request-response API.""" if self.state_factory is None or self.objectstore_factory is None: raise RuntimeError("SuperLink lifespan state has not been initialized.") @@ -435,7 +472,8 @@ def _start_fleet_grpc_rere(self, fleet_address: str) -> None: ) self.grpc_servers.append(fleet_server) - def _start_fleet_grpc_adapter(self, fleet_address: str) -> None: + def _start_legacy_fleet_grpc_adapter(self, fleet_address: str) -> None: + """Start the current Fleet GrpcAdapter compatibility API.""" if self.state_factory is None or self.objectstore_factory is None: raise RuntimeError("SuperLink lifespan state has not been initialized.") @@ -485,6 +523,9 @@ def _parse_superlink_lifespan_config() -> SuperLinkLifespanConfig: interval_hours=args.log_rotation_interval_hours, backup_count=args.log_rotation_backup_count, ) + + _validate_http_api_args(args) + # Detect if `--executor*` arguments were set if args.executor or args.executor_dir or args.executor_config: flwr_exit( @@ -656,6 +697,11 @@ def _parse_superlink_lifespan_config() -> SuperLinkLifespanConfig: serverappio_address=serverappio_address, control_address=control_address, health_server_address=health_server_address, + enable_http_api=args.enable_http_api, + disable_grpc_api=args.disable_grpc_api, + host=args.host, + port=args.port, + insecure=args.insecure, certificates=certificates, appio_certificates=appio_certificates, superexec_auth_secret=superexec_auth_secret, @@ -682,29 +728,43 @@ def flower_superlink() -> None: """Run Flower SuperLink (ServerAppIo API and Fleet API).""" warn_if_flwr_update_available(process_name="flower-superlink") + config = _parse_superlink_lifespan_config() + log(INFO, "Starting Flower SuperLink") event(EventType.RUN_SUPERLINK_ENTER) - config = _parse_superlink_lifespan_config() + ########################################################################### + # Run SuperLink in Compatibility Mode (FastAPI + gRPC) + ########################################################################### + + # Enable this mode by running `flower-superlink --enable-http-api` + if config.enable_http_api: + # Blocking: this will run uvicorn.run() + _run_superlink_http_api(lifespan_config=config) + return - lifespan = SuperLinkLifespan(config) + ########################################################################### + # Run SuperLink in Legacy Mode (Only gRPC) + ########################################################################### + + superlink_lifespan = SuperLinkLifespan(config) try: - lifespan.startup() + superlink_lifespan.startup() except Exception as err: # pylint: disable=broad-except - lifespan.shutdown() + superlink_lifespan.shutdown() flwr_exit(ExitCode.SUPERLINK_INVALID_ARGS, str(err)) # Graceful shutdown register_signal_handlers( event_type=EventType.RUN_SUPERLINK_LEAVE, exit_message="SuperLink terminated gracefully.", - grpc_servers=lifespan.grpc_servers, - exit_handlers=[lifespan.shutdown], + grpc_servers=superlink_lifespan.grpc_servers, + exit_handlers=[superlink_lifespan.shutdown], ) # Block until a thread exits prematurely - lifespan.wait_until_background_thread_exits() + superlink_lifespan.wait_until_background_thread_exits() # Exit if any thread has exited prematurely # This code will not be reached if the SuperLink stops gracefully @@ -722,6 +782,71 @@ def _format_address(address: str) -> tuple[str, str, int]: return (f"[{host}]:{port}" if is_v6 else f"{host}:{port}", host, port) +def _run_superlink_http_api(lifespan_config: SuperLinkLifespanConfig) -> None: + """Run the experimental FastAPI-owned SuperLink service. + + In this mode, FastAPI owns process startup and starts the current + gRPC APIs from its lifespan as legacy compatibility adapters. Later, the + REST routers should call shared SuperLink services directly and this runtime + should no longer bind gRPC ports. + """ + start_legacy_grpc = not lifespan_config.disable_grpc_api + + if start_legacy_grpc and lifespan_config.fleet_api_type == TRANSPORT_TYPE_REST: + flwr_exit( + ExitCode.SUPERLINK_INVALID_ARGS, + "`--enable-http-api` cannot be combined with `--fleet-api-type rest`", + ) + superlink_lifespan = None + if start_legacy_grpc: + superlink_lifespan = SuperLinkLifespan(lifespan_config) + fastapi_app = create_app( + superlink_lifespan=superlink_lifespan, + start_legacy_grpc=start_legacy_grpc, + ) + + if start_legacy_grpc: + log( + WARN, + "EXPERIMENTAL: Starting the combined SuperLink FastAPI service on %s:%s. " + "The legacy gRPC APIs are started from FastAPI lifespan.", + lifespan_config.host, + lifespan_config.port, + ) + else: + log( + WARN, + "EXPERIMENTAL: Starting the SuperLink FastAPI service on %s:%s. " + "The legacy gRPC APIs are disabled.", + lifespan_config.host, + lifespan_config.port, + ) + + # Uvicorn workers must stay at 1 while the lifespan starts gRPC servers. With + # multiple workers, every worker process would try to bind the same Control, + # Fleet, and ServerAppIo ports. + uvicorn.run( + app=fastapi_app, + host=lifespan_config.host, + port=lifespan_config.port, + reload=False, + access_log=True, + ssl_keyfile=None if lifespan_config.insecure else lifespan_config.ssl_keyfile, + ssl_certfile=None if lifespan_config.insecure else lifespan_config.ssl_certfile, + workers=1, + ) + + +def _validate_http_api_args(args: argparse.Namespace) -> None: + """Validate relationships between experimental HTTP API CLI flags.""" + if args.disable_grpc_api and not args.enable_http_api: + flwr_exit( + ExitCode.SUPERLINK_INVALID_ARGS, + "`--disable-grpc-api` can only be used together with " + "`--enable-http-api`.", + ) + + def _obtain_superlink_certificates( args: argparse.Namespace, ) -> tuple[tuple[bytes, bytes, bytes] | None, tuple[bytes, bytes, bytes] | None]: @@ -936,8 +1061,6 @@ def _run_fleet_api_rest( ) -> None: """Run ServerAppIo API (REST-based).""" try: - import uvicorn - from flwr.server.superlink.fleet.rest_rere.rest_api import app as fast_api_app except ModuleNotFoundError: flwr_exit(ExitCode.COMMON_MISSING_EXTRA_REST) @@ -974,6 +1097,7 @@ def _parse_args_run_superlink() -> argparse.ArgumentParser: _add_args_common(parser=parser) add_ee_args_superlink(parser=parser) + _add_args_http_api(parser=parser) _add_args_serverappio_api(parser=parser) _add_args_fleet_api(parser=parser) _add_args_control_api(parser=parser) @@ -1071,6 +1195,44 @@ def _add_args_common(parser: argparse.ArgumentParser) -> None: add_superexec_auth_secret_args(parser) +def _add_args_http_api(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--enable-http-api", + action="store_true", + default=False, + help=( + "EXPERIMENTAL: Start one FastAPI HTTP server and let its lifespan " + "start the legacy SuperLink gRPC APIs." + ), + ) + parser.add_argument( + "--disable-grpc-api", + action="store_true", + default=False, + help=( + "EXPERIMENTAL: When used with `--enable-http-api`, start only the " + "HTTP API and do not start the legacy SuperLink gRPC APIs." + ), + ) + parser.add_argument( + "--host", + default=UVICORN_DEFAULT_HOST, + help=( + "Host for the experimental FastAPI HTTP server. " + f"By default, it is set to {UVICORN_DEFAULT_HOST}." + ), + ) + parser.add_argument( + "--port", + type=_port_int, + default=UVICORN_DEFAULT_PORT, + help=( + "Port for the experimental FastAPI HTTP server. " + f"By default, it is set to {UVICORN_DEFAULT_PORT}." + ), + ) + + def _add_args_serverappio_api(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--serverappio-api-address", @@ -1111,6 +1273,13 @@ def _positive_int(value: str) -> int: return parsed +def _port_int(value: str) -> int: + parsed = int(value) + if parsed < 0 or parsed > 65535: + raise argparse.ArgumentTypeError("value must be between 0 and 65535") + return parsed + + def _add_args_fleet_api(parser: argparse.ArgumentParser) -> None: # Fleet API transport layer type parser.add_argument( diff --git a/framework/py/flwr/superlink/cli/flower_superlink_test.py b/framework/py/flwr/superlink/cli/flower_superlink_test.py index 5e83643a82e7..79e53dba967c 100644 --- a/framework/py/flwr/superlink/cli/flower_superlink_test.py +++ b/framework/py/flwr/superlink/cli/flower_superlink_test.py @@ -68,11 +68,31 @@ def test_parse_superlink_lifespan_config_returns_final_defaults( assert config.certificates is None assert config.appio_certificates is None assert config.superexec_auth_secret is None + assert config.enable_http_api is False + assert config.disable_grpc_api is False + assert config.host == app_module.UVICORN_DEFAULT_HOST + assert config.port == app_module.UVICORN_DEFAULT_PORT + assert config.insecure is True assert config.enable_supernode_auth is False assert config.simulation is False assert config.database == FLWR_IN_MEMORY_DB_NAME +def test_parse_superlink_lifespan_config_keeps_fleet_address_unset_for_simulation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Simulation config should not synthesize a Fleet API address.""" + monkeypatch.setattr( + app_module.sys, + "argv", + ["flower-superlink", "--insecure", "--simulation"], + ) + + config = _parse_superlink_lifespan_config() + + assert config.fleet_api_address is None + + def test_parse_superlink_lifespan_config_maps_exec_api_address( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/framework/py/flwr/superlink/main.py b/framework/py/flwr/superlink/main.py index 08d050eaf0be..e7bc468821b1 100644 --- a/framework/py/flwr/superlink/main.py +++ b/framework/py/flwr/superlink/main.py @@ -26,17 +26,44 @@ from flwr import __version__ from flwr.common import log from flwr.supercore.routers import health -from flwr.superlink.routers import runtime +from flwr.superlink.cli.flower_superlink import SuperLinkLifespan +from flwr.superlink.routers import control, runtime -def create_app() -> FastAPI: - """Create the SuperLink FastAPI app.""" +def create_app( + *, + superlink_lifespan: SuperLinkLifespan | None = None, + start_legacy_grpc: bool = False, +) -> FastAPI: + """Create the SuperLink FastAPI app. + + This FastAPI app can be started in two ways: + 1. Via `flower-superlink`: `superlink_lifespan` will be passed. + 2. Via `uvicorn flwr.superlink.main:app`: `superlink_lifespan` will be None. + """ @asynccontextmanager - async def lifespan(_: FastAPI) -> AsyncIterator[None]: + async def lifespan(fastapi_app: FastAPI) -> AsyncIterator[None]: + """Own process-lifetime resources for the combined SuperLink service.""" log(INFO, "FastAPI lifespan: startup") - yield - log(INFO, "FastAPI lifespan: shutdown") + + if superlink_lifespan is not None: + # Store the SuperLinkLifespan where future REST routers can access shared + # state through FastAPI dependencies + fastapi_app.state.superlink_lifespan = superlink_lifespan + + if superlink_lifespan is not None and start_legacy_grpc: + # Temporary compatibility path: start the existing gRPC APIs from + # FastAPI lifespan + superlink_lifespan.startup() + + try: + yield + finally: + if superlink_lifespan is not None and start_legacy_grpc: + superlink_lifespan.shutdown() + + log(INFO, "FastAPI lifespan: shutdown") fastapi_app = FastAPI( title="SuperLink API", @@ -46,10 +73,11 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: lifespan=lifespan, ) - # SuperCore API routers + # Core APIs fastapi_app.include_router(health.router) - # SuperLink API routers + # SuperLink APIs + fastapi_app.include_router(control.router) fastapi_app.include_router(runtime.router) return fastapi_app diff --git a/framework/py/flwr/superlink/routers/control/__init__.py b/framework/py/flwr/superlink/routers/control/__init__.py new file mode 100644 index 000000000000..0966f7ef5155 --- /dev/null +++ b/framework/py/flwr/superlink/routers/control/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Control API router.""" + + +from .router import router + +__all__ = ["router"] diff --git a/framework/py/flwr/superlink/routers/control/router.py b/framework/py/flwr/superlink/routers/control/router.py new file mode 100644 index 000000000000..ea90c961e274 --- /dev/null +++ b/framework/py/flwr/superlink/routers/control/router.py @@ -0,0 +1,31 @@ +# Copyright 2026 Flower Labs GmbH. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Control API router.""" + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/control", tags=["control"]) + + +@router.get("/runs") +def list_runs() -> dict[str, str]: + """List runs. + + Returns + ------- + dict[str, str] + Not yet implemented. + """ + raise HTTPException(status_code=501, detail="Not implemented") diff --git a/framework/py/flwr/superlink/routers/runtime/router.py b/framework/py/flwr/superlink/routers/runtime/router.py index c3036c417716..73ce3202cfa9 100644 --- a/framework/py/flwr/superlink/routers/runtime/router.py +++ b/framework/py/flwr/superlink/routers/runtime/router.py @@ -15,14 +15,13 @@ """Runtime API router.""" -from fastapi import APIRouter, HTTPException, Request, Response, status -from starlette.datastructures import State +from fastapi import APIRouter, HTTPException, Response, status router = APIRouter(prefix="/runtime", tags=["runtime"]) @router.post("/messages") -def pull_messages(_: Request[State]) -> Response: +def pull_messages() -> Response: """Pull messages for the ServerApp.""" raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, diff --git a/framework/py/flwr/supernode/main.py b/framework/py/flwr/supernode/main.py index de66d5b3ce26..9fdd73cea3ab 100644 --- a/framework/py/flwr/supernode/main.py +++ b/framework/py/flwr/supernode/main.py @@ -46,10 +46,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: lifespan=lifespan, ) - # SuperCore API routers + # Core APIs fastapi_app.include_router(health.router) - # SuperNode API routers + # SuperNode APIs fastapi_app.include_router(runtime.router) return fastapi_app diff --git a/framework/py/flwr/supernode/routers/runtime/router.py b/framework/py/flwr/supernode/routers/runtime/router.py index aeefa4144303..321123b107a8 100644 --- a/framework/py/flwr/supernode/routers/runtime/router.py +++ b/framework/py/flwr/supernode/routers/runtime/router.py @@ -15,14 +15,13 @@ """Runtime API router.""" -from fastapi import APIRouter, HTTPException, Request, Response, status -from starlette.datastructures import State +from fastapi import APIRouter, HTTPException, Response, status router = APIRouter(prefix="/runtime", tags=["runtime"]) @router.post("/messages") -def pull_messages(_: Request[State]) -> Response: +def pull_messages() -> Response: """Pull messages for the ClientApp.""" raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED,