Skip to content

DRU-1149 -- SPA fallback breaks on FastAPI 0.162: Accept: */* no longer serves index.html - #1169

Merged
czpython merged 1 commit into
mainfrom
agent/DRU-1149
Nov 10, 2026
Merged

czpython merged 1 commit into
mainfrom
agent/DRU-1149

Conversation

@druks-ai

@druks-ai druks-ai Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

What changed

A browser navigation to a dashboard client route still gets index.html. FastAPI serves that fallback only to a navigation request. FastAPI 0.162 no longer counts a bare Accept: */* as one, and TestClient sends */* by default. The two /work_items/42 requests in backend/tests/test_spa_serving.py now send Accept: text/html, as a browser navigation does. The serve_spa docstring states that the index.html fallback needs that header. The test module also imports Session at module scope.

On FastAPI 0.162.0, the unchanged tests get {"detail":"Not Found"} for /work_items/42 and fail. The updated file passes on the locked 0.139.0 and on 0.162.0, so the Dependabot bump in PR #1165 can land green. The fix stays in the tests and the docstring. The FastAPI pin, the app.frontend() arguments, SpaCacheControl, and the /api/* JSON 404 catch-all do not change.

Plan

Make one small PR correcting the SPA navigation requests and documenting their requirement.

Verified at HEAD: the two named tests in backend/tests/test_spa_serving.py request /work_items/42 without a navigation header. serve_spa calls app.frontend("/", directory=dist), and uv.lock contains FastAPI 0.139.0. The full ticket and its one existing understanding comment were read; no operator refinement changes the requested approach. The ticket has no assignee.

  1. Add Accept: text/html individually to the /work_items/42 requests in test_spa_serves_index_assets_and_client_routes and test_cache_policy_lives_with_the_server. Preserve their content and cache assertions. Keep the other requests, behavior, and assertions unchanged.
  2. Hoist from sqlalchemy.orm import Session from the database-session test to module scope. This addresses the function-level-import tell required by .druks/software_factory/review.md, without changing the test's behavior.
  3. In backend/druks/api/server.py, split the opening serve_spa docstring explanation into short sentences. Add the requested one-line navigation requirement as one short active sentence in the repository's simplified technical English. Describe only the end-state requirement: the index.html fallback requires a navigation request with an Accept: text/html header. Do not mention FastAPI versions or before/after behavior. Any test comment added must likewise explain an end-state reason.
  4. Before finishing, read both touched files end to end and run the tell-greps from .druks/software_factory/review.md. Registered exception handlers are framework callbacks, not one-caller helper abstractions.

Verification: backend/tests/conftest.py has a session-scoped autouse _reset_test_database fixture, so even this focused file needs Postgres. Use druks.testing.TEST_DATABASE_URL, which reads DRUKS_TEST_DATABASE_URL and defaults to the local druks_test database. If a matching database is reachable or can be started using deploy/compose.dev.yaml, run the focused file with the locked environment and with uv run --with fastapi==0.162.0 pytest backend/tests/test_spa_serving.py. The temporary override must leave dependency declarations and lockfiles unchanged. If Postgres cannot be made available, report the exact command and blocker as not_run; do not bypass or alter the fixtures.

As a database-free fallback, use an uncommitted throwaway script outside the repository under uv run --with fastapi==0.162.0. Set a temporary valid DRUKS_SECRETS_KEY before importing druks.api.server. Construct the SPA app as _app_with_spa does and add SpaCacheControl. Check that GET /work_items/42 with bare Accept: */* returns 404; with Accept: text/html it returns 200, index.html, and cache-control: no-cache; and unknown /api/* paths return JSON 404 with either header. Record this as isolated compatibility evidence, not a substitute for the focused suite. PR #1165's CI is the real FastAPI 0.162 gate.

Scope boundary: “Do not cap the FastAPI version — the goal is to let the 0.162 bump (PR #1165) land green.” The dependency bump remains separate. This PR changes tests and a docstring only. No implementation edits or test runs were performed during this planning pass.

Acceptance Criteria

  • AC1: Both /work_items/42 requests explicitly send Accept: text/html. The existing index.html content assertion and no-cache assertion remain.
    • Verification: Inspect test_spa_serves_index_assets_and_client_routes and test_cache_policy_lives_with_the_server in backend/tests/test_spa_serving.py.
  • AC2: The API, root, asset, no-build, and database-session test behavior and assertions remain unchanged. Unknown /api/* paths retain the existing JSON 404 handling.
    • Verification: Inspect the test diff and unchanged API catch-all. A focused test result on FastAPI 0.162.0 is supporting evidence if obtainable, not a sandbox-dependent acceptance gate.
  • AC3: The sqlalchemy.orm.Session import moves from inside test_request_that_never_touches_the_db_opens_no_connection to module scope without changing that test's behavior or assertions.
    • Verification: Inspect the import relocation in backend/tests/test_spa_serving.py.
  • AC4: The serve_spa docstring includes one short active sentence stating that the index.html fallback requires a navigation request with an Accept: text/html header. Its existing opening explanation is split into short sentences while preserving its meaning. The docstring contains no FastAPI version or before/after narrative.
    • Verification: Inspect the docstring diff in backend/druks/api/server.py; exact prose is not prescribed.
  • AC5: The diff contains only the two request-header changes, the Session import hoist, and the serve_spa docstring edits. Dependency declarations, lockfiles, app.frontend() arguments, SpaCacheControl, and server executable code remain unchanged.
    • Verification: Inspect the complete diff and changed-file list.

FastAPI serves the index.html fallback only to a navigation request.
FastAPI 0.162 no longer counts a bare Accept: */* as one. The two
/work_items/42 requests now send Accept: text/html, as a browser
navigation does. The serve_spa docstring states this requirement. The
test module imports Session at module scope.

