Skip to content

[BUG] RuntimeError: No active request in context on catchall route (FastAPI backend) #3812

@WheatMaThink

Description

@WheatMaThink

Bug Report

Summary

When using Dash with the FastAPI backend, accessing any non-Dash HTTP path (e.g. /login, /about, or any custom route) that falls through to the catchall route raises a RuntimeError: No active request in context, causing a 500 Internal Server Error.

Affected Version

  • Dash version: 4.2.0+ (including latest main branch as of 2026-06-11)
  • Python version: 3.12
  • Backend: FastAPI
  • OS: Linux

Description

The DashMiddleware in dash/backends/_fastapi.py only sets the request context (set_current_request) for Dash-specific routes (_dash-* paths and the root prefix). However, the _setup_catchall() method registers a {path:path} catch-all route that matches all GET paths and calls dash_app.index(), which internally requires a request context via get_current_request().

When a non-Dash path is requested, the middleware skips setting the request context and passes through. The catchall route then tries to render dash_app.index() without a context, triggering the RuntimeError.

Steps to Reproduce

  1. Create a Dash app with FastAPI backend:
from fastapi import FastAPI
from dash import Dash, html
from starlette.testclient import TestClient

fastapi_app = FastAPI()
dash_app = Dash(__name__, server=fastapi_app)
dash_app.layout = html.Div("Hello World")

client = TestClient(fastapi_app)
  1. Access the root path — works correctly:
resp = client.get("/")
assert resp.status_code == 200  # ✅ OK
  1. Access any non-Dash path — crashes:
resp = client.get("/login")
# ❌ RuntimeError: No active request in context

Expected Behavior

The catchall route should successfully render the Dash index HTML for all paths, allowing Dash's client-side router to handle navigation (e.g., redirect to login page, show 404, etc.).

Actual Behavior

RuntimeError: No active request in context

Traceback (most recent call last):
  File ".../fastapi/routing.py", line 134, in app
    await wrap_app_handling_exceptions(app, request)(scope, receive, send)
  ...
  File ".../dash/backends/_fastapi.py", line 333, in catchall
    return Response(content=dash_app.index(), media_type="text/html")
                            ^^^^^^^^^^^^^^^^
  File ".../dash/dash.py", line 1269, in index
    request = self.backend.request_adapter()
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../dash/backends/_fastapi.py", line 837, in __init__
    self._request: Request = get_current_request()
                             ^^^^^^^^^^^^^^^^^^^^^
  File ".../dash/backends/_fastapi.py", line 109, in get_current_request
    raise RuntimeError("No active request in context")

Root Cause Analysis

In dash/backends/_fastapi.py, DashMiddleware.__call__ has the following flow (current latest code on main):

async def __call__(self, scope, receive, send):
    # ... lifespan / non-HTTP handling omitted ...

    # Non-Dash routes pass through WITHOUT setting request context
    path = scope["path"]
    prefix = self.dash_app.config.routes_pathname_prefix
    dash_prefix = prefix.rstrip("/") + "/_dash-"
    if (
        not path.startswith(dash_prefix)
        and path != prefix
        and path != prefix.rstrip("/")
    ):
        await self.app(scope, receive, send)  # ← no context set!
        return

    # Only Dash-specific routes get context
    request = Request(scope, receive=receive)
    token = set_current_request(request)
    try:
        # ... timing, hooks, etc ...
    finally:
        reset_current_request(token)

Meanwhile, _setup_catchall() registers a catch-all route:

def _setup_catchall(self):
    dash_app = get_app()

    async def catchall(_request: Request):
        return Response(content=dash_app.index(), media_type="text/html")
        #                        ^^^^^^^^^^^^^^^^^
        # dash_app.index() internally calls get_current_request()
        # which requires the context to be set by DashMiddleware

    self.add_url_rule("{path:path}", catchall, methods=["GET"])

The gap: When a non-Dash path (e.g. /login) is requested:

  1. DashMiddleware sees it's not a Dash path → skips setting request context → passes through
  2. FastAPI router matches the catchall route → calls dash_app.index()
  3. dash_app.index()get_current_request()RuntimeError (no context was set)

Suggested Fix

Move Request creation and set_current_request() before the non-Dash path check, so that all HTTP requests have a request context available when the catchall route is reached:

async def __call__(self, scope, receive, send):
    # ... lifespan / non-HTTP handling omitted ...

    # Set request context for ALL HTTP requests (needed by catchall route
    # which can match non-Dash paths and still call dash_app.index())
    request = Request(scope, receive=receive)
    token = set_current_request(request)

    # Non-Dash routes pass through to avoid consuming body stream
    path = scope["path"]
    prefix = self.dash_app.config.routes_pathname_prefix
    dash_prefix = prefix.rstrip("/") + "/_dash-"
    if (
        not path.startswith(dash_prefix)
        and path != prefix
        and path != prefix.rstrip("/")
    ):
        await self.app(scope, receive, send)
        reset_current_request(token)
        return

    # Dash route handling with timing, hooks, error handling
    try:
        await self._setup_timing(request)
        await self._run_before_hooks()
        await self.app(scope, receive, send)
        await self._run_after_hooks()
        self._finalize_timing(request)
    except Exception as e:
        await self._handle_error(e, scope, receive, send)
    finally:
        reset_current_request(token)

Key change: The Request object creation and set_current_request(request) call are moved above the non-Dash path check. This ensures that even when a request passes through to the catchall route, the request context is available for dash_app.index() to use.

Related Commit

The path check was improved in 6b2de3f (changing from "_dash-" in path to not path.startswith(dash_prefix)), but the underlying issue — missing request context for non-Dash paths that hit the catchall — was not addressed and remains in the latest main branch.

Additional Context

This bug manifests in real-world Dash applications that use client-side routing with the FastAPI backend. Pages like /login, /dashboard, /settings, etc. all trigger this error because they are not Dash-specific paths but still need to be served by the catchall route so that Dash's client-side router can take over.

Why this fix is safe for custom FastAPI routes (POST body stream)

The original code comment says "Non-Dash routes pass through to avoid consuming body stream". The concern was that Request(scope, receive=receive) might consume the request body, which would cause await request.json() in custom POST routes to hang (body already consumed).

This concern is unfounded. Request(scope, receive=receive) does not read the body. It only wraps the ASGI scope and receive callable. Body reading happens only when await request.body() or await request.json() is explicitly called.

In the suggested fix:

  • Request(scope, receive=receive)does not read body, only wraps scope
  • set_current_request(request)does not read body, only sets a context variable
  • _setup_timing(request)await request.json()does read body, but only runs for Dash-specific routes

So custom FastAPI POST routes like @fastapi_app.post("/api/echo") are completely unaffected — the body stream remains available for the route handler to consume.

Verified with the following test case:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from dash import Dash, html
from starlette.testclient import TestClient

fastapi_app = FastAPI()
dash_app = Dash(__name__, server=fastapi_app)
dash_app.layout = html.Div("Dash is running")

@fastapi_app.get("/api/echo")
async def echo_get():
    return JSONResponse({"method": "GET", "ok": True})

@fastapi_app.post("/api/echo")
async def echo_post(request: Request):
    body = await request.json()  # ← works correctly, body not consumed by middleware
    return JSONResponse({"echo": body})

client = TestClient(fastapi_app)
assert client.post("/api/echo", json={"msg": "hello"}).json() == {"echo": {"msg": "hello"}}
assert client.get("/api/echo").json() == {"method": "GET", "ok": True}
assert client.get("/login").status_code == 200  # catchall works, no RuntimeError

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions