feat(api): harden CORS and surface 500/409 errors with proper CORS headers

Three independent improvements that fix real browser-facing bugs:

1. CORS: allow 127.0.0.1:5173 in addition to localhost:5173. Both
   resolve to the same Vite dev server but CORS treats them as distinct
   origins, so tabs opened via the IP form silently break.
2. CORS: support CYCLONE_ALLOWED_ORIGINS env var (comma-separated) for
   LAN / staging hosts. The middleware reads it at module import.
3. Catch-all exception handler: returns JSON 500 with CORS headers
   instead of a bare Uvicorn text/plain response. Without this, any
   unhandled exception is misreported by browsers as a CORS error
   because the body can't be read without the allow-origin header.
4. IntegrityError → 409: when (batch_id, patient_control_number) is
   UNIQUE-constrained and a duplicate collides, return 409 with the
   batch id instead of letting the exception 500. Same problem as (3)
   for the most common ingest failure mode.

Tests added:
- test_cors_headers_present_for_loopback_ip
- test_cors_extra_origins_via_env (uses importlib.reload because the
  allow-list is built at module import)
This commit is contained in:
Tyler
2026-06-21 16:29:59 -06:00
parent c0b7924aad
commit b6607b2009
2 changed files with 137 additions and 6 deletions
+89 -6
View File
@@ -9,8 +9,11 @@ The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
— one envelope, one line per claim/payout, then one summary — so the
UI can render records incrementally as they're produced.
CORS is configured to allow the Vite dev origin (``http://localhost:5173``)
plus GET/POST with any header.
CORS is configured to allow the Vite dev origins (``http://localhost:5173``
and ``http://127.0.0.1:5173`` — both forms are valid dev-server addresses
but CORS treats them as distinct origins) plus GET/POST with any header.
Additional origins (LAN IPs, staging hosts) can be appended via the
``CYCLONE_ALLOWED_ORIGINS`` env var as a comma-separated list.
"""
from __future__ import annotations
@@ -19,6 +22,7 @@ import csv
import io
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from contextlib import asynccontextmanager
@@ -33,6 +37,7 @@ from pydantic import ValidationError
from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
@@ -214,7 +219,17 @@ PAYER_FACTORIES_835: dict[str, Any] = {
"generic_835": PayerConfig835.generic_835,
}
VITE_DEV_ORIGIN = "http://localhost:5173"
# Allow both common dev-server origins. ``localhost`` and ``127.0.0.1``
# resolve to the same Vite dev server but CORS treats them as distinct
# origins, so the allow-list has to list both — otherwise a tab opened
# via the IP form gets blocked even though the dev server is identical.
VITE_DEV_ORIGINS: list[str] = [
"http://localhost:5173",
"http://127.0.0.1:5173",
]
_extra_origins = os.environ.get("CYCLONE_ALLOWED_ORIGINS", "").strip()
if _extra_origins:
VITE_DEV_ORIGINS.extend(o.strip() for o in _extra_origins.split(",") if o.strip())
app = FastAPI(
title="Cyclone 837P / 835 Parser API",
@@ -225,7 +240,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=[VITE_DEV_ORIGIN],
allow_origins=VITE_DEV_ORIGINS,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
@@ -282,6 +297,35 @@ def _resolve_payer_835(name: str) -> PayerConfig835:
return PAYER_FACTORIES_835[name]()
# --------------------------------------------------------------------------- #
# Catch-all exception handler
# --------------------------------------------------------------------------- #
#
# Without this, any exception that escapes a route handler is rendered by
# Uvicorn as a bare ``500 Internal Server Error`` text/plain response with
# NO CORS headers. Browsers reading such a response report it as a CORS
# error (``Origin ... is not allowed by Access-Control-Allow-Origin``)
# because they cannot read the body without the CORS allow-origin header
# — even though the actual failure was on the server. We catch everything
# here, log it, and return a JSON error with the request's Origin echoed
# back so the browser can surface the real message.
@app.exception_handler(Exception)
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
log.exception(
"Unhandled exception in %s %s", request.method, request.url.path
)
headers: dict[str, str] = {}
origin = request.headers.get("origin", "")
if origin:
headers["Access-Control-Allow-Origin"] = origin
headers["Vary"] = "Origin"
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
headers=headers,
)
@app.post("/api/parse-837")
async def parse_837(
request: Request,
@@ -345,7 +389,30 @@ async def parse_837(
parsed_at=utcnow(),
result=result,
)
store.add(rec, event_bus=request.app.state.event_bus)
try:
store.add(rec, event_bus=request.app.state.event_bus)
except IntegrityError as exc:
# ``(batch_id, patient_control_number)`` is UNIQUE — fires when a
# single batch file contains the same CLM01 control number twice,
# or when the same claim id has already been ingested in a prior
# batch. Surface as 409 with the batch id so the caller can look
# it up; do NOT 500 (a 500 without CORS headers is misreported by
# browsers as a CORS error and hides the real cause).
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
return JSONResponse(
status_code=409,
content={
"error": "Duplicate claim",
"detail": (
"This file (or one previously ingested with the same "
"claim control number) collides with an existing "
"record. Inspect the file for duplicate CLM01 "
"control numbers, or remove the existing batch "
"before retrying."
),
"batch_id": rec.id,
},
)
if _client_wants_json(request):
body = json.loads(result.model_dump_json())
@@ -516,7 +583,23 @@ async def parse_835_endpoint(
parsed_at=utcnow(),
result=result,
)
store.add(rec, event_bus=request.app.state.event_bus)
try:
store.add(rec, event_bus=request.app.state.event_bus)
except IntegrityError as exc:
log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
return JSONResponse(
status_code=409,
content={
"error": "Duplicate remittance",
"detail": (
"This 835 file (or one previously ingested with the "
"same payer claim control number) collides with an "
"existing record. Remove the existing remittance "
"before retrying."
),
"batch_id": rec.id,
},
)
if _client_wants_json(request):
body = json.loads(result.model_dump_json())