Co-Authored-By: paulo@druks.ai <paulo@druks.ai>

@druks-ai druks-ai Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verdict: approve, with one non-blocking finding (comment).

The PR does what it says, and all five acceptance criteria hold. Both /work_items/42 requests send Accept: text/html and keep their content and no-cache assertions. The Session import moved to module scope. The serve_spa docstring is now in short sentences, states the navigation requirement, and names no FastAPI version. Nothing else changed.

What I checked:

  • FastAPI source. In 0.162.0, _is_frontend_navigation_request (fastapi/routing.py) serves the index.html fallback only when Accept lists text/html or application/xhtml+xml with q > 0. In 0.139.0 it also accepted */*. The new docstring sentence matches the 0.162 rule.
  • PR #1165 CI. All four backend jobs fail on these two tests and nothing else (2 failed, 2634 passed).
  • Local runs against Postgres. The updated file passes on the locked 0.139.0 and on 0.162.0 (5 passed each time). The file from main fails the same two tests on 0.162.0.
  • review.md tell-greps on both touched files. Clean. The remaining _-prefixed defs in server.py are exception handlers and a lifespan callback, so they are framework callbacks, not one-caller helpers.
  • Production callers. Caddy passes Accept through. Browser navigations and the OAuth redirect to /app/<name>/... send text/html. The deploy health checks hit /health, not a client route. No production client relies on a */* request getting the fallback.

One finding, inline on test_api_paths_win_over_the_spa. After the 0.162 bump, this test passes without the catch-all it is meant to protect. The fix is the same one-header change that this PR applies to the other two tests.

assert "druks" in client.get("/work_items/42", headers={"Accept": "text/html"}).text


def test_api_paths_win_over_the_spa(tmp_path):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

medium. After the 0.162 bump, this test passes without the code it protects. TestClient sends Accept: */*. On 0.162, FastAPI never serves the index.html fallback for that header. So /api/nope returns 404 whether the /api/{path:path} catch-all exists or not.

I checked this: I deleted api_not_found from _app_with_spa and ran the file. On 0.139.0, this test fails, as it should. On 0.162.0, all 5 tests pass. The case the catch-all protects, which the comment on line 40 describes, is a browser navigation to an unknown /api/* path. Line 41 needs the same header as the client-route requests:

assert client.get("/api/nope", headers={"Accept": "text/html"}).status_code == 404

This goes past AC2, which kept this test unchanged. But it has the same cause and the same fix as AC1, and review.md item 14 requires a test to fail when the code it guards is gone. With the header, the test fails without the catch-all on both 0.139 and 0.162.

@druks-ai
druks-ai Bot marked this pull request as ready for review November 10, 2026 14:41
@druks-ai
druks-ai Bot requested a review from czpython as a code owner November 10, 2026 14:41
@czpython
czpython merged commit 1da86b1 into main Nov 10, 2026
6 checks passed
@czpython
czpython deleted the agent/DRU-1149 branch November 10, 2026 14:49
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.

1 participant