Skip to content

feat(controller): serve the API with FastAPI and Uvicorn - #665

Merged
KFilippopolitis merged 10 commits into
masterfrom
fix/controller-modernization-wave3
Nov 12, 2026
Merged

KFilippopolitis merged 10 commits into
masterfrom
fix/controller-modernization-wave3

Conversation

@KFilippopolitis

@KFilippopolitis KFilippopolitis commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #664. Replaces Quart and Hypercorn with FastAPI and Uvicorn. Data bodies are unchanged. After review, a malformed body is answered with 422 and content types changed (see Compatibility). Needs Medical-Informatics-Platform/platform-backend#55.

Commits

  1. One error table (refactor(controller)): the hand-written handlers are one ERROR_RESPONSES table of (status, log level), with one handler registered per entry. ControllerShuttingDownError stays 503. InsufficientDataError still returns its fixed message. HTTPStatusCode.UNEXPECTED_ERROR and the commented generic handler are gone.

  2. Analysis service (refactor(controller)): exaflow.controller.services.analysis_service.run_analysis owns validation, strategy selection, analysis_slot(), and execute(). It takes the validated AnalysisRequestDTO.

  3. FastAPI (feat(controller)): Quart is replaced with FastAPI. Hypercorn still serves. Routes, status codes, content types, and bodies stay the same. The OpenAPI and docs routes are disabled.

  4. Uvicorn (chore(controller)): Hypercorn is replaced with Uvicorn. See below. FastAPI under Hypercorn does not exit after SIGTERM while an analysis runs, so serving changes in this commit.

  5. Analysis thread join (fix(controller)): the shutdown hook joins only the analysis threads of the pool it stops.

  6. Log config path (fix(tasks)): tasks.py passes the Uvicorn log config by absolute path.

  7. Test client (test(controller)): the tests call the app through a plain httpx.AsyncClient. The client class with the Quart response methods and app.test_client are removed.

  8. Model validation (refactor(controller)): /analysis declares AnalysisRequestDTO as its body. A body that fails validation is answered with 422. The service takes the DTO.

  9. Response types (refactor(controller)): the specification routes return their DTOs. Data is application/json, errors are text/plain. html_response is removed.

  10. Body limit (feat(controller)): a middleware answers 413 for a request body over 16 MiB.

Commits 5 to 10 are the review changes.

Each of the first two commits passes the standalone suite on its own. Commit 3 (FastAPI under Hypercorn) keeps the HTTP contract tests green.

FastAPI

exaflow.controller.http.app is a FastAPI app with docs_url=None, redoc_url=None, and openapi_url=None, so /docs, /redoc, and /openapi.json return 404. /analysis declares AnalysisRequestDTO as its body, so FastAPI validates it. RequestValidationError (a body that is not JSON or does not match the model) is served with FastAPI's status, 422, and a plain-text body like the other errors. The /specifications/* routes return their DTOs. A middleware limits the request body to 16 MiB and answers 413 above it. The lifespan keeps the startup and shutdown order: start the landscape and the analysis pool; on shutdown, cancel the analysis RPCs, stop the landscape, and shut the pool down in finally. The Flower routes are ported unchanged. fastapi 0.115.14 and starlette 0.46.2 are added, httpx 0.28.1 for tests. Quart is removed.

Uvicorn

Image command, exec form:

python -m uvicorn exaflow.controller.http.app:app --host 0.0.0.0 --port 5000 --workers 1 --timeout-graceful-shutdown 120 --log-config exaflow/controller/http/log_config.json

  • --workers 1 serves in-process on Uvicorn 0.32.1 (a supervisor starts only when workers > 1), so the process Kubernetes signals is the one serving.
  • --timeout-graceful-shutdown 120 is the drain. At drain expiry Uvicorn cancels the request tasks still running and starts the lifespan shutdown hook without waiting for them. A cancelled request still waits for its analysis thread (run_blocking), and the hook calls begin_controller_shutdown(), which cancels the registered RPCs. This is the same shutdown design as under Hypercorn. There is no signal-handler chaining, so a client disconnect during the drain still cancels nothing.
  • After a clean shutdown Uvicorn re-raises the SIGTERM it handled, so the process exits with status -15 (143 in a container).
  • The shutdown budget stays in exaflow/controller/services/controller_shutdown.py. exaflow/controller/http/server_config.py holds only workers and graceful_timeout. tasks.py repeats those two values, with no exaflow import, and a test keeps them equal.
  • hypercorn and hypercorn_config.py are removed. tasks.py starts and stops Uvicorn. The Helm comments point at the new modules.

