πŸͺ΄ GoDeep
Search
← Shipping Software

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:

  1. The browser decides whether it is even allowed to send the request, and may send a preflight first.
  2. DNS and TLS get bytes to Azure.
  3. A gateway β€” the App Service front end, Container Apps ingress, or Cloudflare in front of either β€” proxies the request onward.
  4. uvicorn, the actual web server, accepts the connection in one worker process.
  5. uvicorn calls your app over ASGI.
  6. The middleware stack runs, outermost first.
  7. The router matches method and path.
  8. Parameters are parsed and validated; dependencies run.
  9. Your function runs.
  10. The return value is filtered through the response model and serialised to JSON.
  11. 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

SymptomLayerWhat it means
CORS error, no statusBrowserThe response was received but withheld from JavaScript
422ValidationThe caller sent the wrong shape; your code never ran
500Your codeUnhandled exception; there is a traceback in the logs
502 Bad GatewayGateway β†’ appThe worker crashed, ran out of memory, or isn’t listening on the expected port
504 / timeoutGatewayYour 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 /docs and confirm the routes and prefixes match what the front end calls.
  • Grep for add_middleware and check CORS is added last.
  • Grep for allow_origins=["*"] next to allow_credentials=True.
  • Grep for endpoints with no response_model and no return annotation that touch the database.
  • Grep for module-level mutable globals (CACHE = {}, COUNTER = 0) and compare against your --workers count.
  • 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.

Sources