diff --git a/airflow-core/pyproject.toml b/airflow-core/pyproject.toml index bcf2285eadc8c..31a88734c88fd 100644 --- a/airflow-core/pyproject.toml +++ b/airflow-core/pyproject.toml @@ -95,7 +95,11 @@ dependencies = [ "cryptography>=44.0.3", "deprecated>=1.2.13", "dill>=0.2.2", - "fastapi[standard-no-fastapi-cloud-cli]>=0.129.0", + # Cap below 0.137.0: FastAPI 0.137 switched to lazy router inclusion, which breaks cadwyn's + # versioned router generation (RouterGenerationError) and fails api-server / dag-processor + # startup. Relax once cadwyn supports FastAPI 0.137. See + # https://github.com/apache/airflow/issues/68562 + "fastapi[standard-no-fastapi-cloud-cli]>=0.129.0,<0.137.0", "uvicorn>=0.37.0", "starlette>=1.0.1", "httpx>=0.25.0", diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py b/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py index face45ec64871..7b19f3ddd3055 100644 --- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py +++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py @@ -38,7 +38,11 @@ from airflow.api_fastapi.execution_api.security import require_auth execution_api_router = APIRouter() -execution_api_router.include_router(health.router, prefix="/health", tags=["Health"]) +# health.router declares its full paths ("/health", "/health/ping") and is included without a +# prefix, unlike the routers below. A root route registered as @router.get("") under an include-time +# prefix=... raises "Prefix and path cannot be both empty" once FastAPI switched to lazy router +# inclusion (>=0.137); see https://github.com/apache/airflow/issues/68562. Don't reintroduce a prefix here. +execution_api_router.include_router(health.router, tags=["Health"]) # _Every_ single endpoint under here must be authenticated. Some do further checks on top of these authenticated_router = VersionedAPIRouter(dependencies=[Security(require_auth)]) # type: ignore[list-item] diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/health.py b/airflow-core/src/airflow/api_fastapi/execution_api/routes/health.py index d808f51e1db6a..9198ae33d7821 100644 --- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/health.py +++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/health.py @@ -25,12 +25,12 @@ router = APIRouter() -@router.get("") +@router.get("/health") def health() -> dict: return {"status": "healthy"} -@router.get("/ping") +@router.get("/health/ping") async def ping(services=DepContainer): ok: list[str] = [] failing: dict[str, str] = {} diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/routes/__init__.py b/airflow-core/tests/unit/api_fastapi/execution_api/routes/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/airflow-core/tests/unit/api_fastapi/execution_api/routes/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/routes/test_health_routes.py b/airflow-core/tests/unit/api_fastapi/execution_api/routes/test_health_routes.py new file mode 100644 index 0000000000000..fc6af917205f7 --- /dev/null +++ b/airflow-core/tests/unit/api_fastapi/execution_api/routes/test_health_routes.py @@ -0,0 +1,38 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +from __future__ import annotations + +from fastapi.routing import APIRoute + + +def test_health_router_avoids_empty_root_path(): + """Regression guard for https://github.com/apache/airflow/issues/68562 (FastAPI >=0.137). + + The break is specific: a root route registered with an empty path (``@router.get("")``) *and* + mounted under an include-time ``prefix=...`` raises ``FastAPIError: Prefix and path cannot be + both empty`` once FastAPI switched to lazy router inclusion. Older FastAPI merged the prefix in + eagerly and accepted it, so the version pin alone won't catch a reintroduction. The health + router therefore declares full, non-empty paths and is included without a prefix -- assert the + empty path stays gone, while leaving room for additional health sub-routes to be added later. + """ + from airflow.api_fastapi.execution_api.routes import health + + empty = [route.name for route in health.router.routes if isinstance(route, APIRoute) and not route.path] + assert not empty, f"Health routes must use explicit, non-empty paths (breaks FastAPI >=0.137): {empty}" + + paths = {route.path for route in health.router.routes if isinstance(route, APIRoute)} + assert {"/health", "/health/ping"} <= paths diff --git a/uv.lock b/uv.lock index d506f861eb9b6..a164ae529245e 100644 --- a/uv.lock +++ b/uv.lock @@ -2067,7 +2067,7 @@ requires-dist = [ { name = "deprecated", specifier = ">=1.2.13" }, { name = "dill", specifier = ">=0.2.2" }, { name = "eventlet", marker = "extra == 'async'", specifier = ">=0.37.0" }, - { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.129.0" }, + { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.129.0,<0.137.0" }, { name = "gevent", marker = "extra == 'async'", specifier = ">=25.4.1" }, { name = "graphviz", marker = "sys_platform != 'darwin' and extra == 'graphviz'", specifier = ">=0.20" }, { name = "greenback", marker = "extra == 'async'", specifier = ">=1.2.1" },