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.
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:
- 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. - FastAPI's OpenAPI schema says
application/jsonregardless. A route returning a bareResponsestill 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. - Every callsite had already silently compensated. Because
fetchJsonhanded back a raw string, the frontend code readJSON.parse(resp.data)— which is the visible fingerprint of this bug.JSON.parseon a field the generated types call an object is always a tell that the endpoint's content-type is wrong. - 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. BareResponsereturning 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), useJSONResponse(...)and declareresponse_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 checkstatus === 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 onresp.statusfirst, 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.