Compatibility

  • HTTP API: routes, status codes, and data bodies are unchanged. /docs, /redoc, and /openapi.json are not served. Changed:
    • /specifications/* and /analysis results are application/json, not text/html.
    • Error responses are text/plain, not text/html.
    • A body that is not JSON or does not match the request schema is answered with 422, not 400. The message starts with Request malformed: and lists the invalid fields. It no longer echoes the request. 400 stays for the other request errors.
    • A request body over 16 MiB is answered with 413.
    • /analysis and /flower/result need Content-Type: application/json.
  • platform-backend: it must accept 422 from the controller (fix(analysis): show the Exaflow message for a 422 response Medical-Informatics-Platform/platform-backend#55). Deploy that first or together. An older backend answers 500 for a malformed analysis request.
  • Controller image: the command changes from Hypercorn to Uvicorn. The process now exits with status -15 after a graceful SIGTERM, not 0.
  • Helm: values and the rendered pod grace period are unchanged (220 s at the chart defaults: 5 preStop + 120 drain + 5 RPC cancel + 10 cleanup + 60 hook + 20 margin). It does not include worker_tasks_timeout. The chart comments changed. This needs the human review required by kubernetes/AGENTS.md.
  • Dependencies: Quart and Hypercorn removed; FastAPI, Starlette, Uvicorn, and httpx (dev) added. uv.lock updated.

Testing

  • pytest -q -n auto --dist worksteal -m "not smpc" tests/standalone_tests on each commit, each with the dependencies of its own lockfile: 2466, 2468, and 2472 passed, 30 warnings.
  • test_uvicorn_shutdown.py starts a real Uvicorn process with the controller settings (drain shortened to 3 s). It checks that an analysis running at drain expiry has its RPC cancelled, that its cleanup still runs, and that a client disconnect during the drain does not cancel other analyses. It passed three runs in a row.
  • After the review changes: pytest -q tests/standalone_tests/controller, 345 passed. The 8 Helm-rendering tests in test_controller_probes.py were not rerun locally.
  • Open: the Standalone Tests workflow stopped with exit code 143 on an earlier head of the review changes, after its last tests hung. Under investigation.
  • Prod-env kind tests were not run locally; CI covers them.

Needs human review

  • Uvicorn does not abort the lifespan shutdown itself. The 60 s hook budget exists only in the pod grace period. The hook bounds its own joins.
  • /livez fails once the listen socket closes. That relies on kubelet stopping liveness probes of a terminating pod.
  • log_config.json is static. Startup dictConfig then applies the federation- and node-aware format from loggers.py.

Rollback

Revert the PR, and roll back the controller image together with the chart.

Replace Hypercorn with Uvicorn. Uvicorn runs in-process (workers 1), so
the process Kubernetes signals is the one serving. At drain expiry
Uvicorn cancels the request tasks and starts the lifespan hook without
waiting for them, which keeps the shutdown design: the hook cancels the
analysis RPCs, and the budget in controller_shutdown.py still sizes the
pod grace period. Uvicorn re-raises the handled SIGTERM after a clean
shutdown, so the exit status is -15.
@KFilippopolitis
KFilippopolitis force-pushed the fix/controller-modernization-wave3 branch 3 times, most recently from 29ef6b7 to b4239ec Compare November 11, 2026 16:31
Base automatically changed from fix/controller-modernization-wave2 to master November 11, 2026 16:32
@KFilippopolitis
KFilippopolitis force-pushed the fix/controller-modernization-wave3 branch 2 times, most recently from 7f98679 to f3f594d Compare November 11, 2026 17:22
@KFilippopolitis
KFilippopolitis marked this pull request as ready for review November 11, 2026 17:22

@ThanKarab ThanKarab left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work!

Please improve a bit the endpoint validations to be FastAPI native and NOT quart or test compatible.

Comment thread exaflow/controller/http/app.py Resolved
Comment thread exaflow/controller/http/endpoints.py Resolved
@ThanKarab

ThanKarab commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

GPT6.1Sol XHigh Review:

The migration introduces an unbounded request-body allocation and changes malformed JSON errors from 400 to 500. The two analysis-service tests passed, but HTTP tests were blocked by unavailable dependencies.

Full review comments:

- [P1] Restore a bounded HTTP request-body size — /home/thanasis/exaflow/exaflow/controller/http/app.py:18-23
  For large JSON requests, this migration removes Quart’s default 16 MiB body limit without replacing it. Neither FastAPI nor the Uvicorn configuration imposes an equivalent limit, so request.json() now buffers and decodes arbitrarily large payloads before the analysis concurrency limit applies. This exposes the controller to memory exhaustion. Add a bounded body reader or middleware that also handles chunked uploads and returns 413 when the limit is exceeded.
- [P2] Translate JSON decoding failures into a 400 response — /home/thanasis/exaflow/exaflow/controller/http/endpoints.py:147-147
  An empty or syntactically invalid application/json body, such as {, makes request.json() raise JSONDecodeError. Because decoding happens outside FastAPI’s body validation, the registered RequestValidationError handler does not catch it, and the service’s DTO validation is never reached. These requests consequently return 500 instead of the previous, documented 400. Catch decoding failures at this boundary and add raw-body regression tests.

@KFilippopolitis

KFilippopolitis commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Both findings of the GPT review are addressed:

  • [P1] Request body size: fixed in 84d2438. RequestBodyLimitMiddleware counts the body as the route reads it and answers 413 above 16 MiB (the limit Quart applied). It counts received bytes, not Content-Length, so chunked uploads are limited too. test_http_contract.py covers a sized body, a chunked body, and a body exactly at the limit.
  • [P2] Malformed JSON answered 500: fixed in 3476ea0. With the body declared as a model, FastAPI raises RequestValidationError for a body that is not JSON, and the error table maps it to 422. Raw-body tests cover not-json, { and an empty body on /analysis and /flower/result. The review asked for 400; 422 is FastAPI's status for this case, and fix(analysis): show the Exaflow message for a 422 response Medical-Informatics-Platform/platform-backend#55 makes the backend accept it.

The branch was rewritten into one commit per task, so the earlier commit hashes in this thread no longer exist.

Contract changes, also listed in the PR description: a malformed body is answered with 422 and a message that starts with Request malformed:, data responses are application/json, and error responses are text/plain.

Known open point: the Standalone Tests workflow stopped with exit code 143 on the previous head after the last tests hung. That is being investigated and is not fixed yet.

@ThanKarab
ThanKarab self-requested a review November 12, 2026 11:51
Map each controller error to its HTTP status and log level in
ERROR_RESPONSES and register one handler per entry, instead of a
hand-written handler for each error. Responses are unchanged.
The /analysis route only reads the body. analysis_service.execute_analysis
parses the request, validates it, and runs the strategy, so the route no
longer holds the orchestration.
Replace Quart with FastAPI. Hypercorn still serves. Routes, status
codes, content types, and bodies stay the same. The OpenAPI and docs
routes are disabled.
@KFilippopolitis
KFilippopolitis force-pushed the fix/controller-modernization-wave3 branch from 84d2438 to 0fdb548 Compare November 12, 2026 12:03
…pped

A request cancelled at drain expiry runs its cleanup through run_blocking
after the shutdown hook cleared the pool, which starts a new pool with the
same thread prefix. The join counted that pool's idle thread, waited the
whole SHUTDOWN_RPC_CANCEL_TIMEOUT and warned that analysis threads were
still running. It made test_uvicorn_shutdown.py fail under load.

The hook now snapshots the threads under the state guard and joins only
those.
The path was relative to the working directory, so `inv start-controller`
found the file only when it ran from the repository root.
The controller tests used a client class that copied the Quart response
methods (get_data, content_type), reached through app.test_client. They now
use httpx.AsyncClient over ASGITransport and read response.text and
response.headers.

The client stays async: several tests send concurrent requests on one event
loop, for example /livez while an analysis blocks.

The client module moves from exaflow/controller/http into the tests, and
app.test_client is removed.
POST /analysis declares AnalysisRequestDTO as its body and POST
/flower/result declares its body with Body(), so FastAPI parses and
validates them. analysis_service.run_analysis takes the DTO. It no longer
takes the callables that tests patched on the route module; tests patch the
service module.

A body that is not JSON or does not match the model is answered with
FastAPI's status, 422. The body stays plain text like the other errors,
starts with "Request malformed:" and lists the invalid fields.

Breaking: this case was answered with 400 and the message
"Analysis execution request malformed:". platform-backend must accept 422
from the controller before this is deployed.
The specification routes return their DTOs. The analysis result is served
as application/json. /wla, /healthcheck and /livez return an empty response.
Error responses are text/plain.

The text/html responses kept from Quart, and the html_response helper, are
removed.

The analysis route still builds a Response: each algorithm has its own
result model and the strategy returns it already serialized.

Breaking: the Content-Type of the specification, analysis and error
responses changed from text/html.
Uvicorn does not limit the request body, so a large POST was buffered whole
before the analysis concurrency limit applied. Quart limited it to 16 MiB.

A middleware counts the body as the route reads it and answers 413 above
the limit. It counts received bytes, not Content-Length, so chunked uploads
are limited too.
@KFilippopolitis
KFilippopolitis force-pushed the fix/controller-modernization-wave3 branch from 0fdb548 to 95a2ee9 Compare November 12, 2026 13:17
@KFilippopolitis
KFilippopolitis merged commit 47e0c39 into master Nov 12, 2026
5 checks passed
@KFilippopolitis
KFilippopolitis deleted the fix/controller-modernization-wave3 branch November 12, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants