Skip to content

FastAPI/Starlette: 502 error on OAuth/email login due to Content-Type check in oazapfts fetch wrapper

A transport-level guard that rejects 2xx API responses without a JSON content-type took down OAuth login and email login in production, one week after shipping. The symptom is maximally misleading: the button does nothing, the browser console shows a 502, and Sentry records HTTPResponseError: HTTP response error (status: 502) on the login route with handled: no — but the server returned 200 with a perfectly valid JSON body. Pasting the OAuth URL out of that body into the address bar works fine, which makes it look like a CDN or upstream problem rather than a client-side synthesized error.

Stack: FastAPI/starlette backend, SvelteKit frontend, oazapfts-generated client. The guard wraps oazapfts defaults.fetch and throws a 502 when a 2xx response's content-type does not contain json (it exists because fetchJson returns data as a RAW STRING for non-JSON content types, and null/undefined for empty or unreadable bodies — all typed as the success DTO).

The endpoints it killed were our own, and they had been lying about their content-type since the day they were written.

1 solution
ranked by outcome — not votes
Accepted

Root cause: starlette.responses.Response(content=...) with no media_type emits NO Content-Type header at all. Its subclasses set one (JSONResponse -> application/json, PlainTextResponse -> text/plain), but the base class does not. So a hand-rolled return Response(content=json.dumps(payload)) ships valid JSON with a missing content-type. Response(status_code=200) with no body does the same.

What makes this invisible for years and then explosive:

  1. Locally you see no content-type; in production you see text/plain; charset=utf-8. Something downstream (proxy/CDN — observed with Cloudflare in front of Render) stamps a default on the header-less response. So the local repro and the prod symptom do not even look like the same bug.
  2. FastAPI's OpenAPI schema says application/json regardless. A route returning a bare Response still documents a JSON response, so the generated client, the schema diff, and any contract test all agree the endpoint is JSON. Only the wire disagrees.
  3. Every callsite had already silently compensated. Because fetchJson handed back a raw string, the frontend code read JSON.parse(resp.data) — which is the visible fingerprint of this bug. JSON.parse on a field the generated types call an object is always a tell that the endpoint's content-type is wrong.
  4. Empty 200s trip the same branch. return Response(status_code=200) (a common "ack" pattern) has an empty body AND no content-type, so any such endpoint 502s the instant the guard goes live. The guard exempts !response.ok, 204 and 205 — but not an empty 200.

Fix, in order:

  • Audit before installing the guard, not after. grep -rn 'Response(content=\|Response(status_code=' <backend> and check every hit reachable from the generated client. Bare Response returning JSON or an empty 200 is the whole defect set; 3xx and 204/205 are exempt.
  • Fix the routes, not the guard. Return a typed response model (-> MyOVO) so FastAPI serializes and sets the header. Where the handler must keep an explicit response object (e.g. to mutate cookies), use JSONResponse(...) and declare response_model= on the decorator. Give no-payload endpoints a real body ({"status": "success"}) rather than switching them to 204 — changing the status code breaks already-deployed mobile clients that check status === 200, while adding a body to an empty response is inert to them.
  • Then delete the compensating JSON.parse(resp.data) at every callsite and narrow on resp.status first, since the generated type is a discriminated union.

Measuring blast radius in one query: make the guard set a searchable tag, not just a fingerprint. Ours sets fetch_url and malformed_reason, so has:fetch_url in Sentry plus a Counter over the sampled events' tags returned the exact endpoint list and per-reason breakdown immediately, and revealed a second broken login path (magic-link email) that no human had reported yet. A guard that only suppresses or only throws gives you no way to ask this question.

Generalization: any client-side transport guard that hard-fails on a response header is a live grenade for endpoints whose framework omits that header. Before enabling one globally, enumerate your own server's responses on the wire (curl -sD-), never from the OpenAPI schema — the schema is generated from your type annotations and cannot see a bare Response.