The life of a request: from fetch to FastAPI response
Follow one request from a React `fetch` through the load balancer, uvicorn, ASGI, middleware, the router and validation to a JSON response β so you can tell from a failure alone which layer killed it.
12 cards Β· 6 quiz questions Β· 10 min read
An agent built you a React dashboard and a FastAPI back end. The dashboard calls fetch("/api/surveys/412/summary"), and most of the time a chart appears. When it doesnβt, you get one of a small set of failures: a red CORS message with no status code, a 422 with an array of complaints, a 500, a 502, or a spinner that dies after four minutes. Each of those comes from a different layer, and you cannot fix them by editing the same file. This topic is a map of those layers, so a failure tells you where to look.
The chain, once through
A browser request to your API passes through roughly this sequence:
- The browser decides whether it is even allowed to send the request, and may send a preflight first.
- DNS and TLS get bytes to Azure.
- A gateway β the App Service front end, Container Apps ingress, or Cloudflare in front of either β proxies the request onward.
- uvicorn, the actual web server, accepts the connection in one worker process.
- uvicorn calls your app over ASGI.
- The middleware stack runs, outermost first.
- The router matches method and path.
- Parameters are parsed and validated; dependencies run.
- Your function runs.
- The return value is filtered through the response model and serialised to JSON.
- The response travels back out through the middleware in reverse order.
ASGI, uvicorn and workers
ASGI (Asynchronous Server Gateway Interface) is the agreed calling convention between a Python web server and a Python web app. The server hands the app a scope dictionary describing the connection plus two callables, receive and send. FastAPI is an ASGI application. It is not a server, which is why python main.py usually does nothing useful and the real entry point looks like this:
# app:app means "the object named app inside app.py"
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# --workers 4 = four separate OS processes, each with its own memory and event loop
A worker is a whole separate process running its own copy of your app. That buys you multiple CPU cores and survives one process dying. It also means anything an agent stored in a module-level dictionary is per process. A cache written by worker 1 is invisible to worker 3, so the same call is sometimes fast and correct and sometimes stale, with no pattern. Shared state belongs in Redis, Postgres, or Cloudflare KV.
Routers and where parameters come from
Routes are usually split into routers β one module per area β and mounted on the app:
# app/routers/surveys.py
from typing import Annotated
from fastapi import APIRouter, Query, status
router = APIRouter(prefix="/surveys", tags=["surveys"])
@router.get("/{survey_id}/summary", response_model=SurveySummary)
async def summary(
survey_id: int, # matches the path placeholder
wave: Annotated[int | None, Query(ge=1)] = None, # query string, validated
) -> SurveySummary:
...
# app/main.py
app.include_router(surveys.router, prefix="/api") # final URL: /api/surveys/412/summary
FastAPI works out each parameterβs source by name and type. A name matching a path placeholder comes from the path; a simple type (int, str, bool) defaults to the query string; a Pydantic model is read from the JSON body. Annotated[..., Query()], Body() and Header() make it explicit, and are worth adding wherever the guess isnβt obvious. If a route you can see in the code 404s, the prefix has been applied twice or not at all β open /docs and read the routes the app genuinely registered.
Middleware order is the opposite of what it looks like
Middleware wraps every request. The trap is the ordering: the FastAPI docs are explicit that the last middleware added is the outermost, so it sees the request first and the response last.
# BEFORE β what an agent often writes
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True)
app.add_middleware(RequestLoggingMiddleware) # added last => OUTSIDE CORS
If the logging middleware raises, its error response never passes through the CORS layer. The browser sees a response with no CORS headers and reports a CORS error β hiding a perfectly ordinary 500. Add CORS last so it ends up outermost:
# AFTER
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True,
allow_methods=["GET", "POST"], allow_headers=["Authorization"])
CORS, which is a browser rule
CORS (cross-origin resource sharing) is enforced by the browser, not by your server. For anything beyond a simple request β an Authorization header, a PUT, a JSON content type β the browser first sends an OPTIONS preflight to the same URL and only sends the real request if the reply permits the origin, method and headers.
This is why a CORS failure looks so strange: the server logs show a 200, and the browser console shows an error with no status and no readable body. The most common broken configuration is this one:
# BEFORE β browsers reject this outright
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
The CORS specification forbids a wildcard origin on credentialed requests, and the FastAPI docs state plainly that none of allow_origins, allow_methods or allow_headers may be ["*"] when allow_credentials=True. Agents emit that block constantly. The tell: cookie auth fails in the browser and works fine in curl, because curl ignores CORS entirely.
Response models and status codes
A response model does more than document the endpoint. It validates the outgoing data and, as the docs stress for security reasons, limits and filters it: fields not declared on the model never reach the client.
# BEFORE β returns whatever the ORM row happens to hold
@router.get("/respondents")
async def list_respondents(db: SessionDep):
return db.scalars(select(Respondent)).all() # email, internal notes, cost_usd... all of it
# AFTER β only the declared fields leave the building
@router.get("/respondents", response_model=list[RespondentOut],
status_code=status.HTTP_200_OK)
async def list_respondents(db: SessionDep):
return db.scalars(select(Respondent)).all()
Use status.HTTP_201_CREATED on creates and status.HTTP_202_ACCEPTED when you have accepted work you have not finished. The status code is the first thing anyone debugging reads; getting it right is free documentation.
Where a request can die
| Symptom | Layer | What it means |
|---|---|---|
| CORS error, no status | Browser | The response was received but withheld from JavaScript |
| 422 | Validation | The caller sent the wrong shape; your code never ran |
| 500 | Your code | Unhandled exception; there is a traceback in the logs |
| 502 Bad Gateway | Gateway β app | The worker crashed, ran out of memory, or isnβt listening on the expected port |
| 504 / timeout | Gateway | Your app was alive but too slow |
A 502 or 504 frequently has no matching application log line, and that absence is itself the diagnosis: the request never got to your code, or your code never got back.
The four-minute wall deserves its own note. Azureβs load balancer has a four-minute idle timeout, so App Service returns a timeout if your app hasnβt responded in roughly 230 seconds. Your Python carries on running, oblivious, and may write to the database long after the caller saw a 504.
# BEFORE β 4,000 documents processed inside one HTTP request
@router.post("/waves/{wave_id}/process")
async def process(wave_id: int):
return summarise_documents(wave_id) # 6 minutes => 504, work continues invisibly
# AFTER β hand back an id, let the dashboard poll
@router.post("/waves/{wave_id}/process", status_code=status.HTTP_202_ACCEPTED)
async def process(wave_id: int, tasks: BackgroundTasks) -> JobRef:
job = await create_job(wave_id)
tasks.add_task(summarise_documents, wave_id, job.id)
return JobRef(job_id=job.id, status="queued")
For work that must survive a restart, a real queue (Azure Storage Queue, Service Bus) beats BackgroundTasks, which lives and dies with the worker process.
What to check in your own code
- Open
/docsand confirm the routes and prefixes match what the front end calls. - Grep for
add_middlewareand check CORS is added last. - Grep for
allow_origins=["*"]next toallow_credentials=True. - Grep for endpoints with no
response_modeland no return annotation that touch the database. - Grep for module-level mutable globals (
CACHE = {},COUNTER = 0) and compare against your--workerscount. - List every endpoint that could take longer than about 30 seconds and confirm it returns a job id rather than a result.
- In Application Insights, check whether your failing requests appear at all. If they donβt, the problem is in front of your code.
The React dashboard shows "Failed to fetch" and the browser console reports a CORS error. The FastAPI logs show the request arrived and returned 200 with correct JSON. What is happening?
Sources
- FastAPI β Middleware website Confirms that the last middleware added is outermost: it sees the request first and the response last.
- FastAPI β CORS (Cross-Origin Resource Sharing) website Source for the CORSMiddleware parameters and the rule that wildcards cannot be combined with allow_credentials=True.
- FastAPI β Response Model / Return Type website States that the response model limits and filters output data, which matters for security.
- Microsoft β Web request times out in Azure App Service website Source for the roughly 230-second platform request timeout (240 seconds on a Linux app) and the advice to move long work to a background job.